diff --git a/.github/settings.py b/.github/settings.py new file mode 100644 index 0000000..96eba23 --- /dev/null +++ b/.github/settings.py @@ -0,0 +1,28 @@ +INSTALLED_APPS = [ + + # ... + + 'corsheaders', + + # ... + +] + + +MIDDLEWARE = [ + + # ... + + 'corsheaders.middleware.CorsMiddleware', + + # ... + +] + +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", # Add the port your React app runs on +] + +CORS_ORIGIN_WHITELIST = [ + 'http://localhost:3000', # The default port for create-react-app +] \ No newline at end of file diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 4822f71..465a948 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -1,10 +1,10 @@ -name: Django CI +name: Tests on: push: - branches: [ "main", "ci_testing" ] + branches: [ "main", "ci_testing", "Vanilla_layers" ] pull_request: - branches: [ "main" ] + branches: [ "main", "Vanilla_layers" ] jobs: build: @@ -13,12 +13,12 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [3.10.12] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install Dependencies @@ -28,4 +28,32 @@ jobs: pip install -r requirements.txt - name: Run Tests run: | - python ci_testing/mp_layers_testing/manage.py test layers.tests.test_models + coverage run --source='.' ci_testing/mp_layers_testing/manage.py test layers.tests.test_models + - name: Set Coverage + id: set_coverage + run: | + COV=`coverage report --format=total` + echo "coverage=$COV" >> $GITHUB_OUTPUT + # echo "$COV%" + # echo "{'coverage':$COV}" > /tmp/coverage_badge.json + # - name: Archive test coverage results + # uses: actions/upload-artifact@v4 + # with: + # name: test-coverage-report_py-${{ matrix.python-version }} + # path: /tmp/coverage_badge.json + - name: Create Coverage Badge + uses: schneegans/dynamic-badges-action@v1.7.0 + with: + auth: ${{ secrets.COVERAGE_GIST_SECRET }} + gistID: 20cd92a8df1c63f3e6447540e67cddfd + filename: mpl_coverage.json + label: Coverage + message: "${{ steps.set_coverage.outputs.coverage }}%" + color: green + # TODO: Set IF sections to change colors: + # >= 95: 'green' + # >= 90: 'yellow' + # >= 80: 'orange' + # <80: 'red' + + diff --git a/README.md b/README.md index 29e9d91..63b8175 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,5 @@ # mp_layers Madrona Portal's Layer Manager + +[![Test Build](https://github.com/Ecotrust/mp-layers/actions/workflows/django.yml/badge.svg?branch=main)](https://github.com/Ecotrust/mp-layers/actions/workflows/django.yml) +![badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/rhodges/20cd92a8df1c63f3e6447540e67cddfd/raw/mpl_coverage.json) diff --git a/ci_testing/test_requirements.txt b/ci_testing/test_requirements.txt index 8acf2d1..be6156f 100644 --- a/ci_testing/test_requirements.txt +++ b/ci_testing/test_requirements.txt @@ -1,2 +1,3 @@ django<4.0 +coverage -e ./ \ No newline at end of file diff --git a/layers/admin.py b/layers/admin.py index 7daa1c0..de33615 100644 --- a/layers/admin.py +++ b/layers/admin.py @@ -1,17 +1,255 @@ +from collections import OrderedDict +from dal import autocomplete from django.contrib import admin -from .models import * +from django.contrib.contenttypes.admin import GenericTabularInline +from django.contrib.contenttypes.models import ContentType +from django.conf import settings from django import forms +from django.forms.models import inlineformset_factory +from django.db import transaction +from django.http import JsonResponse +from django.urls import path +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.utils.html import format_html +from django.views.decorators.http import require_POST +from import_export.admin import ImportExportMixin +from import_export import resources, fields, widgets +import nested_admin +import os +from queryset_sequence import QuerySetSequence +import requests +from .models import * # Register your models here. -class ThemeAdminForm(forms.ModelForm): +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) + +NestedMultilayerAssociationInlineFormset = inlineformset_factory( + parent_model=Layer, + fk_name = 'parentLayer', + model=MultilayerAssociation, + fields='__all__', # Adjust the fields as necessary + extra=1, + can_delete=True +) + +NestedMultilayerDimensionInlineFormset = inlineformset_factory( + parent_model=Layer, + model=MultilayerDimension, + fields='__all__', # Adjust the fields as necessary + extra=1, + can_delete=True +) + +class NestedMultilayerDimensionValueInline(nested_admin.NestedTabularInline): + model = MultilayerDimensionValue + fields = ('value', 'label', 'order') + extra = 1 + classes = ['collapse', 'open'] + verbose_name_plural = 'Multilayer Dimension Values' + +class NestedMultilayerDimensionInline(nested_admin.NestedTabularInline): + model = MultilayerDimension + fields = (('name', 'label', 'order', 'animated', 'angle_labels'),) + extra = 1 + classes = ['collapse', 'open'] + verbose_name_plural = 'Multilayer Dimensions' + inlines = [ + NestedMultilayerDimensionValueInline, + ] + +class NestedMultilayerAssociationInline(nested_admin.NestedTabularInline): + model = MultilayerAssociation + fk_name = 'parentLayer' + readonly_fields = ('get_values',) + fields = (('get_values', 'name', 'layer'),) + extra = 0 + classes = ['collapse', 'open'] + verbose_name_plural = 'Multilayer Associations' + + def get_values(self, obj): + return '| %s |' % ' | '.join([str(x) for x in obj.multilayerdimensionvalue_set.all()]) + + def get_readlony_values(self, obj): + return obj.multilayerdimensionvalue_set.all() + + def get_dimensions(self, obj): + dimensions = [] + for value in obj.multilayerdimensionvalue_set.all(): + dimension = value.dimension + if dimension not in dimensions: + dimensions.append(dimension) + return dimensions + +class CompanionLayerChoiceField(forms.ModelMultipleChoiceField): + def label_from_instance(self, obj): + # Return the name of the Theme object to be used as the label for the choice + return str(obj) + +class ThemeForm(forms.ModelForm): class Meta: model = Theme - exclude = ('layer_type', "slug_name") + exclude = ("slug_name", "uuid") + labels = { + 'dynamic_url': 'URL', # This will change the label in the form + } + +class ChildInlineForm(autocomplete.FutureModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Override DAL/QuerySetSequence assumption of using 'objects' as each model's Manager + queryset_models = [] + for model in [Theme, Layer]: + queryset_models.append(model.all_objects.all()) + self.fields['content_object'].queryset = QuerySetSequence(*queryset_models) + + content_object = autocomplete.Select2GenericForeignKeyModelField( + model_choice=[(Theme, 'theme'), (Layer, 'layer')], + widget=autocomplete.QuerySetSequenceSelect2, + ) + + class Meta: + model = ChildOrder + fields = ['content_type', 'content_object', 'order'] + +class ChildInline(admin.TabularInline): + model= ChildOrder + extra = 2 + ordering = ['order',] + form = ChildInlineForm + verbose_name = 'New Child' + verbose_name_plural = 'New Children' + + # Loading a Theme form with more than 6 children leads to A LOT of queries. Instead, we + # can block editing existing child records and then we don't have to populate the + # huge queryset over-and-over again: only for the new fields. The catch is 'existing' + # records go into 1 read-only(ish) inline, while new records (w/o read-only) get their own + # inline. + + # Thanks to olessia and kickstarter on S.O. for this elegant solution! + # https://stackoverflow.com/a/28149575/706797 + def has_change_permission(self, request, obj=None): + return False + + # For Django Version > 2.1 there is a "view permission" that needs to be disabled too (https://docs.djangoproject.com/en/2.2/releases/2.1/#what-s-new-in-django-2-1) + def has_view_permission(self, request, obj=None): + return False + +class ExistingChildInlineForm(forms.ModelForm): + + class Meta: + model = ChildOrder + fields = ['order',] + +class ExistingChildInline(admin.TabularInline): + model = ChildOrder + form = ExistingChildInlineForm + verbose_name = 'Child' + verbose_name_plural = 'Current Children' + extra = 0 + readonly_fields = ['content_type', 'content_object',] + classes = ['collapse',] + + def has_add_permission(self, request, obj=None): + return False + +class ParentThemeInlineForm(forms.ModelForm): + parent_theme = forms.ModelChoiceField( + queryset=Theme.all_objects.all(), + widget=autocomplete.ModelSelect2() + ) + + class Meta: + model = ChildOrder + fields = ['parent_theme', 'order'] + +class ThemeParentInline(GenericTabularInline): + model=ChildOrder + extra = 1 + form = ParentThemeInlineForm + verbose_name = 'Parent' + verbose_name_plural = 'Parents' + + def get_formset(self, request, obj, **kwargs): + formset = super(ThemeParentInline,self).get_formset(request, obj, **kwargs) + try: + formset.form.base_fields['parent_theme'].queryset = Theme.all_objects.exclude(pk=obj.pk) + except Exception as e: + pass + return formset + +class ThemeAdmin(ImportExportMixin,admin.ModelAdmin): + list_display = ('display_name', 'name', 'order', 'date_modified', 'is_top_theme', 'primary_site', 'preview_site') + search_fields = ['display_name', 'name',] + form = ThemeForm + inlines = [ThemeParentInline, ExistingChildInline, ChildInline] + + fieldsets = ( + ('BASIC INFO', { + 'fields': ( + 'name', + 'display_name', + 'site', + 'theme_type', + "is_visible", + "is_top_theme", + "order", + + ) + }), + ("METADATA", { + 'classes': ('collapse',), + "fields": ( + "description", + "overview", + "learn_more", + "data_notes", + "source", + "disabled_message", + "data_download", + # "slug_name", + ) + }), + ('DYNAMIC THEME', { + 'classes': ('collapse',), + 'fields': ( + "is_dynamic", + "dynamic_url", + "default_keyword", + "placeholder_text", + ) + }), + ("CATALOG DISPLAY", { + 'classes': ('collapse',), + "fields": ( + "header_image", + "header_attrib", + "thumbnail", + "factsheet_thumb", + "factsheet_link", + "feature_image", + "feature_excerpt", + "feature_link", + ) + }), + ("LEGEND", { + 'classes': ('collapse',), + "fields": ( + "show_legend", + "legend", + "legend_title", + "legend_subtitle", + ) + }) + ) + + class Media: + js = ['theme_admin.js',] + + change_form_template = os.path.join(CURRENT_DIR, 'templates', 'admin', 'layers', 'Theme', 'change_form.html') + -class ThemeAdmin(admin.ModelAdmin): - list_display = ('display_name', 'name', 'order', 'primary_site', 'preview_site') - form = ThemeAdminForm def get_queryset(self, request): # use our manager, rather than the default one qs = self.model.all_objects.get_queryset() @@ -21,6 +259,7 @@ def get_queryset(self, request): if ordering: qs = qs.order_by(*ordering) return qs + def formfield_for_manytomany(self, db_field, request=None, **kwargs): if db_field.name == 'site': kwargs['widget'] = forms.CheckboxSelectMultiple() @@ -28,32 +267,49 @@ def formfield_for_manytomany(self, db_field, request=None, **kwargs): kwargs['widget'].can_add_related = False return db_field.formfield(**kwargs) -class ThemeChoiceField(forms.ModelMultipleChoiceField): - def label_from_instance(self, obj): - # Return the name of the Theme object to be used as the label for the choice - return obj.name + + def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): + context['add_theme_url'] = reverse('admin:layers_theme_add') + context['add_layer_url'] = reverse('admin:layers_layer_add') + return super().render_change_form(request, context, add, change, form_url, obj) + + def change_view(self, request, object_id, form_url='', extra_context=None): + # Fetch the Theme object + obj = self.get_object(request, object_id) + + if obj: + extra_context = extra_context or {} + extra_context['CATALOG_TECHNOLOGY'] = getattr(settings, 'CATALOG_TECHNOLOGY', 'default') + extra_context['CATALOG_PROXY'] = getattr(settings, 'CATALOG_PROXY', '') -class SublayerChoiceField(forms.ModelMultipleChoiceField): - def label_from_instance(self, obj): - # Return the name of the Theme object to be used as the label for the choice - return obj.name + # Call the original change_view method with the updated context + return super().change_view(request, object_id, form_url, extra_context=extra_context) -class CompanionLayerChoiceField(forms.ModelMultipleChoiceField): - def label_from_instance(self, obj): - # Return the name of the Theme object to be used as the label for the choice - return obj.name + def add_view(self, request, form_url='', extra_context=None): + extra_context = extra_context or {} + extra_context['CATALOG_TECHNOLOGY'] = getattr(settings, 'CATALOG_TECHNOLOGY', 'default') + extra_context['CATALOG_PROXY'] = getattr(settings, 'CATALOG_PROXY', '') + return super(ThemeAdmin, self).add_view(request, form_url=form_url, extra_context=extra_context) + def formfield_for_dbfield(self, db_field, request, **kwargs): + formfield = super().formfield_for_dbfield(db_field, request, **kwargs) + if db_field.name == 'children_themes': + formfield.widget = forms.SelectMultiple(attrs={'data-url': self.reverse_add_url('theme')}) + if db_field.name == 'children_layers': + formfield.widget = forms.SelectMultiple(attrs={'data-url': self.reverse_add_url('layer')}) + return formfield + def reverse_add_url(self, model_name): + return reverse(f'admin:layers_{model_name}_add') class LayerForm(forms.ModelForm): - order = forms.IntegerField(required=False) - themes = ThemeChoiceField(queryset=Theme.all_objects.all().filter(layer_type=''), required=False, widget = admin.widgets.FilteredSelectMultiple('themes', False)) - is_sublayer = forms.BooleanField(required=False) + + order = forms.IntegerField(required=True) + # themes = ThemeChoiceField(queryset=Theme.all_objects.all(), required=False, widget = admin.widgets.FilteredSelectMultiple('themes', False)) has_companion = forms.BooleanField(required=False) - sublayers = SublayerChoiceField(queryset=Layer.all_objects.all(), required=False, widget = admin.widgets.FilteredSelectMultiple('sublayers', False)) companion_layers = CompanionLayerChoiceField(queryset=Layer.all_objects.all(), required=False, widget = admin.widgets.FilteredSelectMultiple('companion layers', False)) class Meta: - exclude = ('slug_name',) + exclude = ('slug_name', "has_companion", "uuid") model = Layer fields = '__all__' widgets = { @@ -61,70 +317,577 @@ class Meta: 'attribute_fields': admin.widgets.FilteredSelectMultiple('Attribute fields', False), 'lookup_table': admin.widgets.FilteredSelectMultiple('Lookup table', False), } + def clean(self): + cleaned_data = super().clean() + order = cleaned_data.get('order') + if order is None: + self.add_error('order', 'Order must be filled out.') + # Do not return order; instead, return the entire cleaned_data dictionary + return cleaned_data + def _post_clean(self): + try: + super()._post_clean() + except AttributeError as e: + # Add debugging output to help diagnose the issue + print(f"Error in _post_clean: {e}") + print(f"cleaned_data: {self.cleaned_data}") + raise + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + self.fields['companion_layers'].initial = self.instance.companionships.values_list('companions__id', flat=True) + # For themes + content_type = ContentType.objects.get_for_model(Layer) + child_orders = ChildOrder.objects.filter(content_type=content_type, object_id=self.instance.pk) + # initial_themes = child_orders.values_list('parent_theme', flat=True) + # self.fields['themes'].initial = list(initial_themes) + if child_orders.exists(): + # Assuming the first one if there are multiple (consider how you want to handle multiple themes) + initial_order = child_orders.first().order + self.fields['order'].initial = initial_order + else: + self.fields['order'].initial = 10 + # Check if there are any companions to determine the initial value of has_companion + has_companions = self.instance.companionships.exists() + self.fields['has_companion'].initial = has_companions + else: + self.fields['order'].initial = 10 +class BaseLayerInline(nested_admin.NestedStackedInline): + extra = 1 + max_num = 1 + can_delete = False - def save(self, commit=True): - # Save the Layer instance first - layer = super().save(commit=commit) - - # Now save or update the related ChildOrder instance or other related data - # This is simplified; you'd likely need more logic here - ChildOrder.objects.update_or_create( - content_object=layer, - defaults={'order': self.cleaned_data['order']} - ) - # Handle other fields similarly +class ArcRESTInline(BaseLayerInline): + model = LayerArcREST + + fieldsets = ( + ('', { + 'fields': ( + ('arcgis_layers',), + ( + 'password_protected', + 'query_by_point', + 'disable_arcgis_attributes', + ), + ) + }), + ) + +vectorStyleOverrides = ('Vector Display & Style', { + 'classes': ('collapse',), + 'fields': ( + 'custom_style', + ( + 'outline_width', + 'outline_color', + ), + ( + 'fill_opacity', + 'color', + ), + ( + 'point_radius', + 'graphic', + 'graphic_scale', + ), + ( + 'lookup_field', + 'lookup_table', + ), + ) + }) + +class ArcRESTFeatureServerInline(BaseLayerInline): + model = LayerArcFeatureService + + fieldsets = ( + ('', { + 'fields': ( + ('arcgis_layers',), + ('password_protected', 'disable_arcgis_attributes',) + ) + }), + vectorStyleOverrides, + ) + +class WMSInline(BaseLayerInline): + model = LayerWMS + + fieldsets = ( + ('', { + 'fields': ( + ('wms_help',), + ('wms_slug', 'wms_version'), + ('wms_format', 'wms_srs'), + ('wms_timing', 'wms_time_item'), + ('wms_styles', 'wms_additional'), + ('wms_info', 'wms_info_format'), + ), + }), + ) + +# class XYZInline(BaseLayerInline): +# model = LayerXYZ + +# # query_by_point is not relevant to XYZ layers +# fieldsets = ( +# ('', { +# 'fields': (), +# }), +# ) + +class VectorInline(BaseLayerInline): + model = LayerVector + + fieldsets = ( + vectorStyleOverrides, + ) + +class LayerParentInline(GenericTabularInline, nested_admin.NestedTabularInline): + model=ChildOrder + extra = 1 + form = ParentThemeInlineForm + verbose_name = 'Parent' + verbose_name_plural = 'Parents' + +class ChildOrderResource(resources.ModelResource): + # parent_themes = fields.Field( + # column_name='parent_themes', + # attribute='parent_themes', + # widget=widgets.ManyToManyWidget(Theme, field='id', separator=',') + # ) + + class Meta: + model = ChildOrder + fields = ('id', 'parent_theme', 'content_type', 'object_id', 'order' ) + +class LayerArcRESTResource(resources.ModelResource): - return layer -class LayerArcRESTForm(LayerForm): class Meta: model = LayerArcREST - fields = ['arcgis_layers', 'password_protected', 'query_by_point', 'disable_arcgis_attributes'] -class LayerAdmin(admin.ModelAdmin): - def get_parent_theme(self, obj): + fields = ( + 'id', 'layer', 'query_by_point', + 'arcgis_layers', 'password_protected', 'disable_arcgis_attributes', + ) + +class LayerArcFeatureServiceResource(resources.ModelResource): + + class Meta: + model = LayerArcFeatureService + fields = ( + 'id', 'layer', + 'arcgis_layers', 'password_protected', 'disable_arcgis_attributes', + ) + +class LayerWMSResource(resources.ModelResource): + + class Meta: + model = LayerWMS + fields = ( + 'id', 'layer', + #'query_by_point', # This is not exposed in the forms currently, which would make it hard to debug or turn off + # 'wms_help', # This is not relevant for bulk import/export + 'wms_slug', 'wms_version', 'wms_format', 'wms_srs', 'wms_styles', + 'wms_timing', 'wms_time_item', 'wms_additional', 'wms_info', 'wms_info_format', + ) + + +class LayerResource(resources.ModelResource): + + ######################### + # Child Orders + ######################### + order = fields.Field( + column_name='order', + attribute='order', + widget=widgets.IntegerWidget() + ) + parent_themes = fields.Field( + column_name='parent_themes', + attribute='parent_themes', + widget=widgets.ManyToManyWidget(Theme, field='id', separator=',') + ) + + ######################### + # Raster + ######################### + query_by_point = fields.Field( + column_name='query_by_point', + attribute='query_by_point', + widget=widgets.BooleanWidget() + ) + + ######################### + # LayerArcREST + ######################### + arcgis_layers = fields.Field( + column_name='arcgis_layers', + attribute='arcgis_layers', + widget=widgets.CharWidget() + ) + password_protected = fields.Field( + column_name='password_protected', + attribute='password_protected', + widget=widgets.BooleanWidget() + ) + disable_arcgis_attributes = fields.Field( + column_name='disable_arcgis_attributes', + attribute='disable_arcgis_attributes', + widget=widgets.BooleanWidget() + ) + + ######################### + # WMS + ######################### + + # wms_help = fields.Field( + # column_name='wms_help', + # attribute='wms_help', + # widget=widgets.BooleanWidget() + # ) + wms_slug = fields.Field( + column_name='wms_slug', + attribute='wms_slug', + widget=widgets.CharWidget() + ) + wms_version = fields.Field( + column_name='wms_version', + attribute='wms_version', + widget=widgets.CharWidget() + ) + wms_format = fields.Field( + column_name='wms_format', + attribute='wms_format', + widget=widgets.CharWidget() + ) + wms_srs = fields.Field( + column_name='wms_srs', + attribute='wms_srs', + widget=widgets.CharWidget() + ) + wms_styles = fields.Field( + column_name='wms_styles', + attribute='wms_styles', + widget=widgets.CharWidget() + ) + wms_timing = fields.Field( + column_name='wms_timing', + attribute='wms_timing', + widget=widgets.CharWidget() + ) + wms_time_item = fields.Field( + column_name='wms_time_item', + attribute='wms_time_item', + widget=widgets.CharWidget() + ) + wms_additional = fields.Field( + column_name='wms_additional', + attribute='wms_additional', + widget=widgets.CharWidget() + ) + wms_info = fields.Field( + column_name='wms_info', + attribute='wms_info', + widget=widgets.BooleanWidget() + ) + wms_info_format = fields.Field( + column_name='wms_info_format', + attribute='wms_info_format', + widget=widgets.CharWidget() + ) + + + def export_resource(self, obj): + # RDH 2025-03-03: I tried overriding at 'self.export_field', but this required multiple queries per row for the same data. + # The result was making a 9 second request into a 35 second request just to get 'order' and 'parent_theme' for ChildOrders + # Doing this once per object, brought that down to 19 seconds. + resource_values_list = [] + exception_list = self._meta.order_keys + self._meta.specific_keys + parent_orders = [{'order':x.order, 'parent_pk':str(x.parent_theme.pk)} for x in obj.parent_orders] + # arcRestLayer = obj.layer_type == 'ArcRest' + # wmsLayer = obj.layer_type == 'WMS' + # arcFeatureLayer = obj.layer_type == 'ArcFeatureServer' + # vectorLayer = obj.layer_type == 'Vector' + # vectorTileLayer = obj.layer_type == 'VectorTile' + # xyzLayer = obj.layer_type == 'XYZ' + # sliderLayer = obj.layer_type == 'slider' + # if arcRestLayer: + specific_instance = obj.specific_instance + + for field in self.get_export_fields(): + appendValue = None + if not field.column_name in exception_list: + appendValue = self.export_field(field, obj) + else: + if field.column_name == 'order': + ######################### + # Child Orders + ######################### + order = '' + for parent in parent_orders: + if not parent['order'] in ['',None]: + order = parent['order'] + break + appendValue = order + elif field.column_name == 'parent_themes': + parent_pks = [] + for parent in parent_orders: + parent_pks.append(parent['parent_pk']) + appendValue = ','.join(parent_pks) + elif not specific_instance == None: + if field.column_name in self._meta.raster_keys: + ######################### + # Raster + ######################### + if obj.layer_type in ['ArcRest', 'XYZ', 'WMS'] and type(specific_instance) in [LayerArcREST, LayerXYZ, LayerWMS]: + appendValue = getattr(specific_instance, field.column_name) + + elif field.column_name in self._meta.arc_keys: + ######################### + # ArcServer (MapServer, ImageServer, or FeatureServer) + ######################### + if obj.layer_type in ['ArcRest', 'ArcFeatureServer'] and type(specific_instance) in [LayerArcREST, LayerArcFeatureService]: + appendValue = getattr(specific_instance, field.column_name) + elif field.column_name in self._meta.wms_keys: + ######################### + # WMS + ######################### + if obj.layer_type == 'WMS' and type(specific_instance) == LayerWMS: + appendValue = getattr(specific_instance, field.column_name) + + resource_values_list.append(appendValue) + + return resource_values_list + + def import_related_record(self, rel_resource, rel_row, result, using_transactions=True, dry_run=False, raise_errors=False, **kwargs): + rel_loader = rel_resource._meta.instance_loader_class(rel_resource, rel_row) + rel_result = rel_resource.import_row(rel_row, rel_loader, using_transactions=using_transactions, dry_run=dry_run, raise_errors=raise_errors, **kwargs) + for error in rel_result.errors: + result.errors.append(error) + return result + + def import_row(self, row, instance_loader, using_transactions=True, dry_run=False, raise_errors=False, **kwargs): + pop_dict = {} + pop_keys = self._meta.order_keys + self._meta.specific_keys + for key in pop_keys: + if key in row.keys(): + pop_dict[key] = row.pop(key) + + # TODO: When we upgrade to django 4.2 and upgrade django_import_export to 4.2.x, we will need to review this deprecation + # Namely that 'raise_errors' is going away. + result = super(LayerResource, self).import_row(row, instance_loader, using_transactions=True, dry_run=False, raise_errors=False, **kwargs) + + ############################# + # Related Records + ############################# + parent_layer_pk = None + if result and not result.object_id == None: + parent_layer_pk = result.object_id + if parent_layer_pk == None and row['id'] not in [None, '']: + try: + parent_layer_pk = int(float(row['id'])) + except ValueError as e: + pass + + + ############################# + # Child Order + ############################# + layer_type = ContentType.objects.get_for_model(Layer) + co_resource = ChildOrderResource() + for parent_theme in str(pop_dict['parent_themes']).split(','): + if not parent_theme in ['', None]: + existing_co_pk = None + try: + existing_co_pk = ChildOrder.objects.get(parent_theme=int(float(parent_theme)), content_type=layer_type, object_id=parent_layer_pk).pk + except ObjectDoesNotExist as e: + # existing record does not match + pass + except ValueError as e: + # common case when an 'id' field is not given + pass + co_row = OrderedDict([('id', existing_co_pk), ('parent_theme', int(float(parent_theme))), ('content_type', layer_type.pk), ('object_id', parent_layer_pk), ('order', int(float(pop_dict['order'])))]) + result = self.import_related_record(co_resource, co_row, result, using_transactions=using_transactions, dry_run=dry_run, raise_errors=raise_errors, **kwargs) + + ############################# + # Raster + ############################# + # query_by_point + + + ############################# + # ArcREST + ############################# + # arcgis_layers + # disable_arcgis_attributes + # password_protected + + if row['layer_type'] in ['ArcRest', 'ArcFeatureServer']: + if row['layer_type'] == 'ArcRest': + target_model = LayerArcREST + arl_resource = LayerArcRESTResource() + elif row['layer_type'] == 'ArcFeatureServer': + target_model = LayerArcFeatureService + arl_resource = LayerArcFeatureServiceResource() + existing_arl_pk = None + + try: + existing_arl_pk = target_model.objects.get(layer__id=parent_layer_pk).pk + except ObjectDoesNotExist as e: + pass + + # this field has a nasty habit with some formats of coming in as floats. Not sure if that happens with commas, but this + # should enforce the correct format in the end. + arcgis_layers = str(pop_dict['arcgis_layers']).split(',') + for index, layer_id in enumerate(arcgis_layers): + arcgis_layers[index] = str(int(float(layer_id))) + arcgis_layers = ','.join(arcgis_layers) + arl_row = OrderedDict([ + ('id', existing_arl_pk), ('layer', parent_layer_pk), ('query_by_point', pop_dict['query_by_point']), + ('arcgis_layers', arcgis_layers),('password_protected', pop_dict['password_protected']),('disable_arcgis_attributes', pop_dict['disable_arcgis_attributes'])]) + result = self.import_related_record(arl_resource, arl_row, result, using_transactions=using_transactions, dry_run=dry_run, raise_errors=raise_errors, **kwargs) + + ############################# + # WMS + ############################# + if row['layer_type'] == 'WMS': + wms_resource = LayerWMSResource() + existing_wms_pk = None + try: + existing_wms_pk = LayerWMS.objects.get(layer__id=parent_layer_pk).pk + except ObjectDoesNotExist as e: + pass + wms_row = OrderedDict([ + ('id', existing_wms_pk), ('layer', parent_layer_pk), #('wms_help', pop_dict['wms_help']), + ('wms_slug', pop_dict['wms_slug']), ('wms_version', pop_dict['wms_version']), + ('wms_format', pop_dict['wms_format']), ('wms_srs', pop_dict['wms_srs']), + ('wms_styles', pop_dict['wms_styles']), ('wms_timing', pop_dict['wms_timing']), + ('wms_time_item', pop_dict['wms_time_item']), ('wms_additional', pop_dict['wms_additional']), + ('wms_info', pop_dict['wms_info']), ('wms_info_format', pop_dict['wms_info_format']) + ]) + result = self.import_related_record(wms_resource, wms_row, result, using_transactions=using_transactions, dry_run=dry_run, raise_errors=raise_errors, **kwargs) + + return result + + class Meta: + model = Layer + + ''' + Old list: + id,uuid,site,name,order,slug_name,layer_type,url,shareable_url,proxy_url,arcgis_layers,query_by_point,disable_arcgis_attributes,wms_help,wms_slug,wms_version,wms_format,wms_srs,wms_styles,wms_timing,wms_time_item,wms_additional,wms_info,wms_info_format,is_sublayer,sublayers,themes,search_query,has_companion,connect_companion_layers_to,is_disabled,disabled_message,legend,legend_title,legend_subtitle,show_legend,utfurl,filterable,geoportal_id,description,data_overview,data_source,data_notes,data_publish_date,catalog_name,catalog_id,bookmark,kml,data_download,learn_more,metadata,source,map_tiles,thumbnail,label_field,attribute_fields,compress_display,attribute_event,mouseover_field,lookup_field,lookup_table,is_annotated,custom_style,vector_outline_color,vector_outline_opacity,vector_outline_width,vector_color,vector_fill,vector_graphic,vector_graphic_scale,point_radius,opacity,espis_enabled,espis_search,espis_region,date_created,date_modified,minZoom,maxZoom,password_protected + + New list: + id,name,uuid,layer_type,url,site,opacity,is_disabled,disabled_message,is_visible,search_query,geoportal_id,catalog_name,catalog_id,proxy_url,shareable_url,utfurl,show_legend,legend,legend_title,legend_subtitle,description,overview,data_source,data_notes,data_publish_date,metadata,source,bookmark,kml,data_download,learn_more,map_tiles,label_field,attribute_event,attribute_fields,annotated,compress_display,mouseover_field,espis_enabled,espis_search,espis_region,date_created,date_modified,minZoom,maxZoom + + Munge: + # Atribute Reporting [Vector/Tile/ArcREST/Feature/WMS] + label_field,attribute_event,attribute_fields,mouseover_field, + + # Uncertain Use + shareable_url + + # Organization info [ChildOrder] + order, parent_themes(NEW), has_companion,connect_companion_layers_to, + + # VECTOR DISPLAY/Style [ArcFeature/Vector] + lookup_field,lookup_table,custom_style,vector_outline_color,vector_outline_opacity,vector_outline_width,vector_color,vector_fill,vector_graphic,vector_graphic_scale,point_radius, + + # WMS [WMS] + wms_help,wms_slug,wms_version,wms_format,wms_srs,wms_styles,wms_timing,wms_time_item,wms_additional,wms_info,wms_info_format + + # Uncertain + is_disabled,disabled_message,filterable,compress_display, + + # Auto (leave out) + + # Dropped (leave out) + slug_name,is_sublayer,sublayers,search_query,utfurl,thumbnail,annotated,espis_enabled,espis_search,espis_region,is_visible + ''' + # Primary Fields + fields = ['id', 'name', 'layer_type', 'url',] + # Secondary Fields + fields += ['uuid', 'site','proxy_url','opacity','date_created','date_modified','minZoom','maxZoom',] + # Legend + fields += ['show_legend','legend','legend_title','legend_subtitle',] + # Catalog + fields += [ + 'geoportal_id','catalog_name','catalog_id', + 'description','overview','data_source','data_notes', + 'data_publish_date','metadata','source','bookmark', + 'kml','data_download','learn_more','map_tiles', + ] + + # ChildOrder + order_keys = ['order', 'parent_themes',] + fields += order_keys + + # specific fields + raster_keys = ['query_by_point',] + vector_keys = [] #This is where we'd add vector styling fields if relevant. + arc_keys = ['arcgis_layers','disable_arcgis_attributes','password_protected',] + wms_keys = [ + # 'wms_help', + 'wms_slug','wms_version','wms_format','wms_srs','wms_styles', + 'wms_timing','wms_time_item','wms_additional','wms_info','wms_info_format', + ] + xyz_keys = [] # XYZ is not interactive, and does not require additional fields (including query_by_point) + specific_keys = raster_keys + vector_keys + arc_keys + wms_keys + xyz_keys + + fields += specific_keys + + export_order = fields + +class LayerAdmin(ImportExportMixin, nested_admin.NestedModelAdmin): + def get_parent_themes(self, obj): # Fetch the ContentType for the Layer model content_type = ContentType.objects.get_for_model(obj) - # Try to fetch the corresponding ChildOrder for this Layer - child_order = ChildOrder.objects.filter(content_type=content_type, object_id=obj.pk).first() + # Try to fetch the corresponding ChildOrders (parent relationships) for this Layer + child_orders = ChildOrder.objects.filter(content_type=content_type, object_id=obj.pk) + parent_themes = [x.parent_theme.name for x in child_orders if x.parent_theme] + parent_count = len(parent_themes) + themes_text = "; ".join(parent_themes) + if parent_count < 1: + themes_text = "(None)" + elif parent_count == 1: + themes_text += " (1 theme)" + else: + themes_text += " ({} themes)".format(parent_count) - # Return the name of the parent theme if exists - return child_order.parent_theme.name if child_order and child_order.parent_theme else 'None' - get_parent_theme.short_description = 'Theme' # Sets column name + # Return the names of the parent themes if they exist + return themes_text + + get_parent_themes.short_description = 'Themes' # Sets column name def get_order(self, obj): # Fetch the ContentType for the Layer model - content_type = ContentType.objects.get_for_model(obj) - + content_type = ContentType.objects.get_for_model(Layer) + # Try to fetch the corresponding ChildOrder for this Layer child_order = ChildOrder.objects.filter(content_type=content_type, object_id=obj.pk).first() # Return the order if exists - return child_order.order if child_order else 'None' + return child_order.order if child_order else 10 get_order.short_description = 'Order' # Sets column name - def get_form(self, request, obj=None, **kwargs): - # Dynamically set the form class based on the layer_type - if obj and obj.layer_type == 'ArcRest': - kwargs['form'] = LayerArcRESTForm - else: - kwargs['form'] = LayerForm - return super().get_form(request, obj, **kwargs) - - def get_fieldsets(self, request, obj=None): - # Dynamically return fieldsets based on the layer_type - if obj and obj.layer_type == 'ArcRest': - # No need to modify self.fieldsets; directly return the combined fieldsets - return self.base_fieldsets + self.arcrest_fieldsets - return self.base_fieldsets - list_display = ('name', 'layer_type', 'date_modified', "get_parent_theme", "get_order", 'data_publish_date', 'data_source') + def formfield_for_manytomany(self, db_field, request=None, **kwargs): + if db_field.name == 'site': + kwargs['widget'] = forms.CheckboxSelectMultiple() + kwargs['widget'].attrs['style'] = 'list-style-type: none;' + kwargs['widget'].can_add_related = False + + return db_field.formfield(**kwargs) + + list_display = ('name', 'get_parent_themes', 'layer_type', 'date_modified', 'data_publish_date', 'data_source', 'primary_site', 'preview_site', 'http_status', 'last_success_status', 'url') search_fields = ['name', 'layer_type', 'date_modified', 'url', 'data_source'] ordering = ('name', ) - exclude = ('slug_name',) - class Media: - js = ['layer_admin.js',] + exclude = ('slug_name', "is_sublayer", "sublayers") + form = LayerForm + resource_classes = [LayerResource] + - if settings.CATALOG_TECHNOLOGY not in ['default', None]: + if getattr(settings, 'CATALOG_TECHNOLOGY', None) not in ['default', None]: # catalog_fields = ('catalog_name', 'catalog_id',) # catalog_fields = 'catalog_name' basic_fields = ( @@ -137,17 +900,16 @@ class Media: basic_fields = ( ('name','layer_type',), ('url', 'proxy_url'), - 'site' + 'site', ) - base_fieldsets = ( + fieldsets = ( ('BASIC INFO', { 'fields': basic_fields }), ('LAYER ORGANIZATION', { - # 'classes': ('collapse', 'open',), + 'classes': ('collapse', ), 'fields': ( - ('order','themes'), - ('is_sublayer','sublayers'), + ('order',), ('has_companion','companion_layers'), # RDH 2019-10-25: We don't use this, and it doesn't seem helpful # ('is_disabled','disabled_message') @@ -156,7 +918,7 @@ class Media: ('METADATA', { 'classes': ('collapse',), 'fields': ( - 'description', 'overview','data_source','data_notes', 'data_publish_date' + 'description', 'overview','data_source','data_notes', 'data_publish_date', ) }), ('LEGEND', { @@ -167,13 +929,13 @@ class Media: ('legend_title','legend_subtitle') ) }), - ('LINKS', { + ('LINKS', { 'classes': ('collapse',), 'fields': ( ('metadata','source'), ('bookmark', 'kml'), ('data_download','learn_more'), - ('map_tiles'), + ('map_tiles',), ) }), ('SHARING', { @@ -181,27 +943,292 @@ class Media: 'fields': ( 'shareable_url', ) - }),) - arcrest_fieldsets = ( - ('ArcGIS DETAILS', { + }), + ('Dynamic Layers (MDAT & CAS)', { + 'classes': ('collapse',), + 'fields': ( + 'search_query', + ) + }), + ('UTF Grid Layers', { + 'classes': ('collapse',), + 'fields': ('utfurl',) + }), + ('ATTRIBUTE REPORTING (Vector/Tile, ArcREST/Feature, and WMS)', { + 'classes': ('collapse',), + 'fields': ( + 'label_field', + ( + 'attribute_event', + 'attribute_fields', + 'mouseover_field', + ), + # These fields are no longer used, but would have gone here. + # ('is_annotated', 'compress_display') + ) + }), + ('APPEARANCE', { 'classes': ('collapse',), 'fields': ( - ('arcgis_layers', 'password_protected', 'query_by_point', 'disable_arcgis_attributes'), + 'opacity', + ( + 'minZoom', + 'maxZoom' + ), ) }), ) + inlines = [ArcRESTInline, WMSInline, #XYZInline, + VectorInline, ArcRESTFeatureServerInline, NestedMultilayerDimensionInline, + NestedMultilayerAssociationInline, LayerParentInline + ] + add_form_template = os.path.join(CURRENT_DIR, 'templates', 'admin', 'layers', 'Layer', 'change_form.html') + change_form_template = os.path.join(CURRENT_DIR, 'templates', 'admin', 'layers', 'Layer', 'change_form.html') + + def change_view(self, request, object_id, form_url='', extra_context={}): + extra_context['CATALOG_TECHNOLOGY'] = settings.CATALOG_TECHNOLOGY + extra_context['CATALOG_PROXY'] = settings.CATALOG_PROXY + return super(LayerAdmin, self).change_view(request, object_id, form_url=form_url, extra_context=extra_context) + def add_view(self, request, form_url='', extra_context=None): + extra_context = extra_context or {} + extra_context['CATALOG_TECHNOLOGY'] = getattr(settings, 'CATALOG_TECHNOLOGY', 'default') + extra_context['CATALOG_PROXY'] = getattr(settings, 'CATALOG_PROXY', '') + return super(LayerAdmin, self).add_view(request, form_url=form_url, extra_context=extra_context) + def get_queryset(self, request): - # use our manager, rather than the default one + #use our manager, rather than the default one qs = self.model.all_objects.get_queryset() # we need this from the superclass method ordering = self.ordering or () # otherwise we might try to *None, which is bad ;) if ordering: qs = qs.order_by(*ordering) + return qs -# if hasattr(settings, 'DATA_MANAGER_ADMIN'): -# admin.site.register(Theme, ThemeAdmin) + def update_child_order(self, obj, order): + content_type = ContentType.objects.get_for_model(obj) + + # Update the order for all ChildOrder instances where the content_object is the current layer + ChildOrder.objects.filter(content_type=content_type, object_id=obj.pk).update(order=order) + + + def create_or_update_companionship(self, obj, companion_layer_ids): + + # Find existing companionship ids + existing_companion_ids = set(obj.companionships.values_list('companions__id', flat=True)) + + # Determine the companions to add and to remove + ids_to_add = companion_layer_ids - existing_companion_ids + ids_to_remove = existing_companion_ids - companion_layer_ids + + # Remove companionships that are no longer selected + Companionship.objects.filter(layer=obj, companions__id__in=ids_to_remove).delete() + + # Add new companionships + for companion_id in ids_to_add: + # Retrieve the Layer instance for the given companion_id + companion_layer = Layer.all_objects.get(id=companion_id) + # Create a new Companionship instance + companionship = Companionship.objects.create(layer=obj) + # Add the Layer instance to the companions relationship + companionship.companions.add(companion_layer) + + + def save_model(self, request, obj, form, change): + + with transaction.atomic(): + + if change: + # Check if 'layer_type' has changed, and handle accordingly + original_layer_type = Layer.all_objects.get(pk=obj.pk).layer_type + new_layer_type = form.cleaned_data.get('layer_type') + if original_layer_type != new_layer_type: + self.handle_layer_type_change(request, obj, original_layer_type, new_layer_type) + + super().save_model(request, obj, form, change) # Ensure the basic saving functionality. + + if not change: + self.save_add(request, obj, form) + + # Handling ChildOrder after the layer is saved + order = form.cleaned_data.get('order', 0) + + self.update_child_order(obj, order) + + # Handling Companionship after the layer is saved + companion_layers = set(form.cleaned_data.get('companion_layers', [])) + companion_layer_ids = {layer.id for layer in companion_layers} + self.create_or_update_companionship(obj, companion_layer_ids) + + + def save_add(self, request, obj, form): + layer_type = form.cleaned_data.get('layer_type') + InlineModels = [] + + # Determine the inline model based on layer_type + if layer_type == 'ArcRest': + InlineModels = [LayerArcREST,] + elif layer_type == 'WMS': + InlineModels = [LayerWMS,] + elif layer_type == 'XYZ': + InlineModels = [LayerXYZ,] + elif layer_type == 'Vector': + InlineModels = [LayerVector,] + elif layer_type == "slider": + InlineModels = [MultilayerDimension, MultilayerAssociation,] + elif layer_type == "ArcFeatureServer": + InlineModels = [LayerArcFeatureService,] + + # If an inline model is determined, proceed to save it + for nestedModel in InlineModels: + if nestedModel == MultilayerDimension: + InlineFormSet = NestedMultilayerDimensionInlineFormset + elif nestedModel == MultilayerAssociation: + InlineFormSet = NestedMultilayerAssociationInlineFormset + else: + InlineFormSet = inlineformset_factory(Layer, nestedModel, fields='__all__') + formset = InlineFormSet(request.POST, request.FILES, instance=obj) + if formset.is_valid() and nestedModel.objects.filter(layer=obj).count() == 1: + formset.save() + + def handle_layer_type_change(self, request, obj, original_layer_type, new_layer_type): + if new_layer_type == "slider": + # Handle the slider case by just saving the formset + self.save_inline_formsets(request, obj) + else: + # Handle the case for other layer types by getting or creating the instance + InlineModelClass = self.get_inline_model_class(new_layer_type) + + if InlineModelClass is not None: + # Get or create the inline instance + # inline_instance, created = InlineModelClass.objects.get_or_create(layer=obj) + + # Process the inline formset + InlineFormSetClass = self.get_inline_model(InlineModelClass) + if InlineFormSetClass is not None: + formset = InlineFormSetClass(request.POST, request.FILES, instance=obj) + if formset.is_valid(): + formset.save() + else: + # Handle the formset errors + raise Exception(f"Inline formset validation failed for {new_layer_type}") + + def save_inline_formsets(self, request, obj): + # Get all inline formsets for the 'slider' type and save them + for InlineFormSetClass in [NestedMultilayerAssociationInlineFormset, NestedMultilayerDimensionInlineFormset]: + formset = InlineFormSetClass(request.POST, request.FILES, instance=obj) + if formset.is_valid(): + formset.save() + else: + raise Exception('Slider inline formset validation failed') + # Add the method to get the inline instance by type + + def get_inline_model(self, layer_type): + mapping = { + 'ArcRest': [ArcRESTInline], + 'WMS': [WMSInline], + # 'XYZ': [XYZInline], + 'Vector': [VectorInline], + 'ArcFeatureServer': [ArcRESTFeatureServerInline], + 'slider': [NestedMultilayerDimensionInline, NestedMultilayerAssociationInline], + } + return mapping.get(layer_type) + + def get_inline_model_class(self, layer_type): + mapping = { + 'ArcRest': LayerArcREST, + 'ArcFeatureServer': LayerArcFeatureService, + 'WMS': LayerWMS, + 'XYZ': LayerXYZ, + 'Vector': LayerVector, + } + return mapping.get(layer_type) + + # def get_inline_instances(self, request, obj=None): + # inline_instances = super(LayerAdmin, self).get_inline_instances(request, obj) + # if obj: # Make sure the object exists + # for inline_instance in inline_instances: + # inline_type = 'ArcRest' # Change this based on the actual logic + # # Set a custom attribute to match against layer_type + # inline_instance.attrs = {'data-inline-for': inline_type.lower()} + # return inline_instances + + def get_form(self, request, obj=None, **kwargs): + form = super(LayerAdmin, self).get_form(request, obj, **kwargs) + return form + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('get-layer-list/', self.admin_site.admin_view(self.get_layer_list), name='get-layer-list'), + path('update-layer-status//', self.admin_site.admin_view(self.update_layer_status), name='update-layer-status'), + ] + return custom_urls + urls + + def http_status(self, obj): + return format_html( + '{}', + obj.pk, obj.url, obj.name, obj.last_http_status, obj.last_http_status + ) + http_status.short_description = 'HTTP Status' + + def update_layer_status(self, request, layer_id): + try: + layer = Layer.objects.get(pk=layer_id) + except Layer.DoesNotExist: + return JsonResponse({"error": "Layer not found"}, status=404) + try: + response = requests.get(layer.url, timeout=5, allow_redirects=True) + status = response.status_code + except Exception as e: + status = 404 + layer.last_http_status = status + update_fields = ['last_http_status'] + if status == 200: + layer.last_success_status = timezone.now() + update_fields.append('last_success_status') + layer.save(update_fields=update_fields) + return JsonResponse({ + "layer": layer.name, + "status": status, + "last_http_status": layer.last_http_status, + "last_success_status": layer.last_success_status.isoformat() if layer.last_success_status else None + }) + + def get_layer_list(self, request): + layers = Layer.objects.all() + layer_statuses = {} + for layer in layers: + if layer.name and layer.url: + layer_statuses[layer.name] = layer.url + return JsonResponse(layer_statuses) + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + extra_context['layers'] = Layer.objects.all() + return super(LayerAdmin, self).changelist_view(request, extra_context=extra_context) + + class Media: + js = ["admin/js/layer_http_status.js", 'admin/js/layer_admin.js'] + css = { + 'all': ("admin/css/layer_http_status.css","admin/css/layer_admin.css",) + } + +class LookupInfoAdmin(admin.ModelAdmin): + list_display = ('value', 'description', 'color', 'stroke_color', 'dashstyle', 'fill', 'graphic') + +# class ChildOrderForm(forms.ModelForm): +# def __init__(self, *args, **kwargs): +# super().__init__(*args, **kwargs) +# self.fields['parent_theme'].queryset = Theme.all_objects.all() + +# class ChildOrderAdmin(admin.ModelAdmin): +# list_display = ('parent_theme', 'content_type', 'object_id', 'order') +# form = ChildOrderForm + admin.site.register(Theme, ThemeAdmin) -admin.site.register(Layer, LayerAdmin) \ No newline at end of file +admin.site.register(Layer, LayerAdmin) +admin.site.register(LookupInfo, LookupInfoAdmin) +# admin.site.register(ChildOrder, ChildOrderAdmin) \ No newline at end of file diff --git a/layers/management/commands/migration_to_layers.py b/layers/management/commands/migration_to_layers.py new file mode 100644 index 0000000..c3e2043 --- /dev/null +++ b/layers/management/commands/migration_to_layers.py @@ -0,0 +1,429 @@ +from django.core.management.base import BaseCommand +from django.contrib.contenttypes.models import ContentType +from layers.models import Theme as LayersTheme, Layer as LayersLayer, ChildOrder, LayerWMS, LayerArcREST, LayerArcFeatureService, LayerVector, LayerXYZ, Companionship, MultilayerAssociation, MultilayerDimension, MultilayerDimensionValue, AttributeInfo, LookupInfo +from data_manager.models import Theme as DataManagerTheme, Layer as DataManagerLayer, MultilayerAssociation as DataManagerMultilayerAssociation, MultilayerDimension as DataManagerMultilayerDimension, MultilayerDimensionValue as DataManagerMultilayerDimensionValue +from django.contrib.sites.models import Site +import uuid + +class Command(BaseCommand): + help = 'Migrates old layers and themes from data_manager to layers module' + + def create_child_order(self, parent_theme, child, order): + # Your existing create_child_order function + + content_type = ContentType.objects.get_for_model(child) + child_order = ChildOrder.objects.create( + parent_theme=parent_theme, + content_type=content_type, + object_id=child.id, + order=order, + ) + return child_order + + def create_theme_or_layer(self, old_layer): + # Your existing create_or_update_layer function + # Replace all print statements with self.stdout.write for command-line output + new_entity = None + + # RDH 2024-04-30: A layer can be a sublayer in one theme and a 'layer' in another, so really something's only a Theme if type in ['radio', 'checkbox'] + # see 'Wastewater Outfall Pipes' under + # Maritime -> Submarine Cables and Pipelines -> Wastewater Outfal Pipes + # AND + # Water Quality -> Wastewater Outfal Pipes + # However, there are instances where data curators DID NOT use those types for parent layers: see Layer 4878 "Management Areas: Expired Management Areas" + # No URL, has a sublayer, but "is_sublayer" is False. + # if old_layer.layer_type in ['radio', 'checkbox'] or (old_layer.sublayers.all().count() > 0 and not old_layer.is_sublayer): + if old_layer.layer_type in ['radio', 'checkbox'] or (old_layer.sublayers.filter(is_sublayer=True).count()>0 and not old_layer.is_sublayer): + # Create as subtheme + visible = False if old_layer.layer_type == "placeholder" else True + layer_type = old_layer.layer_type + if layer_type not in ['radio', 'checkbox']: + layer_type = 'checkbox' # Default + new_subtheme = LayersTheme.objects.create( + id=old_layer.id, + uuid=old_layer.uuid, + name=old_layer.name, + display_name=old_layer.name, + overview=old_layer.data_overview, + description=old_layer.description, + learn_more=old_layer.learn_more, + theme_type=layer_type, + is_visible = visible, + data_notes=old_layer.data_notes, + disabled_message=old_layer.disabled_message, + data_source=old_layer.data_source, + source=old_layer.source, + data_download=old_layer.data_download, + show_legend=old_layer.show_legend, + legend=old_layer.legend, + legend_title=old_layer.legend_title, + legend_subtitle=old_layer.legend_subtitle, + slug_name=old_layer.slug_name, + ) + new_entity = new_subtheme + for site in old_layer.site.all(): + new_subtheme.site.add(site) + self.stdout.write(f"Created {old_layer.name} as a subtheme") + else: + # Create as layer + layer_type = "slider" if old_layer.isMultilayerParent else old_layer.layer_type + is_visible = False if old_layer.layer_type == "placeholder" else True + new_layer = LayersLayer.objects.create( + id=old_layer.id, + uuid=old_layer.uuid, + name=old_layer.name, + layer_type=layer_type, + slug_name=old_layer.slug_name, + label_field=old_layer.label_field, + attribute_event=old_layer.attribute_event, + mouseover_field=old_layer.mouseover_field, + annotated=old_layer.is_annotated, + compress_display=old_layer.compress_display, + url=old_layer.url, + is_visible=is_visible, + proxy_url=old_layer.proxy_url, + shareable_url=old_layer.shareable_url, + is_disabled=old_layer.is_disabled, + disabled_message=old_layer.disabled_message, + search_query=old_layer.search_query, + utfurl=old_layer.utfurl, + show_legend=old_layer.show_legend, + legend=old_layer.legend, + legend_title=old_layer.legend_title, + legend_subtitle=old_layer.legend_subtitle, + geoportal_id=old_layer.geoportal_id, + description=old_layer.description, + overview=old_layer.data_overview, + data_source=old_layer.data_source, + data_notes=old_layer.data_notes, + data_publish_date=old_layer.data_publish_date, + catalog_name=old_layer.catalog_name, + catalog_id=old_layer.catalog_id, + metadata=old_layer.metadata_link, + source=old_layer.source, + bookmark=old_layer.bookmark, + kml=old_layer.kml, + data_download=old_layer.data_download, + learn_more=old_layer.learn_more, + map_tiles=old_layer.map_tiles, + espis_enabled=old_layer.espis_enabled, + espis_search=old_layer.espis_search, + espis_region=old_layer.espis_region, + minZoom=old_layer.minZoom, + maxZoom=old_layer.maxZoom, + opacity=old_layer.opacity + ) + for attribute_field in old_layer.attribute_fields.all(): + # Create a new instance of AttributeInfo with the same data + new_attribute_field = AttributeInfo.objects.create( + display_name=attribute_field.display_name, + field_name=attribute_field.field_name, + precision=attribute_field.precision, + order=attribute_field.order, + preserve_format=attribute_field.preserve_format + ) + # Add the new instance to the new layer's attribute_fields + new_layer.attribute_fields.add(new_attribute_field) + new_layer.save() + if new_layer.layer_type == "WMS": + LayerWMS.objects.create( + layer=new_layer, + wms_slug=old_layer.wms_slug, + wms_version=old_layer.wms_version, + wms_format=old_layer.wms_format, + wms_srs=old_layer.wms_srs, + wms_styles=old_layer.wms_styles, + wms_timing=old_layer.wms_timing, + wms_time_item=old_layer.wms_time_item, + wms_additional=old_layer.wms_additional, + wms_info=old_layer.wms_info, + wms_info_format=old_layer.wms_info_format, + query_by_point=old_layer.query_by_point + ) + elif new_layer.layer_type == "ArcRest": + LayerArcREST.objects.create( + layer=new_layer, + arcgis_layers=old_layer.arcgis_layers, + disable_arcgis_attributes=old_layer.disable_arcgis_attributes, + password_protected=old_layer.password_protected, + query_by_point=old_layer.query_by_point + ) + elif new_layer.layer_type == "ArcFeatureServer": + LayerArcFeatureService.objects.create( + layer=new_layer, + arcgis_layers=old_layer.arcgis_layers, + disable_arcgis_attributes=old_layer.disable_arcgis_attributes, + password_protected=old_layer.password_protected, + custom_style=old_layer.custom_style, + outline_width=old_layer.vector_outline_width, + outline_color=old_layer.vector_outline_color, + outline_opacity=old_layer.vector_outline_opacity, + fill_opacity=old_layer.vector_fill, + color=old_layer.vector_color, + point_radius=old_layer.point_radius, + graphic=old_layer.vector_graphic, + graphic_scale=old_layer.vector_graphic_scale, + lookup_field=old_layer.lookup_field, + ) + elif new_layer.layer_type == "XYZ": + LayerXYZ.objects.create( + layer=new_layer, + query_by_point=old_layer.query_by_point + ) + elif new_layer.layer_type == "Vector": + LayerVector.objects.create( + layer=new_layer, + custom_style=old_layer.custom_style, + outline_width=old_layer.vector_outline_width, + outline_color=old_layer.vector_outline_color, + outline_opacity=old_layer.vector_outline_opacity, + fill_opacity=old_layer.vector_fill, + color=old_layer.vector_color, + point_radius=old_layer.point_radius, + graphic=old_layer.vector_graphic, + graphic_scale=old_layer.vector_graphic_scale, + lookup_field=old_layer.lookup_field, + ) + if new_layer.layer_type in ['ArcFeatureServer', 'Vector']: + for old_lookup in old_layer.lookup_table.all(): + new_lookup = LookupInfo.objects.create( + value=old_lookup.value, + description=old_lookup.description, + color=old_lookup.color, + stroke_color=old_lookup.stroke_color, + stroke_width=old_lookup.stroke_width, + dashstyle=old_lookup.dashstyle, + fill=old_lookup.fill, + graphic=old_lookup.graphic, + graphic_scale=old_lookup.graphic_scale, + ) + new_layer.specific_instance.lookup_table.add(new_lookup) + + new_entity = new_layer + for site in old_layer.site.all(): + new_layer.site.add(site) + self.stdout.write(f"Created {old_layer.name} as a layer") + + return new_entity + + + # for sublayer in old_layer.sublayers.all(): + # self.create_or_update_layer(sublayer, new_subtheme) + + def handle(self, *args, **options): + # Replace all print statements with self.stdout.write for command-line output + + # Delete all layers and themes first in layers module + LayersTheme.all_objects.all().delete() + self.stdout.write(self.style.SUCCESS('All themes deleted successfully.')) + + LayersLayer.all_objects.all().delete() + self.stdout.write(self.style.SUCCESS('Deleted all layers...')) + + # LOOP 1: for each layer in DM layers, create either layer or subtheme in Layers module + for old_layer in DataManagerLayer.all_objects.all(): + # Check if a theme with the old_uuid already exists + created_entity = self.create_theme_or_layer(old_layer) + + # LOOP 2: for theme in layers.theme, create child order + for theme in LayersTheme.all_objects.all(): + try: + old_layer = DataManagerLayer.all_objects.get(uuid=theme.uuid) # Match by UUID + if (old_layer.sublayers.all().count() > 0 and not old_layer.is_sublayer) or old_layer.isMultilayerParent: + for sublayer in old_layer.sublayers.all(): + if sublayer.uuid == theme.uuid: + self.stdout.write(self.style.WARNING(f'Skipping sublayer {sublayer.name} as its UUID matches the theme UUID.')) + continue + try: + # if (not sublayer.is_sublayer and sublayer.sublayers.all().count()>0) or (sublayer.layer_type == "radio" or sublayer.layer_type == "checkbox"): + if sublayer.layer_type == "radio" or sublayer.layer_type == "checkbox": + new_entity = LayersTheme.all_objects.get(uuid=sublayer.uuid) + else: + new_entity = LayersLayer.all_objects.get(uuid=sublayer.uuid) + # Create the child order and get the created object + created_child_order = self.create_child_order(theme, new_entity, sublayer.order) + # Print a success message with details about the created child order + self.stdout.write(self.style.SUCCESS(f'Child order created between theme "{theme.name}" and sublayer "{new_entity.name}" with order {created_child_order.order}.')) + except LayersLayer.DoesNotExist: + self.stdout.write(self.style.ERROR(f'No matching entity found for theme{theme.name} sublayer {sublayer.name} with UUID {sublayer.uuid}')) + continue + except DataManagerLayer.DoesNotExist: + self.stdout.write(self.style.ERROR(f'Original layer for theme {theme.name} not found in DataManagerLayer')) + + # LOOP 3: for theme in DM themes, create + for old_theme in DataManagerTheme.all_objects.all(): + new_theme = LayersTheme.objects.create( + id=old_theme.id, + uuid=old_theme.uuid, + name=old_theme.name, + display_name=old_theme.display_name, + description=old_theme.description, + overview=old_theme.overview, + thumbnail=old_theme.thumbnail, + header_image=old_theme.header_image, + header_attrib=old_theme.header_attrib, + factsheet_thumb=old_theme.factsheet_thumb, + factsheet_link=old_theme.factsheet_link, + feature_image=old_theme.feature_image, + feature_excerpt=old_theme.feature_excerpt, + feature_link=old_theme.feature_link, + order=old_theme.order, + is_visible=old_theme.visible, + date_created=old_theme.date_created, + date_modified=old_theme.date_modified, + ) + + # Add sites from the old theme to the new theme + for site in old_theme.site.all(): + new_theme.site.add(site) + + new_theme.save() + self.stdout.write(self.style.SUCCESS(f'Created theme "{new_theme.name}" from DataManagerTheme.')) + + # LOOP 4: For each theme, go through its children layers and create child order + for dm_theme in DataManagerTheme.all_objects.all(): + # Find the corresponding theme in LayersTheme + try: + parent_theme = LayersTheme.all_objects.get(uuid=dm_theme.uuid) + except LayersTheme.DoesNotExist: + self.stdout.write(self.style.ERROR(f'No matching theme found for {dm_theme.name} with UUID {dm_theme.uuid}')) + continue + + # Iterate over each layer associated with the dm_theme + # for dm_layer in dm_theme.layer_set.all(): + # RDH: this is hackier than the above line, but captures layers not associated with the production site. + for dm_layer in DataManagerLayer.all_objects.filter(themes=dm_theme): + # Try to find the corresponding layer or subtheme in LayersLayer or LayersTheme + if (dm_layer.is_sublayer == False): + try: + matching_layer = LayersLayer.all_objects.get(uuid=dm_layer.uuid) + child_order = self.create_child_order(parent_theme, matching_layer, dm_layer.order) + self.stdout.write(self.style.SUCCESS(f'Created child order for layer {matching_layer.name} under theme {child_order.parent_theme.name} and order {child_order.order} and id {child_order.id}')) + except LayersLayer.DoesNotExist: + try: + matching_subtheme = LayersTheme.all_objects.get(uuid=dm_layer.uuid) + self.create_child_order(parent_theme, matching_subtheme, dm_layer.order) + self.stdout.write(self.style.SUCCESS(f'Created child order for subtheme {matching_subtheme.name} under theme {parent_theme.name}')) + except LayersTheme.DoesNotExist: + self.stdout.write(self.style.ERROR(f'No matching layer or subtheme found for {dm_layer.name} with UUID {dm_layer.uuid}')) + continue + else: + continue + self.stdout.write(self.style.SUCCESS('Migration completed successfully')) + + # LOOP 5: for layer in DM layers, filter(has_companion), get layers.layer by UUID and create companionships + dm_layers_with_companions = DataManagerLayer.all_objects.filter(has_companion=True) + for dm_layer in dm_layers_with_companions: + # Get the corresponding layer in the Layers module by UUID + try: + parent_layer = LayersLayer.all_objects.get(uuid=dm_layer.uuid) + self.stdout.write(f'Processing companions for layer: {parent_layer.name}') + + # Check if this layer already has a companionship setup, to avoid duplicates + if not parent_layer.companionships.exists(): + companionship = Companionship.objects.create(layer=parent_layer) + + # Go through each connected companion layer + for companion_dm_layer in dm_layer.connect_companion_layers_to.all(): + try: + # Find the companion layer in Layers module by UUID + companion_layer = LayersLayer.all_objects.get(uuid=companion_dm_layer.uuid) + # Add the companion layer to the companionship + companionship.companions.add(companion_layer) + self.stdout.write(self.style.SUCCESS(f'Added {companion_layer.name} as a companion to {parent_layer.name}')) + except LayersLayer.DoesNotExist: + pass + self.stdout.write(self.style.ERROR(f'Companion layer {companion_dm_layer.name} with UUID {companion_dm_layer.uuid} not found in Layers module')) + + else: + self.stdout.write(self.style.WARNING(f'Companionship already exists for layer: {parent_layer.name}')) + + except LayersLayer.DoesNotExist: + self.stdout.write(self.style.ERROR(f'Layer {dm_layer.name} with UUID {dm_layer.uuid} not found in Layers module, turned into Subtheme')) + continue + + # LOOP 6: for each DM MLDM create layers MLDM + for dm_dimension in DataManagerMultilayerDimension.objects.all(): + try: + corresponding_layer = LayersLayer.all_objects.get(uuid=dm_dimension.layer.uuid) + except LayersLayer.DoesNotExist: + self.stdout.write(self.style.ERROR(f'Layer with UUID {dm_dimension.layer.uuid} not found in Layers module')) + continue # Skip this dimension if the corresponding layer is not found + + layer_dimension = MultilayerDimension.objects.create( + uuid=dm_dimension.uuid, + name=dm_dimension.name, + label=dm_dimension.label, + order=dm_dimension.order, + animated=dm_dimension.animated, + angle_labels=dm_dimension.angle_labels, + layer=corresponding_layer, # Adjust for correct Layer object linking + ) + self.stdout.write(f'Migrated dimension: {dm_dimension.name}') + + # LOOP 7: for each DM MLAssociations, creates layers MLAssociations + for dm_association in DataManagerMultilayerAssociation.objects.all(): + try: + # Find the corresponding layer in the Layers module by UUID + corresponding_layer = LayersLayer.all_objects.get(uuid=dm_association.layer.uuid) + except LayersLayer.DoesNotExist: + if dm_association.layer: + self.stdout.write(self.style.ERROR(f'Layer {dm_association.layer.name} with UUID {dm_association.layer.uuid} not found in Layers module')) + else: + print("ERROR: MultiLayer Association {} has no layer.".format(dm_association.pk)) + continue + except AttributeError: + print("ERROR: MultiLayer Association {} has no layer.".format(dm_association.pk)) + continue + + try: + # Ensure parentLayer is a Layer by checking the LayersLayer model + parent_layer = LayersLayer.all_objects.get(uuid=dm_association.parentLayer.uuid) + except LayersLayer.DoesNotExist: + self.stdout.write(self.style.ERROR(f'Parent layer with UUID {dm_association.parentLayer.uuid} not found in Layers module')) + continue + + # Proceed with creating the MultilayerAssociation instance + association = MultilayerAssociation.objects.create( + uuid=dm_association.uuid, + name=dm_association.name, + parentLayer=parent_layer, # Ensures parentLayer is a Layer instance + layer=corresponding_layer, + ) + self.stdout.write(f'Migrated association: {dm_association.name}') + + # LOOP 8: for each DM MLDV, create layers MLDV + dimension_values = [] + for dm_value in DataManagerMultilayerDimensionValue.objects.all(): + try: + dimension = MultilayerDimension.objects.get(uuid=dm_value.dimension.uuid) + dimension_value = MultilayerDimensionValue( + uuid=dm_value.uuid, + dimension=dimension, + value=dm_value.value, + label=dm_value.label, + order=dm_value.order, + ) + dimension_values.append(dimension_value) + except MultilayerDimension.DoesNotExist: + print("MultilayerDimension with uuid {} does not exist.".format(dm_value.dimension.uuid)) + continue + + # Bulk create instances + MultilayerDimensionValue.objects.bulk_create(dimension_values) + + # Migrate associations for each value + for dm_value in DataManagerMultilayerDimensionValue.objects.all(): + try: + dimension_value = MultilayerDimensionValue.objects.get(uuid=dm_value.uuid) + for dm_association in dm_value.associations.all(): + try: + association = MultilayerAssociation.objects.get(uuid=dm_association.uuid) + dimension_value.associations.add(association) + except MultilayerAssociation.DoesNotExist: + print("MultilayerAssociation with uuid {} does not exist.".format(dm_association.uuid)) + pass + self.stdout.write(f'Migrated dimension value: {dm_value.value}') + except MultilayerDimensionValue.DoesNotExist: + print("MultilayerDimensionValue with uuid {} does not exist.".format(dm_value.uuid)) + pass diff --git a/layers/management/commands/update_least_recent_layer_http_status.py b/layers/management/commands/update_least_recent_layer_http_status.py new file mode 100644 index 0000000..980d6ed --- /dev/null +++ b/layers/management/commands/update_least_recent_layer_http_status.py @@ -0,0 +1,36 @@ +from django.core.management.base import BaseCommand +from django.utils.timezone import now +from layers.models import Layer +import requests + +class Command(BaseCommand): + help = "Check HTTP status for the layer with the least recent last_http_status." + + def handle(self, *args, **kwargs): + # Get the layer with the least recent last_http_status + layer = Layer.objects.order_by('last_http_status').first() + + if not layer: + self.stdout.write("No layers found to check http status.") + return + + if layer.url: + try: + response = requests.get(layer.url, timeout=5, allow_redirects=True) + status = response.status_code + except Exception as e: + self.stderr.write(f"Error while checking URL {layer.url}: {e}") + status = 404 # Default to 404 if the request fails + + layer.last_http_status = status + update_fields = ['last_http_status'] + + if status == 200: + layer.last_success_status = now() + update_fields.append('last_success_status') + + layer.save(update_fields=update_fields) + self.stdout.write(f"Layer {layer.name} status updated to {status}.") + + else: + self.stdout.write(f"Layer {layer.name} has no URL to check.") \ No newline at end of file diff --git a/layers/migrations/0001_initial.py b/layers/migrations/0001_initial.py index 9e113c1..2c0c4d8 100644 --- a/layers/migrations/0001_initial.py +++ b/layers/migrations/0001_initial.py @@ -1,6 +1,11 @@ -# Generated by Django 3.2.23 on 2023-12-20 00:37 +# Generated by Django 3.2.12 on 2024-04-08 00:12 +import colorfield.fields +import django.contrib.sites.managers from django.db import migrations, models +import django.db.models.deletion +import layers.models +import uuid class Migration(migrations.Migration): @@ -8,14 +13,128 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('contenttypes', '0002_remove_content_type_name'), ] operations = [ + migrations.CreateModel( + name='AttributeInfo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('display_name', models.CharField(blank=True, max_length=255, null=True)), + ('field_name', models.CharField(blank=True, max_length=255, null=True)), + ('precision', models.IntegerField(blank=True, null=True)), + ('order', models.IntegerField(default=1)), + ('preserve_format', models.BooleanField(default=False, help_text='Prevent portal from making any changes to the data to make it human-readable')), + ], + ), migrations.CreateModel( name='Layer', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=100)), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('slug_name', models.CharField(blank=True, max_length=200, null=True)), + ('layer_type', models.CharField(choices=[('XYZ', 'XYZ'), ('WMS', 'WMS'), ('ArcRest', 'ArcRest'), ('ArcFeatureServer', 'ArcFeatureServer'), ('Vector', 'Vector'), ('VectorTile', 'VectorTile'), ('slider', 'slider')], help_text='use placeholder to temporarily remove layer from TOC', max_length=50)), + ('url', models.TextField(blank=True, default='')), + ('proxy_url', models.BooleanField(default=False, help_text='proxy layer url through marine planner')), + ('shareable_url', models.BooleanField(default=True, help_text='Indicates whether the data layer (e.g. map tiles) can be shared with others (through the Map Tiles Link)')), + ('is_disabled', models.BooleanField(default=False, help_text='when disabled, the layer will still appear in the TOC, only disabled')), + ('disabled_message', models.CharField(blank=True, default='', max_length=255, null=True)), + ('is_visible', models.BooleanField(default=True)), + ('utfurl', models.CharField(blank=True, max_length=255, null=True)), + ('show_legend', models.BooleanField(default=True, help_text='show the legend for this layer if available')), + ('legend', models.CharField(blank=True, help_text='URL or path to the legend image file', max_length=255, null=True)), + ('legend_title', models.CharField(blank=True, help_text='alternative to using the layer name', max_length=255, null=True)), + ('legend_subtitle', models.CharField(blank=True, max_length=255, null=True)), + ('geoportal_id', models.CharField(blank=True, default=None, help_text='GeoPortal UUID', max_length=255, null=True)), + ('description', models.TextField(blank=True, default='')), + ('overview', models.TextField(blank=True, default='')), + ('data_source', models.CharField(blank=True, max_length=255, null=True)), + ('data_notes', models.TextField(blank=True, default='')), + ('data_publish_date', models.DateField(blank=True, default=None, help_text='YYYY-MM-DD', null=True, verbose_name='Date published')), + ('catalog_name', models.TextField(blank=True, help_text='name of associated record in catalog', null=True, verbose_name='Catalog Record Name')), + ('catalog_id', models.TextField(blank=True, help_text='unique ID of associated record in catalog', null=True, verbose_name='Catalog Record Id')), + ('metadata', models.CharField(blank=True, help_text='link to view/download the metadata', max_length=255, null=True)), + ('source', models.CharField(blank=True, help_text='link back to the data source', max_length=255, null=True)), + ('bookmark', models.CharField(blank=True, help_text='link to view data layer in the planner', max_length=755, null=True)), + ('kml', models.CharField(blank=True, help_text='link to download the KML', max_length=255, null=True)), + ('data_download', models.CharField(blank=True, help_text='link to download the data', max_length=255, null=True)), + ('learn_more', models.CharField(blank=True, default=None, help_text='link to view description in the Learn section', max_length=255, null=True)), + ('map_tiles', models.CharField(blank=True, help_text='internal link to a page that details how others might consume the data', max_length=255, null=True)), + ('label_field', models.CharField(blank=True, help_text='Which field should be used for labels and feature identification in reports?', max_length=255, null=True)), + ('attribute_event', models.CharField(choices=[('click', 'click'), ('mouseover', 'mouseover')], default='click', max_length=35)), + ('annotated', models.BooleanField(default=False)), + ('compress_display', models.BooleanField(default=False)), + ('mouseover_field', models.CharField(blank=True, default=None, help_text='feature level attribute used in mouseover display', max_length=75, null=True)), + ('lookup_field', models.CharField(blank=True, help_text='To override the style based on specific attributes, provide the attribute name here and define your attributes in the Lookup table below.', max_length=255, null=True)), + ('espis_enabled', models.BooleanField(default=False)), + ('espis_search', models.CharField(blank=True, default=None, help_text='keyphrase search for ESPIS Link', max_length=255, null=True)), + ('espis_region', models.CharField(blank=True, choices=[('Mid Atlantic', 'Mid Atlantic')], default=None, help_text='Region to search within', max_length=100, null=True)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ('minZoom', models.FloatField(blank=True, default=None, null=True, verbose_name='Minimum zoom')), + ('maxZoom', models.FloatField(blank=True, default=None, null=True, verbose_name='Maximum zoom')), + ('attribute_fields', models.ManyToManyField(blank=True, to='layers.AttributeInfo')), + ], + bases=(models.Model, layers.models.SiteFlags), + managers=[ + ('objects', django.contrib.sites.managers.CurrentSiteManager('site')), + ('all_objects', layers.models.AllObjectsManager()), + ], + ), + migrations.CreateModel( + name='LookupInfo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('value', models.CharField(blank=True, max_length=255, null=True)), + ('description', models.CharField(blank=True, default=None, max_length=255, null=True)), + ('color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=18, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Fill Color')), + ('stroke_color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=18, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Stroke Color')), + ('stroke_width', models.IntegerField(blank=True, default=None, null=True, verbose_name='Stroke Width')), + ('dashstyle', models.CharField(choices=[('dot', 'dot'), ('dash', 'dash'), ('dashdot', 'dashdot'), ('longdash', 'longdash'), ('longdashdot', 'longdashdot'), ('solid', 'solid')], default='solid', max_length=11)), + ('fill', models.BooleanField(default=False)), + ('graphic', models.CharField(blank=True, max_length=255, null=True)), + ('graphic_scale', models.FloatField(blank=True, default=None, help_text='Scale the graphic from its original size.', null=True, verbose_name='Graphic Scale')), + ], + ), + migrations.CreateModel( + name='MultilayerAssociation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('name', models.CharField(max_length=200)), + ('layer', models.ForeignKey(blank=True, db_column='associatedlayer', default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='associated_layer', to='layers.layer')), + ], + ), + migrations.CreateModel( + name='MultilayerDimension', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('name', models.CharField(help_text='name to be used for selection in admin tool forms', max_length=200)), + ('label', models.CharField(help_text='label to be used in mapping tool slider', max_length=50)), + ('order', models.IntegerField(default=100, help_text='the order in which this dimension will be presented among other dimensions on this layer')), + ('animated', models.BooleanField(default=False, help_text='enable auto-toggling of layers across this dimension')), + ('angle_labels', models.BooleanField(default=False, help_text='display labels at an angle to make more fit')), + ], + options={ + 'ordering': ('order',), + }, + ), + migrations.CreateModel( + name='Library', + fields=[ + ('layer_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='layers.layer')), + ('queryable', models.BooleanField(default=False, help_text='Select when layers are queryable - e.g. MDAT and CAS')), + ], + bases=('layers.layer',), + managers=[ + ('objects', django.contrib.sites.managers.CurrentSiteManager('site')), + ('all_objects', layers.models.AllObjectsManager()), ], ), migrations.CreateModel( @@ -23,6 +142,181 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=100)), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('display_name', models.CharField(max_length=100)), + ('theme_type', models.CharField(blank=True, choices=[('radio', 'radio'), ('checkbox', 'checkbox'), ('slider', 'slider')], help_text='use placeholder to temporarily remove layer from TOC', max_length=50)), + ('order', models.PositiveIntegerField(blank=True, null=True)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ('is_visible', models.BooleanField(default=True)), + ('description', models.TextField(blank=True, null=True)), + ('overview', models.TextField(blank=True, default='', null=True)), + ('slug_name', models.CharField(blank=True, max_length=200, null=True)), + ('header_image', models.CharField(blank=True, max_length=255, null=True)), + ('header_attrib', models.CharField(blank=True, max_length=255, null=True)), + ('thumbnail', models.URLField(blank=True, max_length=255, null=True)), + ('factsheet_thumb', models.CharField(blank=True, max_length=255, null=True)), + ('factsheet_link', models.CharField(blank=True, max_length=255, null=True)), + ('feature_image', models.CharField(blank=True, max_length=255, null=True)), + ('feature_excerpt', models.TextField(blank=True, null=True)), + ('feature_link', models.CharField(blank=True, max_length=255, null=True)), + ('site', models.ManyToManyField(related_name='theme_site', to='sites.Site')), + ], + options={ + 'ordering': ['order'], + }, + bases=(models.Model, layers.models.SiteFlags), + managers=[ + ('objects', django.contrib.sites.managers.CurrentSiteManager('site')), + ('all_objects', layers.models.AllObjectsManager()), + ], + ), + migrations.CreateModel( + name='MultilayerDimensionValue', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('value', models.CharField(help_text='Actual value of selection', max_length=200)), + ('label', models.CharField(help_text='Label for this selection seen in mapping tool slider', max_length=50)), + ('order', models.IntegerField(default=100)), + ('associations', models.ManyToManyField(to='layers.MultilayerAssociation')), + ('dimension', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.multilayerdimension')), + ], + options={ + 'ordering': ('order',), + }, + ), + migrations.AddField( + model_name='multilayerdimension', + name='theme', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.theme'), + ), + migrations.AddField( + model_name='multilayerassociation', + name='parentLayer', + field=models.ForeignKey(db_column='parentlayer', on_delete=django.db.models.deletion.CASCADE, related_name='parent_layer', to='layers.theme'), + ), + migrations.CreateModel( + name='LayerXYZ', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('query_by_point', models.BooleanField(default=False, help_text='Do not buffer selection clicks (not recommended for point or line data)')), + ('layer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.layer')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LayerWMS', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('query_by_point', models.BooleanField(default=False, help_text='Do not buffer selection clicks (not recommended for point or line data)')), + ('wms_help', models.BooleanField(default=False, help_text='Enable simple selection for WMS fields. Only supports WMS 1.1.1')), + ('wms_slug', models.CharField(blank=True, max_length=255, null=True, verbose_name='WMS Layer Name')), + ('wms_version', models.CharField(blank=True, choices=[(None, ''), ('1.0.0', '1.0.0'), ('1.1.0', '1.1.0'), ('1.1.1', '1.1.1'), ('1.3.0', '1.3.0')], default=None, help_text='WMS Versioning - usually either 1.1.1 or 1.3.0', max_length=10, null=True)), + ('wms_format', models.CharField(blank=True, default=None, help_text='most common: image/png. Only image types supported.', max_length=100, null=True, verbose_name='WMS Format')), + ('wms_srs', models.CharField(blank=True, default=None, help_text='If not EPSG:3857 WMS requests will be proxied', max_length=100, null=True, verbose_name='WMS SRS')), + ('wms_timing', models.CharField(blank=True, default=None, help_text='http://docs.geoserver.org/stable/en/user/services/wms/time.html#specifying-a-time', max_length=255, null=True, verbose_name='WMS Time')), + ('wms_time_item', models.CharField(blank=True, default=None, help_text='Time Attribute Field, if different from "TIME". Proxy only.', max_length=255, null=True, verbose_name='WMS Time Field')), + ('wms_styles', models.CharField(blank=True, default=None, help_text='pre-determined styles, if exist', max_length=255, null=True, verbose_name='WMS Styles')), + ('wms_additional', models.TextField(blank=True, default='', help_text='additional WMS key-value pairs: &key=value...', null=True, verbose_name='WMS Additional Fields')), + ('wms_info', models.BooleanField(default=False, help_text='enable Feature Info requests on click')), + ('wms_info_format', models.CharField(blank=True, default=None, help_text='Available supported feature info formats', max_length=255, null=True)), + ('layer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.layer')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LayerVector', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('custom_style', models.CharField(blank=True, choices=[(None, '------'), ('color', 'color'), ('random', 'random')], default=None, help_text="Apply a custom styling rule: i.e. 'color' for Native-Land.ca layers, or 'random' to assign arbitary colors", max_length=255, null=True)), + ('outline_width', models.IntegerField(blank=True, default=None, null=True, verbose_name='Vector Stroke Width')), + ('outline_color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=18, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Stroke Color')), + ('outline_opacity', models.FloatField(blank=True, default=None, null=True, verbose_name='Vector Stroke Opacity')), + ('fill_opacity', models.FloatField(blank=True, default=None, null=True, verbose_name='Vector Fill Opacity')), + ('color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=18, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Fill Color')), + ('point_radius', models.IntegerField(blank=True, default=None, help_text='Used only for for Point layers (default is 2)', null=True)), + ('graphic', models.CharField(blank=True, default=None, help_text='address of image to use for point data', max_length=255, null=True, verbose_name='Vector Graphic')), + ('graphic_scale', models.FloatField(blank=True, default=1.0, help_text='Scale for the vector graphic from original size.', null=True, verbose_name='Vector Graphic Scale')), + ('opacity', models.FloatField(blank=True, default=0.5, null=True, verbose_name='Initial Opacity')), + ('layer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.layer')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LayerArcREST', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('query_by_point', models.BooleanField(default=False, help_text='Do not buffer selection clicks (not recommended for point or line data)')), + ('arcgis_layers', models.CharField(blank=True, help_text='comma separated list of arcgis layer IDs', max_length=255, null=True)), + ('password_protected', models.BooleanField(default=False, help_text='check this if the server requires a password to show layers')), + ('disable_arcgis_attributes', models.BooleanField(default=False, help_text='Click to disable clickable ArcRest layers')), + ('layer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.layer')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LayerArcFeatureService', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('custom_style', models.CharField(blank=True, choices=[(None, '------'), ('color', 'color'), ('random', 'random')], default=None, help_text="Apply a custom styling rule: i.e. 'color' for Native-Land.ca layers, or 'random' to assign arbitary colors", max_length=255, null=True)), + ('outline_width', models.IntegerField(blank=True, default=None, null=True, verbose_name='Vector Stroke Width')), + ('outline_color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=18, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Stroke Color')), + ('outline_opacity', models.FloatField(blank=True, default=None, null=True, verbose_name='Vector Stroke Opacity')), + ('fill_opacity', models.FloatField(blank=True, default=None, null=True, verbose_name='Vector Fill Opacity')), + ('color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=18, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Fill Color')), + ('point_radius', models.IntegerField(blank=True, default=None, help_text='Used only for for Point layers (default is 2)', null=True)), + ('graphic', models.CharField(blank=True, default=None, help_text='address of image to use for point data', max_length=255, null=True, verbose_name='Vector Graphic')), + ('graphic_scale', models.FloatField(blank=True, default=1.0, help_text='Scale for the vector graphic from original size.', null=True, verbose_name='Vector Graphic Scale')), + ('opacity', models.FloatField(blank=True, default=0.5, null=True, verbose_name='Initial Opacity')), + ('arcgis_layers', models.CharField(blank=True, help_text='comma separated list of arcgis layer IDs', max_length=255, null=True)), + ('password_protected', models.BooleanField(default=False, help_text='check this if the server requires a password to show layers')), + ('disable_arcgis_attributes', models.BooleanField(default=False, help_text='Click to disable clickable ArcRest layers')), + ('layer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.layer')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='layer', + name='lookup_table', + field=models.ManyToManyField(blank=True, to='layers.LookupInfo'), + ), + migrations.AddField( + model_name='layer', + name='site', + field=models.ManyToManyField(related_name='layer_site', to='sites.Site'), + ), + migrations.CreateModel( + name='Companionship', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('companions', models.ManyToManyField(related_name='companion_to', to='layers.Layer')), + ('layer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='companionships', to='layers.layer')), + ], + ), + migrations.CreateModel( + name='ChildOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('order', models.PositiveIntegerField()), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('parent_theme', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', to='layers.theme')), ], + options={ + 'ordering': ['order'], + }, ), ] diff --git a/layers/migrations/0002_auto_20240207_1925.py b/layers/migrations/0002_auto_20240207_1925.py deleted file mode 100644 index 31530c1..0000000 --- a/layers/migrations/0002_auto_20240207_1925.py +++ /dev/null @@ -1,560 +0,0 @@ -# Generated by Django 3.2.23 on 2024-02-07 19:25 - -import colorfield.fields -import django.contrib.sites.managers -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone -import layers.models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('sites', '0002_alter_domain_unique'), - ('contenttypes', '0002_remove_content_type_name'), - ('layers', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='AttributeInfo', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), - ('display_name', models.CharField(blank=True, max_length=255, null=True)), - ('field_name', models.CharField(blank=True, max_length=255, null=True)), - ('precision', models.IntegerField(blank=True, null=True)), - ('order', models.IntegerField(default=1)), - ('preserve_format', models.BooleanField(default=False, help_text='Prevent portal from making any changes to the data to make it human-readable')), - ], - ), - migrations.CreateModel( - name='LayerArcFeatureService', - fields=[ - ('layer_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='layers.layer')), - ('custom_style', models.CharField(blank=True, choices=[(None, '------'), ('color', 'color'), ('random', 'random')], default=None, help_text="Apply a custom styling rule: i.e. 'color' for Native-Land.ca layers, or 'random' to assign arbitary colors", max_length=255, null=True)), - ('outline_width', models.IntegerField(blank=True, default=None, null=True, verbose_name='Vector Stroke Width')), - ('outline_color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Stroke Color')), - ('outline_opacity', models.FloatField(blank=True, default=None, null=True, verbose_name='Vector Stroke Opacity')), - ('fill_opacity', models.FloatField(blank=True, default=None, null=True, verbose_name='Vector Fill Opacity')), - ('color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Fill Color')), - ('point_radius', models.IntegerField(blank=True, default=None, help_text='Used only for for Point layers (default is 2)', null=True)), - ('graphic', models.CharField(blank=True, default=None, help_text='address of image to use for point data', max_length=255, null=True, verbose_name='Vector Graphic')), - ('graphic_scale', models.FloatField(blank=True, default=1.0, help_text='Scale for the vector graphic from original size.', null=True, verbose_name='Vector Graphic Scale')), - ('opacity', models.FloatField(blank=True, default=0.5, null=True, verbose_name='Initial Opacity')), - ('arcgis_layers', models.CharField(blank=True, help_text='comma separated list of arcgis layer IDs', max_length=255, null=True)), - ('password_protected', models.BooleanField(default=False, help_text='check this if the server requires a password to show layers')), - ('disable_arcgis_attributes', models.BooleanField(default=False, help_text='Click to disable clickable ArcRest layers')), - ], - options={ - 'abstract': False, - }, - bases=('layers.layer',), - managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager('site')), - ('all_objects', layers.models.AllObjectsManager()), - ], - ), - migrations.CreateModel( - name='LayerArcREST', - fields=[ - ('layer_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='layers.layer')), - ('query_by_point', models.BooleanField(default=False, help_text='Do not buffer selection clicks (not recommended for point or line data)')), - ('arcgis_layers', models.CharField(blank=True, help_text='comma separated list of arcgis layer IDs', max_length=255, null=True)), - ('password_protected', models.BooleanField(default=False, help_text='check this if the server requires a password to show layers')), - ('disable_arcgis_attributes', models.BooleanField(default=False, help_text='Click to disable clickable ArcRest layers')), - ], - options={ - 'abstract': False, - }, - bases=('layers.layer',), - managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager('site')), - ('all_objects', layers.models.AllObjectsManager()), - ], - ), - migrations.CreateModel( - name='LayerVector', - fields=[ - ('layer_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='layers.layer')), - ('custom_style', models.CharField(blank=True, choices=[(None, '------'), ('color', 'color'), ('random', 'random')], default=None, help_text="Apply a custom styling rule: i.e. 'color' for Native-Land.ca layers, or 'random' to assign arbitary colors", max_length=255, null=True)), - ('outline_width', models.IntegerField(blank=True, default=None, null=True, verbose_name='Vector Stroke Width')), - ('outline_color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Stroke Color')), - ('outline_opacity', models.FloatField(blank=True, default=None, null=True, verbose_name='Vector Stroke Opacity')), - ('fill_opacity', models.FloatField(blank=True, default=None, null=True, verbose_name='Vector Fill Opacity')), - ('color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Fill Color')), - ('point_radius', models.IntegerField(blank=True, default=None, help_text='Used only for for Point layers (default is 2)', null=True)), - ('graphic', models.CharField(blank=True, default=None, help_text='address of image to use for point data', max_length=255, null=True, verbose_name='Vector Graphic')), - ('graphic_scale', models.FloatField(blank=True, default=1.0, help_text='Scale for the vector graphic from original size.', null=True, verbose_name='Vector Graphic Scale')), - ('opacity', models.FloatField(blank=True, default=0.5, null=True, verbose_name='Initial Opacity')), - ], - options={ - 'abstract': False, - }, - bases=('layers.layer',), - managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager('site')), - ('all_objects', layers.models.AllObjectsManager()), - ], - ), - migrations.CreateModel( - name='LayerWMS', - fields=[ - ('layer_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='layers.layer')), - ('query_by_point', models.BooleanField(default=False, help_text='Do not buffer selection clicks (not recommended for point or line data)')), - ('wms_help', models.BooleanField(default=False, help_text='Enable simple selection for WMS fields. Only supports WMS 1.1.1')), - ('wms_slug', models.CharField(blank=True, max_length=255, null=True, verbose_name='WMS Layer Name')), - ('wms_version', models.CharField(blank=True, choices=[(None, ''), ('1.0.0', '1.0.0'), ('1.1.0', '1.1.0'), ('1.1.1', '1.1.1'), ('1.3.0', '1.3.0')], default=None, help_text='WMS Versioning - usually either 1.1.1 or 1.3.0', max_length=10, null=True)), - ('wms_format', models.CharField(blank=True, default=None, help_text='most common: image/png. Only image types supported.', max_length=100, null=True, verbose_name='WMS Format')), - ('wms_srs', models.CharField(blank=True, default=None, help_text='If not EPSG:3857 WMS requests will be proxied', max_length=100, null=True, verbose_name='WMS SRS')), - ('wms_timing', models.CharField(blank=True, default=None, help_text='http://docs.geoserver.org/stable/en/user/services/wms/time.html#specifying-a-time', max_length=255, null=True, verbose_name='WMS Time')), - ('wms_time_item', models.CharField(blank=True, default=None, help_text='Time Attribute Field, if different from "TIME". Proxy only.', max_length=255, null=True, verbose_name='WMS Time Field')), - ('wms_styles', models.CharField(blank=True, default=None, help_text='pre-determined styles, if exist', max_length=255, null=True, verbose_name='WMS Styles')), - ('wms_additional', models.TextField(blank=True, default='', help_text='additional WMS key-value pairs: &key=value...', null=True, verbose_name='WMS Additional Fields')), - ('wms_info', models.BooleanField(default=False, help_text='enable Feature Info requests on click')), - ('wms_info_format', models.CharField(blank=True, default=None, help_text='Available supported feature info formats', max_length=255, null=True)), - ], - options={ - 'abstract': False, - }, - bases=('layers.layer',), - managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager('site')), - ('all_objects', layers.models.AllObjectsManager()), - ], - ), - migrations.CreateModel( - name='LayerXYZ', - fields=[ - ('layer_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='layers.layer')), - ('query_by_point', models.BooleanField(default=False, help_text='Do not buffer selection clicks (not recommended for point or line data)')), - ], - options={ - 'abstract': False, - }, - bases=('layers.layer',), - managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager('site')), - ('all_objects', layers.models.AllObjectsManager()), - ], - ), - migrations.CreateModel( - name='Library', - fields=[ - ('layer_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='layers.layer')), - ('queryable', models.BooleanField(default=False, help_text='Select when layers are queryable - e.g. MDAT and CAS')), - ], - bases=('layers.layer',), - managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager('site')), - ('all_objects', layers.models.AllObjectsManager()), - ], - ), - migrations.CreateModel( - name='LookupInfo', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), - ('value', models.CharField(blank=True, max_length=255, null=True)), - ('description', models.CharField(blank=True, default=None, max_length=255, null=True)), - ('color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Fill Color')), - ('stroke_color', colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Stroke Color')), - ('stroke_width', models.IntegerField(blank=True, default=None, null=True, verbose_name='Stroke Width')), - ('dashstyle', models.CharField(choices=[('dot', 'dot'), ('dash', 'dash'), ('dashdot', 'dashdot'), ('longdash', 'longdash'), ('longdashdot', 'longdashdot'), ('solid', 'solid')], default='solid', max_length=11)), - ('fill', models.BooleanField(default=False)), - ('graphic', models.CharField(blank=True, max_length=255, null=True)), - ('graphic_scale', models.FloatField(blank=True, default=None, help_text='Scale the graphic from its original size.', null=True, verbose_name='Graphic Scale')), - ], - ), - migrations.CreateModel( - name='MultilayerAssociation', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), - ('name', models.CharField(max_length=200)), - ], - ), - migrations.CreateModel( - name='MultilayerDimension', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), - ('name', models.CharField(help_text='name to be used for selection in admin tool forms', max_length=200)), - ('label', models.CharField(help_text='label to be used in mapping tool slider', max_length=50)), - ('order', models.IntegerField(default=100, help_text='the order in which this dimension will be presented among other dimensions on this layer')), - ('animated', models.BooleanField(default=False, help_text='enable auto-toggling of layers across this dimension')), - ('angle_labels', models.BooleanField(default=False, help_text='display labels at an angle to make more fit')), - ], - options={ - 'ordering': ('order',), - }, - ), - migrations.AlterModelOptions( - name='theme', - options={'ordering': ['order']}, - ), - migrations.AlterModelManagers( - name='layer', - managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager('site')), - ('all_objects', layers.models.AllObjectsManager()), - ], - ), - migrations.AlterModelManagers( - name='theme', - managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager('site')), - ('all_objects', layers.models.AllObjectsManager()), - ], - ), - migrations.AddField( - model_name='layer', - name='annotated', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='layer', - name='attribute_event', - field=models.CharField(choices=[('click', 'click'), ('mouseover', 'mouseover')], default='click', max_length=35), - ), - migrations.AddField( - model_name='layer', - name='bookmark', - field=models.CharField(blank=True, help_text='link to view data layer in the planner', max_length=755, null=True), - ), - migrations.AddField( - model_name='layer', - name='catalog_id', - field=models.TextField(blank=True, help_text='unique ID of associated record in catalog', null=True, verbose_name='Catalog Record Id'), - ), - migrations.AddField( - model_name='layer', - name='catalog_name', - field=models.TextField(blank=True, help_text='name of associated record in catalog', null=True, verbose_name='Catalog Record Name'), - ), - migrations.AddField( - model_name='layer', - name='compress_display', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='layer', - name='data_download', - field=models.CharField(blank=True, help_text='link to download the data', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='data_notes', - field=models.TextField(blank=True, default=''), - ), - migrations.AddField( - model_name='layer', - name='data_publish_date', - field=models.DateField(blank=True, default=None, help_text='YYYY-MM-DD', null=True, verbose_name='Date published'), - ), - migrations.AddField( - model_name='layer', - name='data_source', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='date_created', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='layer', - name='date_modified', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='layer', - name='description', - field=models.TextField(blank=True, default=''), - ), - migrations.AddField( - model_name='layer', - name='disabled_message', - field=models.CharField(blank=True, default='', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='espis_enabled', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='layer', - name='espis_region', - field=models.CharField(blank=True, choices=[('Mid Atlantic', 'Mid Atlantic')], default=None, help_text='Region to search within', max_length=100, null=True), - ), - migrations.AddField( - model_name='layer', - name='espis_search', - field=models.CharField(blank=True, default=None, help_text='keyphrase search for ESPIS Link', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='geoportal_id', - field=models.CharField(blank=True, default=None, help_text='GeoPortal UUID', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='is_disabled', - field=models.BooleanField(default=False, help_text='when disabled, the layer will still appear in the TOC, only disabled'), - ), - migrations.AddField( - model_name='layer', - name='kml', - field=models.CharField(blank=True, help_text='link to download the KML', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='label_field', - field=models.CharField(blank=True, help_text='Which field should be used for labels and feature identification in reports?', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='layer_type', - field=models.CharField(choices=[('XYZ', 'XYZ'), ('WMS', 'WMS'), ('ArcRest', 'ArcRest'), ('ArcFeatureServer', 'ArcFeatureServer'), ('radio', 'radio'), ('checkbox', 'checkbox'), ('Vector', 'Vector'), ('VectorTile', 'VectorTile'), ('placeholder', 'placeholder')], default='arcgis', help_text='use placeholder to temporarily remove layer from TOC', max_length=50), - preserve_default=False, - ), - migrations.AddField( - model_name='layer', - name='learn_more', - field=models.CharField(blank=True, default=None, help_text='link to view description in the Learn section', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='legend', - field=models.CharField(blank=True, help_text='URL or path to the legend image file', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='legend_subtitle', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='legend_title', - field=models.CharField(blank=True, help_text='alternative to using the layer name', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='lookup_field', - field=models.CharField(blank=True, help_text='To override the style based on specific attributes, provide the attribute name here and define your attributes in the Lookup table below.', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='map_tiles', - field=models.CharField(blank=True, help_text='internal link to a page that details how others might consume the data', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='maxZoom', - field=models.FloatField(blank=True, default=None, null=True, verbose_name='Maximum zoom'), - ), - migrations.AddField( - model_name='layer', - name='metadata', - field=models.CharField(blank=True, help_text='link to view/download the metadata', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='minZoom', - field=models.FloatField(blank=True, default=None, null=True, verbose_name='Minimum zoom'), - ), - migrations.AddField( - model_name='layer', - name='mouseover_field', - field=models.CharField(blank=True, default=None, help_text='feature level attribute used in mouseover display', max_length=75, null=True), - ), - migrations.AddField( - model_name='layer', - name='overview', - field=models.TextField(blank=True, default=''), - ), - migrations.AddField( - model_name='layer', - name='proxy_url', - field=models.BooleanField(default=False, help_text='proxy layer url through marine planner'), - ), - migrations.AddField( - model_name='layer', - name='shareable_url', - field=models.BooleanField(default=True, help_text='Indicates whether the data layer (e.g. map tiles) can be shared with others (through the Map Tiles Link)'), - ), - migrations.AddField( - model_name='layer', - name='show_legend', - field=models.BooleanField(default=True, help_text='show the legend for this layer if available'), - ), - migrations.AddField( - model_name='layer', - name='site', - field=models.ManyToManyField(related_name='layer_site', to='sites.Site'), - ), - migrations.AddField( - model_name='layer', - name='slug_name', - field=models.CharField(blank=True, max_length=200, null=True), - ), - migrations.AddField( - model_name='layer', - name='source', - field=models.CharField(blank=True, help_text='link back to the data source', max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='url', - field=models.TextField(blank=True, default=''), - ), - migrations.AddField( - model_name='layer', - name='utfurl', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='layer', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, unique=True), - ), - migrations.AddField( - model_name='theme', - name='data_notes', - field=models.TextField(blank=True, default=''), - ), - migrations.AddField( - model_name='theme', - name='data_source', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='theme', - name='date_created', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='theme', - name='date_modified', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='theme', - name='description', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='theme', - name='display_name', - field=models.CharField(default='test', max_length=100), - preserve_default=False, - ), - migrations.AddField( - model_name='theme', - name='is_visible', - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name='theme', - name='layer_type', - field=models.CharField(choices=[('radio', 'radio'), ('checkbox', 'checkbox')], default='arcgis', help_text='use placeholder to temporarily remove layer from TOC', max_length=50), - preserve_default=False, - ), - migrations.AddField( - model_name='theme', - name='order', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AddField( - model_name='theme', - name='overview', - field=models.TextField(blank=True, default='', null=True), - ), - migrations.AddField( - model_name='theme', - name='site', - field=models.ManyToManyField(related_name='theme_site', to='sites.Site'), - ), - migrations.AddField( - model_name='theme', - name='slug_name', - field=models.CharField(blank=True, max_length=200, null=True), - ), - migrations.AddField( - model_name='theme', - name='source', - field=models.CharField(blank=True, help_text='link back to the data source', max_length=255, null=True), - ), - migrations.AddField( - model_name='theme', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, unique=True), - ), - migrations.CreateModel( - name='MultilayerDimensionValue', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), - ('value', models.CharField(help_text='Actual value of selection', max_length=200)), - ('label', models.CharField(help_text='Label for this selection seen in mapping tool slider', max_length=50)), - ('order', models.IntegerField(default=100)), - ('associations', models.ManyToManyField(to='layers.MultilayerAssociation')), - ('dimension', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.multilayerdimension')), - ], - options={ - 'ordering': ('order',), - }, - ), - migrations.AddField( - model_name='multilayerdimension', - name='layer', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.layer'), - ), - migrations.AddField( - model_name='multilayerassociation', - name='layer', - field=models.ForeignKey(blank=True, db_column='associatedlayer', default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='associated_layer', to='layers.layer'), - ), - migrations.AddField( - model_name='multilayerassociation', - name='parentLayer', - field=models.ForeignKey(db_column='parentlayer', on_delete=django.db.models.deletion.CASCADE, related_name='parent_layer', to='layers.layer'), - ), - migrations.CreateModel( - name='Companionship', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('companions', models.ManyToManyField(related_name='companion_to', to='layers.Layer')), - ('layer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='companionships', to='layers.layer')), - ], - ), - migrations.CreateModel( - name='ChildOrder', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('object_id', models.PositiveIntegerField()), - ('order', models.PositiveIntegerField()), - ('date_created', models.DateTimeField(auto_now_add=True)), - ('date_modified', models.DateTimeField(auto_now=True)), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), - ('parent_theme', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', to='layers.theme')), - ], - options={ - 'ordering': ['order'], - }, - ), - migrations.AddField( - model_name='layer', - name='attribute_fields', - field=models.ManyToManyField(blank=True, to='layers.AttributeInfo'), - ), - migrations.AddField( - model_name='layer', - name='lookup_table', - field=models.ManyToManyField(blank=True, to='layers.LookupInfo'), - ), - ] diff --git a/layers/migrations/0002_auto_20240408_0046.py b/layers/migrations/0002_auto_20240408_0046.py new file mode 100644 index 0000000..8fc3fdd --- /dev/null +++ b/layers/migrations/0002_auto_20240408_0046.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.12 on 2024-04-08 00:46 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='multilayerdimension', + name='theme', + ), + migrations.AddField( + model_name='multilayerdimension', + name='layer', + field=models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, to='layers.layer'), + preserve_default=False, + ), + ] diff --git a/layers/migrations/0003_alter_multilayerassociation_parentlayer.py b/layers/migrations/0003_alter_multilayerassociation_parentlayer.py new file mode 100644 index 0000000..9c556f9 --- /dev/null +++ b/layers/migrations/0003_alter_multilayerassociation_parentlayer.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.12 on 2024-04-08 01:06 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0002_auto_20240408_0046'), + ] + + operations = [ + migrations.AlterField( + model_name='multilayerassociation', + name='parentLayer', + field=models.ForeignKey(db_column='parentlayer', on_delete=django.db.models.deletion.CASCADE, related_name='parent_layer', to='layers.layer'), + ), + ] diff --git a/layers/migrations/0003_auto_20240207_2012.py b/layers/migrations/0003_auto_20240207_2012.py deleted file mode 100644 index da6f765..0000000 --- a/layers/migrations/0003_auto_20240207_2012.py +++ /dev/null @@ -1,65 +0,0 @@ -# Generated by Django 3.2.23 on 2024-02-07 20:12 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('layers', '0002_auto_20240207_1925'), - ] - - operations = [ - migrations.RemoveField( - model_name='theme', - name='data_notes', - ), - migrations.RemoveField( - model_name='theme', - name='data_source', - ), - migrations.RemoveField( - model_name='theme', - name='source', - ), - migrations.AddField( - model_name='theme', - name='factsheet_link', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='theme', - name='factsheet_thumb', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='theme', - name='feature_excerpt', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='theme', - name='feature_image', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='theme', - name='feature_link', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='theme', - name='header_attrib', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='theme', - name='header_image', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='theme', - name='thumbnail', - field=models.URLField(blank=True, max_length=255, null=True), - ), - ] diff --git a/layers/migrations/0004_alter_theme_layer_type.py b/layers/migrations/0004_alter_theme_layer_type.py deleted file mode 100644 index f4afc04..0000000 --- a/layers/migrations/0004_alter_theme_layer_type.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.23 on 2024-02-07 20:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('layers', '0003_auto_20240207_2012'), - ] - - operations = [ - migrations.AlterField( - model_name='theme', - name='layer_type', - field=models.CharField(blank=True, choices=[('radio', 'radio'), ('checkbox', 'checkbox')], help_text='use placeholder to temporarily remove layer from TOC', max_length=50), - ), - ] diff --git a/layers/migrations/0004_auto_20240503_1533.py b/layers/migrations/0004_auto_20240503_1533.py new file mode 100644 index 0000000..d95c64b --- /dev/null +++ b/layers/migrations/0004_auto_20240503_1533.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.12 on 2024-05-03 15:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0003_alter_multilayerassociation_parentlayer'), + ] + + operations = [ + migrations.RemoveField( + model_name='layerarcfeatureservice', + name='opacity', + ), + migrations.RemoveField( + model_name='layervector', + name='opacity', + ), + migrations.AddField( + model_name='layer', + name='opacity', + field=models.FloatField(blank=True, default=0.5, null=True, verbose_name='Initial Opacity'), + ), + migrations.AlterField( + model_name='theme', + name='theme_type', + field=models.CharField(blank=True, choices=[('radio', 'radio'), ('checkbox', 'checkbox')], help_text='use placeholder to temporarily remove layer from TOC', max_length=50), + ), + ] diff --git a/layers/migrations/0005_auto_20240504_0039.py b/layers/migrations/0005_auto_20240504_0039.py new file mode 100644 index 0000000..ccb6eaf --- /dev/null +++ b/layers/migrations/0005_auto_20240504_0039.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.12 on 2024-05-04 00:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0004_auto_20240503_1533'), + ] + + operations = [ + migrations.AddField( + model_name='theme', + name='data_notes', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='theme', + name='data_source', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='theme', + name='disabled_message', + field=models.CharField(blank=True, default=None, max_length=255, null=True), + ), + migrations.AddField( + model_name='theme', + name='source', + field=models.CharField(blank=True, help_text='link back to the data source', max_length=255, null=True), + ), + ] diff --git a/layers/migrations/0006_theme_learn_more.py b/layers/migrations/0006_theme_learn_more.py new file mode 100644 index 0000000..0404f04 --- /dev/null +++ b/layers/migrations/0006_theme_learn_more.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2024-05-07 22:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0005_auto_20240504_0039'), + ] + + operations = [ + migrations.AddField( + model_name='theme', + name='learn_more', + field=models.CharField(blank=True, default=None, help_text='MDAT/VTR/CAS: link to learn more', max_length=255, null=True), + ), + ] diff --git a/layers/migrations/0007_auto_20240508_0012.py b/layers/migrations/0007_auto_20240508_0012.py new file mode 100644 index 0000000..5505285 --- /dev/null +++ b/layers/migrations/0007_auto_20240508_0012.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.12 on 2024-05-08 00:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0006_theme_learn_more'), + ] + + operations = [ + migrations.AddField( + model_name='theme', + name='legend', + field=models.CharField(blank=True, help_text='URL or path to the legend image file', max_length=255, null=True), + ), + migrations.AddField( + model_name='theme', + name='legend_subtitle', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='theme', + name='legend_title', + field=models.CharField(blank=True, help_text='alternative to using the layer name', max_length=255, null=True), + ), + migrations.AddField( + model_name='theme', + name='show_legend', + field=models.BooleanField(default=True, help_text='show the legend for this layer if available'), + ), + ] diff --git a/layers/migrations/0008_theme_data_download.py b/layers/migrations/0008_theme_data_download.py new file mode 100644 index 0000000..8017668 --- /dev/null +++ b/layers/migrations/0008_theme_data_download.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2024-05-08 15:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0007_auto_20240508_0012'), + ] + + operations = [ + migrations.AddField( + model_name='theme', + name='data_download', + field=models.CharField(blank=True, help_text='link to download the data', max_length=255, null=True), + ), + ] diff --git a/layers/migrations/0009_auto_20240509_1512.py b/layers/migrations/0009_auto_20240509_1512.py new file mode 100644 index 0000000..6ac29e2 --- /dev/null +++ b/layers/migrations/0009_auto_20240509_1512.py @@ -0,0 +1,53 @@ +# Generated by Django 3.2.12 on 2024-05-09 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0008_theme_data_download'), + ] + + operations = [ + migrations.AlterField( + model_name='layer', + name='data_notes', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='layer', + name='description', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='layer', + name='disabled_message', + field=models.CharField(blank=True, default=None, max_length=255, null=True), + ), + migrations.AlterField( + model_name='layer', + name='overview', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='layer', + name='url', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='layerwms', + name='wms_additional', + field=models.TextField(blank=True, default=None, help_text='additional WMS key-value pairs: &key=value...', null=True, verbose_name='WMS Additional Fields'), + ), + migrations.AlterField( + model_name='theme', + name='description', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='theme', + name='overview', + field=models.TextField(blank=True, default=None, null=True), + ), + ] diff --git a/layers/migrations/0010_auto_20240520_1715.py b/layers/migrations/0010_auto_20240520_1715.py new file mode 100644 index 0000000..735abea --- /dev/null +++ b/layers/migrations/0010_auto_20240520_1715.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.12 on 2024-05-20 17:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0009_auto_20240509_1512'), + ] + + operations = [ + migrations.AlterField( + model_name='childorder', + name='order', + field=models.PositiveIntegerField(blank=True, default=10, null=True), + ), + migrations.AlterField( + model_name='theme', + name='order', + field=models.PositiveIntegerField(blank=True, default=10, null=True), + ), + ] diff --git a/layers/migrations/0011_auto_20240520_1844.py b/layers/migrations/0011_auto_20240520_1844.py new file mode 100644 index 0000000..336b1ce --- /dev/null +++ b/layers/migrations/0011_auto_20240520_1844.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.12 on 2024-05-20 18:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0010_auto_20240520_1715'), + ] + + operations = [ + migrations.AddField( + model_name='layer', + name='search_query', + field=models.BooleanField(default=False, help_text='Select when layers are queryable - e.g. MDAT and CAS'), + ), + migrations.DeleteModel( + name='Library', + ), + ] diff --git a/layers/migrations/0012_auto_20240524_1818.py b/layers/migrations/0012_auto_20240524_1818.py new file mode 100644 index 0000000..8e7b129 --- /dev/null +++ b/layers/migrations/0012_auto_20240524_1818.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.12 on 2024-05-24 18:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0011_auto_20240520_1844'), + ] + + operations = [ + migrations.RemoveField( + model_name='layer', + name='lookup_field', + ), + migrations.RemoveField( + model_name='layer', + name='lookup_table', + ), + migrations.AddField( + model_name='layerarcfeatureservice', + name='lookup_field', + field=models.CharField(blank=True, help_text='To override the style based on specific attributes, provide the attribute name here and define your attributes in the Lookup table below.', max_length=255, null=True), + ), + migrations.AddField( + model_name='layerarcfeatureservice', + name='lookup_table', + field=models.ManyToManyField(blank=True, to='layers.LookupInfo'), + ), + migrations.AddField( + model_name='layervector', + name='lookup_field', + field=models.CharField(blank=True, help_text='To override the style based on specific attributes, provide the attribute name here and define your attributes in the Lookup table below.', max_length=255, null=True), + ), + migrations.AddField( + model_name='layervector', + name='lookup_table', + field=models.ManyToManyField(blank=True, to='layers.LookupInfo'), + ), + ] diff --git a/layers/migrations/0013_auto_20240604_1649.py b/layers/migrations/0013_auto_20240604_1649.py new file mode 100644 index 0000000..5d9a86b --- /dev/null +++ b/layers/migrations/0013_auto_20240604_1649.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.25 on 2024-06-04 16:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0012_auto_20240524_1818'), + ] + + operations = [ + migrations.AlterField( + model_name='layerarcfeatureservice', + name='layer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.layer', unique=True), + ), + migrations.AlterField( + model_name='layerarcrest', + name='layer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.layer', unique=True), + ), + migrations.AlterField( + model_name='layervector', + name='layer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.layer', unique=True), + ), + migrations.AlterField( + model_name='layerwms', + name='layer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.layer', unique=True), + ), + migrations.AlterField( + model_name='layerxyz', + name='layer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layers.layer', unique=True), + ), + ] diff --git a/layers/migrations/0014_theme_is_dynamic.py b/layers/migrations/0014_theme_is_dynamic.py new file mode 100644 index 0000000..8ac53d4 --- /dev/null +++ b/layers/migrations/0014_theme_is_dynamic.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2024-08-19 09:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0013_auto_20240604_1649'), + ] + + operations = [ + migrations.AddField( + model_name='theme', + name='is_dynamic', + field=models.BooleanField(default=False), + ), + ] diff --git a/layers/migrations/0015_theme_dynamic_url.py b/layers/migrations/0015_theme_dynamic_url.py new file mode 100644 index 0000000..a03267e --- /dev/null +++ b/layers/migrations/0015_theme_dynamic_url.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2024-08-20 01:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0014_theme_is_dynamic'), + ] + + operations = [ + migrations.AddField( + model_name='theme', + name='dynamic_url', + field=models.TextField(blank=True, default=None, null=True), + ), + ] diff --git a/layers/migrations/0016_auto_20240904_1453.py b/layers/migrations/0016_auto_20240904_1453.py new file mode 100644 index 0000000..1a4021c --- /dev/null +++ b/layers/migrations/0016_auto_20240904_1453.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.12 on 2024-09-04 14:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0015_theme_dynamic_url'), + ] + + operations = [ + migrations.AddField( + model_name='theme', + name='default_keyword', + field=models.CharField(default='', max_length=100), + preserve_default=False, + ), + migrations.AddField( + model_name='theme', + name='placeholder_text', + field=models.CharField(default='', max_length=100), + preserve_default=False, + ), + ] diff --git a/layers/migrations/0017_auto_20240906_2322.py b/layers/migrations/0017_auto_20240906_2322.py new file mode 100644 index 0000000..3b8f6ed --- /dev/null +++ b/layers/migrations/0017_auto_20240906_2322.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.12 on 2024-09-06 23:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0016_auto_20240904_1453'), + ] + + operations = [ + migrations.AlterField( + model_name='theme', + name='default_keyword', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name='theme', + name='placeholder_text', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/layers/migrations/0018_auto_20250131_1742.py b/layers/migrations/0018_auto_20250131_1742.py new file mode 100644 index 0000000..1229eb3 --- /dev/null +++ b/layers/migrations/0018_auto_20250131_1742.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.25 on 2025-01-31 17:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('layers', '0017_auto_20240906_2322'), + ] + + operations = [ + migrations.AddField( + model_name='theme', + name='is_top_theme', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='childorder', + name='content_type', + field=models.ForeignKey(limit_choices_to=models.Q(models.Q(('app_label', 'layers'), ('model', 'theme')), models.Q(('app_label', 'layers'), ('model', 'layer')), _connector='OR'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Child Type'), + ), + migrations.AlterField( + model_name='childorder', + name='parent_theme', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parent_theme', to='layers.theme'), + ), + ] diff --git a/layers/migrations/0019_auto_20250131_1805.py b/layers/migrations/0019_auto_20250131_1805.py new file mode 100644 index 0000000..1511122 --- /dev/null +++ b/layers/migrations/0019_auto_20250131_1805.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.25 on 2025-01-31 18:05 + +from django.db import migrations + + +def assign_top_themes(apps, schema_editor): + Theme = apps.get_model("layers", "Theme") + ChildOrder = apps.get_model("layers", "ChildOrder") + ContentType = apps.get_model("contenttypes", "ContentType") + themeContentType = ContentType.objects.get_for_model(Theme) + themeOrders = ChildOrder.objects.filter(content_type=themeContentType) + subtheme_ids = [x.object_id for x in themeOrders] + top_level_themes = Theme.objects.exclude(pk__in=subtheme_ids).exclude(display_name="Companion").filter(is_visible=True).order_by('order', 'name') + for theme in top_level_themes: + theme.is_top_theme = True + theme.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0018_auto_20250131_1742'), + ] + + operations = [ + migrations.RunPython(assign_top_themes) + ] diff --git a/layers/migrations/0020_auto_20250131_1944.py b/layers/migrations/0020_auto_20250131_1944.py new file mode 100644 index 0000000..5e96b91 --- /dev/null +++ b/layers/migrations/0020_auto_20250131_1944.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2025-01-31 19:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0019_auto_20250131_1805'), + ] + + operations = [ + migrations.AlterField( + model_name='theme', + name='is_top_theme', + field=models.BooleanField(default=False, help_text='Check this box to show this level at the top tier of the layer picker', verbose_name='Is Top Level Theme'), + ), + migrations.AlterField( + model_name='theme', + name='order', + field=models.PositiveIntegerField(default=10, help_text="Only used for 'Top Level Themes'", verbose_name='Default Order'), + ), + ] diff --git a/layers/migrations/0021_auto_20250131_2126.py b/layers/migrations/0021_auto_20250131_2126.py new file mode 100644 index 0000000..50750da --- /dev/null +++ b/layers/migrations/0021_auto_20250131_2126.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2025-01-31 21:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0020_auto_20250131_1944'), + ] + + operations = [ + migrations.AlterField( + model_name='theme', + name='name', + field=models.CharField(help_text='e.g.: "Grandparent|Parent|Name". Spaces are allowed.', max_length=255, verbose_name='System Name'), + ), + migrations.AlterField( + model_name='theme', + name='theme_type', + field=models.CharField(blank=True, choices=[('radio', 'radio: select 1 layer at a time'), ('checkbox', 'checkbox: support simultaneous layers')], default='checkbox', help_text='This only impacts how many LAYERS can be activated at once. This does not impact child-themes or their layers', max_length=50), + ), + ] diff --git a/layers/migrations/0022_auto_20250207_2230.py b/layers/migrations/0022_auto_20250207_2230.py new file mode 100644 index 0000000..d1429cc --- /dev/null +++ b/layers/migrations/0022_auto_20250207_2230.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.25 on 2025-02-07 22:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0021_auto_20250131_2126'), + ] + + operations = [ + migrations.AddIndex( + model_name='childorder', + index=models.Index(fields=['id'], name='layers_chil_id_b325cd_idx'), + ), + migrations.AddIndex( + model_name='childorder', + index=models.Index(fields=['parent_theme'], name='layers_chil_parent__becea0_idx'), + ), + migrations.AddIndex( + model_name='childorder', + index=models.Index(fields=['content_type'], name='layers_chil_content_13302b_idx'), + ), + migrations.AddIndex( + model_name='layer', + index=models.Index(fields=['id'], name='layers_laye_id_228d74_idx'), + ), + migrations.AddIndex( + model_name='theme', + index=models.Index(fields=['id'], name='layers_them_id_f874c8_idx'), + ), + ] diff --git a/layers/migrations/0023_alter_layer_site_alter_theme_site.py b/layers/migrations/0023_alter_layer_site_alter_theme_site.py new file mode 100644 index 0000000..cffcbd5 --- /dev/null +++ b/layers/migrations/0023_alter_layer_site_alter_theme_site.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.20 on 2025-04-08 00:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('layers', '0022_auto_20250207_2230'), + ] + + operations = [ + migrations.AlterField( + model_name='layer', + name='site', + field=models.ManyToManyField(related_name='%(class)s_site', to='sites.site'), + ), + migrations.AlterField( + model_name='theme', + name='site', + field=models.ManyToManyField(related_name='%(class)s_site', to='sites.site'), + ), + ] diff --git a/layers/migrations/0023_layer_last_http_status_layer_last_success_status_and_more.py b/layers/migrations/0023_layer_last_http_status_layer_last_success_status_and_more.py new file mode 100644 index 0000000..d1c7602 --- /dev/null +++ b/layers/migrations/0023_layer_last_http_status_layer_last_success_status_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.17 on 2025-03-28 23:29 + +import colorfield.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('layers', '0022_auto_20250207_2230'), + ] + + operations = [ + migrations.AddField( + model_name='layer', + name='last_http_status', + field=models.CharField(blank=True, help_text='HTTP status code from last check', max_length=100, null=True), + ), + migrations.AddField( + model_name='layer', + name='last_success_status', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='layer', + name='site', + field=models.ManyToManyField(related_name='%(class)s_site', to='sites.site'), + ), + migrations.AlterField( + model_name='layerarcfeatureservice', + name='color', + field=colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Fill Color'), + ), + migrations.AlterField( + model_name='layerarcfeatureservice', + name='outline_color', + field=colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Stroke Color'), + ), + migrations.AlterField( + model_name='layervector', + name='color', + field=colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Fill Color'), + ), + migrations.AlterField( + model_name='layervector', + name='outline_color', + field=colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Vector Stroke Color'), + ), + migrations.AlterField( + model_name='lookupinfo', + name='color', + field=colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Fill Color'), + ), + migrations.AlterField( + model_name='lookupinfo', + name='stroke_color', + field=colorfield.fields.ColorField(blank=True, default=None, image_field=None, max_length=25, null=True, samples=[('#FFFFFF', 'white'), ('#888888', 'gray'), ('#000000', 'black'), ('#FF0000', 'red'), ('#FFFF00', 'yellow'), ('#00FF00', 'green'), ('#00FFFF', 'cyan'), ('#0000FF', 'blue'), ('#FF00FF', 'magenta')], verbose_name='Stroke Color'), + ), + migrations.AlterField( + model_name='theme', + name='site', + field=models.ManyToManyField(related_name='%(class)s_site', to='sites.site'), + ), + ] diff --git a/layers/migrations/0024_merge_20250408_2111.py b/layers/migrations/0024_merge_20250408_2111.py new file mode 100644 index 0000000..26e7aae --- /dev/null +++ b/layers/migrations/0024_merge_20250408_2111.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.20 on 2025-04-08 21:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('layers', '0023_alter_layer_site_alter_theme_site'), + ('layers', '0023_layer_last_http_status_layer_last_success_status_and_more'), + ] + + operations = [ + ] diff --git a/layers/models.py b/layers/models.py index cb58adc..59be255 100644 --- a/layers/models.py +++ b/layers/models.py @@ -1,8 +1,10 @@ -from django.db import models +from django.db import models, transaction, IntegrityError, connection from django.contrib.sites.managers import CurrentSiteManager from django.contrib.sites.models import Site -from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.core.exceptions import ObjectDoesNotExist from django.template.defaultfilters import slugify from django.urls import reverse from django.conf import settings @@ -15,13 +17,22 @@ def get_domain(port=8010): #domain = Site.objects.all()[0].domain domain = Site.objects.get(id=SITE_ID).domain if 'localhost' in domain: - domain = 'localhost:%s' %port + domain = 'localhost:{}'.format(port) domain = 'http://' + domain except: domain = '..' #print(domain) return domain +# Since we migrate raw SQL data into the DB from data_manager, the sequences are not updated. +# This catches any time there is a mismatch and tries to update it. +def update_model_sequence(model, unique_key, manager): + max_theme_pk = manager.all().order_by('pk').last().pk + sequence_name = 'layers_{}_{}_seq'.format(model.__name__.lower(), unique_key) + with connection.cursor() as cursor: + cursor.execute("SELECT setval('{}', {}, true);".format(sequence_name, max_theme_pk)) + + class SiteFlags(object):#(models.Model): """Add-on class for displaying sites in the list_display in the admin. @@ -38,17 +49,22 @@ class AllObjectsManager(models.Manager): use_in_migrations = True class Theme(models.Model, SiteFlags): - LAYER_TYPE_CHOICES = ( - ('radio', 'radio'), - ('checkbox', 'checkbox'), + THEME_TYPE_CHOICES = ( + ('radio', 'radio: select 1 layer at a time'), + ('checkbox', 'checkbox: support simultaneous layers'), ) site = models.ManyToManyField(Site, related_name='%(class)s_site') - name = models.CharField(max_length=100) + name = models.CharField(max_length=255, verbose_name="System Name", + help_text='e.g.: "Grandparent|Parent|Name". Spaces are allowed.') uuid = models.UUIDField(default=uuid.uuid4, unique=True) + default_keyword = models.CharField(null=True, blank=True, max_length=100) + placeholder_text = models.CharField(null=True, blank=True, max_length=100) display_name = models.CharField(max_length=100) - layer_type = models.CharField(max_length=50, choices=LAYER_TYPE_CHOICES, blank=True, help_text='use placeholder to temporarily remove layer from TOC') + is_top_theme = models.BooleanField(default=False, verbose_name="Is Top Level Theme", help_text="Check this box to show this level at the top tier of the layer picker") + theme_type = models.CharField(max_length=50, choices=THEME_TYPE_CHOICES, blank=True, default='checkbox', + help_text='This only impacts how many LAYERS can be activated at once. This does not impact child-themes or their layers') # Modify Theme model to include order field but don't want subthemes to necessarily have an order, make order field optional - order = models.PositiveIntegerField(null=True, blank=True) + order = models.PositiveIntegerField(default=10, verbose_name='Default Order', help_text="Only used for 'Top Level Themes'") ###################################################### # DATES # @@ -58,9 +74,20 @@ class Theme(models.Model, SiteFlags): is_visible = models.BooleanField(default=True) - # need to add data_source, data_notes, source, data_url, catalog_html to match v1 subtheme/parent layer creation - description = models.TextField(blank=True, null=True) - overview = models.TextField(blank=True, null=True, default="") + is_dynamic = models.BooleanField(default=False) + dynamic_url = models.TextField(blank=True, null=True, default=None) + + # need to add data_source, data_notes, source, (prop) data_url, (prop) catalog_html to match v1 subtheme/parent layer creation + data_source = models.CharField(max_length=255, blank=True, null=True) + data_notes = models.TextField(blank=True, null=True, default=None) + source = models.CharField(max_length=255, blank=True, null=True, help_text='link back to the data source') + disabled_message = models.CharField(max_length=255, blank=True, null=True, default=None) + data_download = models.CharField(max_length=255, blank=True, null=True, help_text='link to download the data') + + + description = models.TextField(blank=True, null=True, default=None) + overview = models.TextField(blank=True, null=True, default=None) + learn_more = models.CharField(max_length=255, blank=True, null=True, default=None, help_text='MDAT/VTR/CAS: link to learn more') slug_name = models.CharField(max_length=200, blank=True, null=True) @@ -75,12 +102,40 @@ class Theme(models.Model, SiteFlags): feature_image = models.CharField(max_length=255, blank=True, null=True) feature_excerpt = models.TextField(blank=True, null=True) feature_link = models.CharField(max_length=255, blank=True, null=True) + + ###################################################### + # LEGEND # + # Child layers can inherit legends from parent themes# + ###################################################### + show_legend = models.BooleanField(default=True, help_text='show the legend for this layer if available') + legend = models.CharField(max_length=255, blank=True, null=True, help_text='URL or path to the legend image file') + legend_title = models.CharField(max_length=255, blank=True, null=True, help_text='alternative to using the layer name') + legend_subtitle = models.CharField(max_length=255, blank=True, null=True) + + order_records = GenericRelation('ChildOrder') + objects = CurrentSiteManager('site') all_objects = AllObjectsManager() + + def url(self): + # RDH Backward compatibility hack: Of all parent layers, only two had a value for 'url' that wasn't ''. + # We can hardcode those 2 values into this property to maintain 100% backward compatibility without needing to maintain new DB Fields. + v1_parent_layer_urls = { + "5258": "https://coast.noaa.gov/arcgismc/rest/services/Hosted/WastewaterOutfallPipes/FeatureServer/", + "5141": "https://oceandata.rad.rutgers.edu/arcgis/rest/services/RenewableEnergy/NYBightProposedCommercialLeases/MapServer/export", + } + + if str(self.pk) in v1_parent_layer_urls.keys(): + return v1_parent_layer_urls[str(self.pk)] + elif not self.parent == None: + return '' + + return '/visualize/#x=-73.24&y=38.93&z=7&logo=true&controls=true&basemap=Ocean&themes[ids][]={}&tab=data&legends=false&layers=true'.format(self.id) + @property def learn_link(self): domain = get_domain(8000) - return '%s/learn/%s' %(domain, self.name) + return '{}/learn/{}'.format(domain, self.name) @property def parent(self): @@ -89,41 +144,417 @@ def parent(self): content_type = ContentType.objects.get_for_model(self.__class__) # Find the ChildOrder instance that refers to this theme - child_order = ChildOrder.objects.filter(object_id=self.id, content_type=content_type).first() + child_orders = ChildOrder.objects.filter(object_id=self.id, content_type=content_type) + # Child orders have no concept of 'site'. To ensure 'site' is respected between layers + # and themes, we can query 'objects' by the possible ids of matching themes + parent_theme_ids = [x.parent_theme.pk for x in child_orders] + parent_theme = Theme.objects.filter(pk__in=parent_theme_ids).order_by('order', 'name', 'id').first() + + if parent_theme: + return parent_theme + return None + + @property + def top_parent(self): + parent = self.parent + if parent == None: + return self + while parent.parent != None: + parent = parent.parent + + return parent + + @property + def ancestor_ids(self): + # Get the ContentType for the Theme model + content_type = ContentType.objects.get_for_model(self.__class__) + + # Find the ChildOrder instance that refers to this theme + parent_orders = ChildOrder.objects.filter(object_id=self.id, content_type=content_type) + # Child orders have no concept of 'site'. To ensure 'site' is respected between layers + # and themes, we can query 'objects' by the possible ids of matching themes + parent_theme_ids = [x.parent_theme.pk for x in parent_orders] + parent_themes = Theme.objects.filter(pk__in=parent_theme_ids).order_by('order', 'name', 'id') - if child_order: - return child_order.parent_theme + # Initialize an empty list to hold ancestor theme ids + ancestor_theme_ids = [] + for parent in parent_themes: + ancestor_theme_ids.append(parent.pk) + ancestor_theme_ids.extend(parent.ancestor_ids) + + ancestor_theme_ids = list(set(ancestor_theme_ids)) + return ancestor_theme_ids + + @property + def ancestors(self): + return Theme.objects.filter(pk__in=self.ancestor_ids) + + ###################################################### + # CATALOG COMPATIBILITY # + ###################################################### + + @property + def data_url(self): + + if not self.parent: + data_catalog_url = "/data-catalog/{}/".format(self.name) + return data_catalog_url + + # Return None if DATA_CATALOG_ENABLED is False, or if no parent or slug_name is found + if settings.DATA_CATALOG_ENABLED and self.is_visible: + # parent_theme = self.parent + try: + parent_theme = self.top_parent + except IndexError: + parent_theme = False + + + if parent_theme: + # Format the parent theme's name to be URL-friendly + # This can be custom tailored if you store slugs differently + parent_theme_slug = parent_theme.name.replace(" ", "-") + + # Ensure there's a slug_name to use for constructing the URL + if self.slug_name: + # Construct the URL + + data_catalog_url = "/data-catalog/{}/#layer-info-{}".format(parent_theme_slug, self.slug_name) + return data_catalog_url + + return None + + @property + def bookmark(self): + # RDH Backward compatibility hack: Of all parent layers, only six had a value for 'bookmark'. + # We can hardcode those 6 values into this property to maintain 100% backward compatibility without needing to maintain new DB Fields. + v1_parent_bookmarks = { + '271': "/visualize/#x=-75.57&y=39.18&z=7&logo=true&controls=true&dls%5B%5D=false&dls%5B%5D=0.5&dls%5B%5D=272&basemap=Ocean&themes%5Bids%5D%5B%5D=4&tab=data&legends=false&layers=true", + '292': "/visualize/#x=-75.57&y=39.18&z=7&logo=true&controls=true&dls%5B%5D=true&dls%5B%5D=0.7&dls%5B%5D=311&basemap=Ocean&themes%5Bids%5D%5B%5D=4&tab=active&legends=false&layers=true", + '80': "/visualize/#x=-73.24&y=38.93&z=7&logo=true&controls=true&dls%5B%5D=true&dls%5B%5D=0.6&dls%5B%5D=80&basemap=Ocean&themes%5Bids%5D%5B%5D=16&tab=data&legends=false&layers=true", + '97': "/visualize/#x=-73.26&y=39.01&z=7&logo=true&controls=true&dls%5B%5D=true&dls%5B%5D=0.5&dls%5B%5D=98&basemap=Ocean&themes%5Bids%5D%5B%5D=8&tab=data&legends=false&layers=true", + '224': "/visualize/#x=-74.42&y=39.38&z=7&logo=true&controls=true&dls%5B%5D=true&dls%5B%5D=0.5&dls%5B%5D=225&basemap=Ocean&themes%5Bids%5D%5B%5D=8&tab=data&legends=false&layers=true", + '142': "/visualize/#x=-73.40&y=39.47&z=7&logo=true&controls=true&dls%5B%5D=true&dls%5B%5D=0.5&dls%5B%5D=143&basemap=Ocean&themes%5Bids%5D%5B%5D=8&tab=data&legends=false&layers=true" + } + if str(self.pk) in v1_parent_bookmarks.keys(): + return v1_parent_bookmarks[str(self.pk)] return None + + @property + def bookmark_link(self): + if self.bookmark and "%5D={}&".format(self.id) in self.bookmark: + return self.bookmark + + if self.parent and self.parent.bookmark and len(self.parent.bookmark) > 0: + return self.parent.bookmark.replace('', str(self.id)) + + if not self.parent == None and self.parent.name in ['vtr', 'mdat', 'cas', 'marine-life-library']: + # RDH: Most Marine Life layers seem to have bogus bookmarks. If the first line of this def + # isn't true, then we likely need to give users something that will work. This should do it. + root_str = '/visualize/#x=-73.24&y=38.93&z=7&logo=true&controls=true&basemap=Ocean' + layer_str = '&dls%5B%5D=true&dls%5B%5D=0.5&dls%5B%5D={}'.format(self.id) + themes_str = '' + if self.parent: + themes_str = '&themes%5Bids%5D%5B%5D={}'.format(self.parent.id) + + panel_str = '&tab=data&legends=false&layers=true' + + return "{}{}{}{}".format(root_str, layer_str, themes_str, panel_str) + + if self.parent: + # RDH 2024-05-06: All bookmark_link requests are v1 'Layer' requests. If they get here, they wanted a parent layer + return self.top_parent.url() + else: + return self.data_url + @property + def kml(self): + # RDH Backward compatibility hack: Of all parent layers, only 26 had a value for 'kml', and all were ''. + # We can hardcode those 26 pks to maintain 100% backward compatibility without needing to maintain new DB Fields. + # if self.pk in [3305, 80, 842, 840, 454, 344, 417, 3324, 838, 3927, 843, 4540, 545, 538, 1338, 163, 210, 136, 780, 1347, 1331, 224, 3310, 4509, 4475, 1756]: + # return "" + # else: + # return None + + # RDH: str(None) == 'None'. That is bonkers. KML will always be a string, so we just want to return '' + return '' + + @property + def data_download_link(self): + # RDH: str(None) == 'None'. That is bonkers. Links will always be a string, so we just want to return '' + return str(self.data_download or '') + + @property + def metadata_link(self): + # RDH Backward compatibility hack: Of all parent layers, only 49 had a value for 'metadata'. + # We can hardcode those 49 values to maintain 100% backward compatibility without needing to maintain new DB Fields. + v1_parent_metadata = { + "5765": "/static/data_manager/metadata/pdf/BoatRamps_WaterTrails_Metadata_20230718.pdf", + "5539": "/static/data_manager/metadata/pdf/BoatRamps_WaterTrails_Metadata_20230718.pdf", + "5849": "/static/data_manager/metadata/pdf/AcidificationMonitoringMidA_Ver202310_metadata.pdf", + "5775": "/static/data_manager/metadata/pdf/SeaTurtleStrandings_Metadata_10_2023.pdf", + "842": "http://seamap.env.duke.edu/models/mdat/Fish/MDAT_NEFSC_Fish_Summary_Products_Metadata.pdf", + "840": "http://seamap.env.duke.edu/models/mdat/Mammal/MDAT_Mammal_Summary_Products_Metadata.pdf", + "454": "http://seamap.env.duke.edu/models/mdat/Mammal/MDAT_Mammal_Summary_Products_v1_1_2016_08_29_Metadata.pdf", + "344": "http://seamap.env.duke.edu/models/mdat/Avian/MDAT_Avian_Summary_Products_v1_1_2016_08_29_Metadata.pdf", + "417": "http://seamap.env.duke.edu/models/mdat/Fish/MDAT_NEFSC_Fish_Summary_Products_v1_1_2016_08_29_Metadata.pdf", + "2950": "http://seamap.env.duke.edu/models/mdat/Fish/MDAT_NEFSC_Fish_Summary_Products_Metadata.pdf", + "5258": "https://www.fisheries.noaa.gov/inport/item/66706", + "841": "http://seamap.env.duke.edu/models/mdat/Mammal/MDAT_Mammal_Summary_Products_Metadata.pdf", + "839": "http://seamap.env.duke.edu/models/mdat/Avian/MDAT_Avian_Summary_Products_Metadata.pdf", + "2949": "http://seamap.env.duke.edu/models/mdat/Fish/MDAT_NEFSC_Fish_Summary_Products_Metadata.pdf", + "838": "http://seamap.env.duke.edu/models/mdat/Avian/MDAT_Avian_Summary_Products_Metadata.pdf", + "5311": "https://www.northeastoceandata.org/files/metadata/Themes/AIS/AllAISVesselTransitCounts2021.pdf", + "843": "http://seamap.env.duke.edu/models/mdat/Fish/MDAT_NEFSC_Fish_Summary_Products_Metadata.pdf", + "5207": "/static/data_manager/metadata/pdf/METADATA__MarineMammalStrandings_5_2022.pdf", + "5220": "/static/data_manager/metadata/pdf/METADATA__MarineMammalStrandings_5_2022.pdf", + "545": "/static/data_manager/metadata/html/NPP_SeasonalMax.html", + "538": "/static/data_manager/metadata/html/Fronts_SeasonalMax.html", + "313": "/static/data_manager/metadata/html/CASMetadata.html", + "1338": "https://www.northeastoceandata.org/files/metadata/Themes/AIS/AllAISVesselTransitCounts2017.pdf", + "163": "/static/data_manager/metadata/html/RecBoaterSurvey_All_Activities_Pts_metadata.html", + "210": "http://opdgig.dos.ny.gov/geoportal/catalog/search/resource/detailsnoheader.page?uuid={3B5083DA-2060-4F5D-8416-201A0A2B962B}", + "136": "/static/data_manager/metadata/html/CoastalRec_overview.html", + "780": "https://www.northeastoceandata.org/files/metadata/Themes/AIS/AllAISVesselTransitCounts2015.pdf", + "1347": "https://www.northeastoceandata.org/files/metadata/Themes/AIS/AllAISVesselTransitCounts2016.pdf", + "97": "/static/data_manager/metadata/html/AIS2011.html", + "1331": "https://www.northeastoceandata.org/files/metadata/Themes/AIS/AllAISVesselTransitCounts2013.pdf", + "224": "/static/data_manager/metadata/pdf/AtlanticVesselDensity2013Documentation_20150710.pdf", + "3310": "https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0196127", + "4509": "https://www.northeastoceandata.org/files/metadata/Themes/AIS/AllAISVesselTransitCounts2019.pdf", + "4475": "https://www.northeastoceandata.org/files/metadata/Themes/AIS/AllAISVesselTransitCounts2018.pdf", + "1756": "/static/data_manager/metadata/html/FishSpeciesThroughTime_metadata.htm", + "3315": "/static/data_manager/metadata/pdf/METADATA__HFRadarSurfaceCurrents_MidAtlantic.pdf", + "55": "/static/data_manager/metadata/html/CASMetadata.html", + "4842": "https://www.northeastoceandata.org/files/metadata/Themes/Habitat/FishingEffectsPercentSeabedHabitatDisturbancemetadata.pdf", + "4841": "https://www.northeastoceandata.org/files/metadata/Themes/Habitat/FishingEffectsPercentSeabedHabitatDisturbancemetadata.pdf", + "4843": "https://www.northeastoceandata.org/files/metadata/Themes/Habitat/FishingEffectsPercentSeabedHabitatDisturbancemetadata.pdf", + "5126": "https://www.northeastoceandata.org/files/metadata/Themes/AIS/AllAISVesselTransitCounts2020.pdf", + "142": "/static/data_manager/metadata/html/AIS2012.html", + "5787": "/static/data_manager/metadata/pdf/SeaTurtleStrandings_Metadata_10_2023.pdf", + "5141": "https://www.boem.gov/renewable-energy/state-activities/new-york-bight", + } + + if str(self.pk) in v1_parent_metadata.keys(): + return v1_parent_metadata[str(self.pk)] + else: + return None + + @property + def metadata(self): + return self.metadata_link + + @property + def tiles_link(self): + # RDH Backwards compatibility hack -- allow some parent layer (themes) to share 'tiles' for migration testing purposes + if self.pk in [4878, ]: + return self.slug_name + return None + + @property + def data_overview(self): + return self.overview + + @property + def data_overview_text(self): + if not self.overview and self.parent: + return self.parent.overview + else: + return self.overview + + @property + def is_sublayer(self): + if self.parent: + return True + return False + + @property + def children(self): + return ChildOrder.objects.filter(parent_theme=self) + + @property + def layer_count(self): + layerType = ContentType.objects.get_for_model(Layer) + themeType = ContentType.objects.get_for_model(Theme) + children_count = 0 + for child in self.children: + if child.content_type == layerType: + children_count += 1 + elif child.content_type == themeType: + child_layer_count = child.content_object.layer_count + # dynamic themes will appear to have 0 children, since they have no + # layer records. Instead, let's count each dynamic subtheme. + if child_layer_count == 0 and child.content_object.is_dynamic: + children_count += 1 + else: + children_count += child_layer_count + return children_count + + @property + def badge(self): + return self.layer_count + + @property + def badge_text(self): + return 'Records' + + @property + def catalog_html(self): + from django.template.loader import render_to_string + try: + return render_to_string( + "data_catalog/includes/cacheless_layer_info.html", + { + 'layer': self, + # 'sub_layers': self.sublayers.exclude(layer_type="placeholder") + } + ) + except Exception as e: + print(e) + + @property + def orders(self): + content_type = ContentType.objects.get_for_model(self.__class__) + return ChildOrder.objects.filter(object_id=self.id, content_type=content_type) + + def shortDict(self, site=None, order=None): + if site == None: + site_id = '' + else: + site_id = site.pk + cache_label = 'layers_theme_shortdict_{}_{}'.format(self.pk, site_id) + layers_dict = cache.get(cache_label) + if not layers_dict: + childOrders = ChildOrder.objects.filter(parent_theme=self) + if not site == None: + site_children_pks = [] + for child in childOrders: + if site in child.content_object.site.all(): + site_children_pks.append(child.pk) + childOrders = childOrders.filter(pk__in=site_children_pks) + subthemes = [] + layers = [] + for child in childOrders: + if child.content_type.model == 'theme' and child.content_object.is_visible: + subthemes.append(child.content_object.shortDict(site=site, order=child.order)) + if child.content_type.model == 'layer' and child.content_object.is_visible: + layers.append(child.content_object.shortDict(site=site, order=child.order, parent=self)) + + children_list = subthemes + layers + sorted_children_list = sorted(children_list, key=lambda x: (x['order'], x['name'])) + + layers_dict = { + 'id': self.id, + 'parent': None, + 'order': order if not order == None else 0, + 'name': self.display_name, + 'type': 'theme', + 'slug_name': self.slug_name, + 'bookmark_link': self.bookmark_link, + 'is_sublayer': self.is_sublayer, + 'children': sorted_children_list, + } + cache.set(cache_label, layers_dict, 60*60*24*7) + return layers_dict + + def __str__(self): + return "{} [T-{}]".format(self.name, self.pk) + + def save(self, *args, **kwargs): + content_type = ContentType.objects.get_for_model(self.__class__) + dirty_cache_keys = [] + # clean keys tied to /children/ api + children = ChildOrder.objects.filter(object_id=self.pk, content_type=content_type) + ancestor_ids = self.ancestor_ids + if self.slug_name == None or self.slug_name == '': + self.slug_name = "{}{}".format(slugify(self.name), self.pk) + for site_id in [x.pk for x in Site.objects.all()] + ['']: + for child in children: + dirty_cache_keys.append('layers_childorder_{}_{}'.format(child.pk, site_id)) + for ancestor_id in ancestor_ids: + dirty_cache_keys.append('layers_theme_shortdict_{}_{}'.format(ancestor_id, site_id)) + dirty_cache_keys.append('layers_theme_shortdict_{}_{}'.format(self.pk, site_id)) + for key in dirty_cache_keys: + cache.delete(key) + with connection.cursor() as cursor: + cursor.execute("NOTIFY {}, 'deletecache:{}'".format(settings.DB_CHANNEL, key)) + try: + with transaction.atomic(): + super(Theme, self).save(*args, **kwargs) + except IntegrityError as e: + if 'duplicate key value violates unique constraint' in str(e): + model = type(self) + unique_key = str(e).split('Key (')[-1].split(')=(')[0] + update_model_sequence(model, unique_key, manager=model.all_objects) + with transaction.atomic(): + super(Theme, self).save(*args, **kwargs) + else: + raise IntegrityError(e) + class Meta: ordering = ['order'] + app_label = 'layers' + indexes = [ + models.Index(fields=['id',]), + ] + # in admin, how can we show all layers regardless of layer type, without querying get all layers that are wms, get layers that are arcgis, etc, bc that is a lot of subqueries class Layer(models.Model, SiteFlags): LAYER_TYPE_CHOICES = ( - ('XYZ', 'XYZ'), - ('WMS', 'WMS'), - ('ArcRest', 'ArcRest'), - ('ArcFeatureServer', 'ArcFeatureServer'), - ('radio', 'radio'), - ('checkbox', 'checkbox'), - ('Vector', 'Vector'), - ('VectorTile', 'VectorTile'), - ('placeholder', 'placeholder'), + ('XYZ', 'XYZ'), + ('WMS', 'WMS'), + ('ArcRest', 'ArcRest'), + ('ArcFeatureServer', 'ArcFeatureServer'), + ('Vector', 'Vector'), + ('VectorTile', 'VectorTile'), + ('slider', 'slider'), ) + ###################################################### + # KEY INFO # + ###################################################### name = models.CharField(max_length=100) uuid = models.UUIDField(default=uuid.uuid4, unique=True) slug_name = models.CharField(max_length=200, blank=True, null=True) layer_type = models.CharField(max_length=50, choices=LAYER_TYPE_CHOICES, help_text='use placeholder to temporarily remove layer from TOC') - url = models.TextField(blank=True, default="") - proxy_url = models.BooleanField(default=False, help_text="proxy layer url through marine planner") - shareable_url = models.BooleanField(default=True, help_text='Indicates whether the data layer (e.g. map tiles) can be shared with others (through the Map Tiles Link)') - is_disabled = models.BooleanField(default=False, help_text='when disabled, the layer will still appear in the TOC, only disabled') - disabled_message = models.CharField(max_length=255, blank=True, null=True, default="") + url = models.TextField(blank=True, null=True, default=None) + site = models.ManyToManyField(Site, related_name='%(class)s_site') + + order_records = GenericRelation('ChildOrder') + objects = CurrentSiteManager('site') all_objects = AllObjectsManager() + + ###################################################### + # HTTP STATUS # + ###################################################### + last_success_status = models.DateTimeField(null=True, blank=True) + last_http_status = models.CharField(max_length=100, blank=True, null=True, help_text="HTTP status code from last check") + + ###################################################### + # DISPLAY # + ###################################################### + opacity = models.FloatField(default=.5, blank=True, null=True, verbose_name="Initial Opacity") + is_disabled = models.BooleanField(default=False, help_text='when disabled, the layer will still appear in the TOC, only disabled') + disabled_message = models.CharField(max_length=255, blank=True, null=True, default=None) + is_visible = models.BooleanField(default=True) + search_query = models.BooleanField(default=False, help_text='Select when layers are queryable - e.g. MDAT and CAS') + + ###################################################### + # DATA CATALOG # + ###################################################### + # RDH: geoportal_id is used in data_manager view 'geoportal_ids', which is not used for the built-in catalog tech + # but is critical for projects using GeoPortal as their catalog + geoportal_id = models.CharField(max_length=255, blank=True, null=True, default=None, help_text="GeoPortal UUID") + #data catalog links + catalog_name = models.TextField(null=True, blank=True, help_text="name of associated record in catalog", verbose_name='Catalog Record Name') + catalog_id = models.TextField(null=True, blank=True, help_text="unique ID of associated record in catalog", verbose_name='Catalog Record Id') + + + proxy_url = models.BooleanField(default=False, help_text="proxy layer url through marine planner") + shareable_url = models.BooleanField(default=True, help_text='Indicates whether the data layer (e.g. map tiles) can be shared with others (through the Map Tiles Link)') + + # UTFURL to be deprecated in v25 utfurl = models.CharField(max_length=255, blank=True, null=True) - site = models.ManyToManyField(Site, related_name='%(class)s_site') ###################################################### # LEGEND # ###################################################### @@ -132,22 +563,16 @@ class Layer(models.Model, SiteFlags): legend_title = models.CharField(max_length=255, blank=True, null=True, help_text='alternative to using the layer name') legend_subtitle = models.CharField(max_length=255, blank=True, null=True) - # RDH: geoportal_id is used in data_manager view 'geoportal_ids', which is never used - geoportal_id = models.CharField(max_length=255, blank=True, null=True, default=None, help_text="GeoPortal UUID") ###################################################### # METADATA # ###################################################### - description = models.TextField(blank=True, default="") - overview = models.TextField(blank=True, default="") + description = models.TextField(blank=True, null=True) + overview = models.TextField(blank=True, null=True, default=None) #formerly data_overview in data_manager data_source = models.CharField(max_length=255, blank=True, null=True) - data_notes = models.TextField(blank=True, default="") + data_notes = models.TextField(blank=True, null=True, default=None) data_publish_date = models.DateField(auto_now=False, auto_now_add=False, null=True, blank=True, default=None, verbose_name='Date published', help_text='YYYY-MM-DD') - #data catalog links - catalog_name = models.TextField(null=True, blank=True, help_text="name of associated record in catalog", verbose_name='Catalog Record Name') - catalog_id = models.TextField(null=True, blank=True, help_text="unique ID of associated record in catalog", verbose_name='Catalog Record Id') - ###################################################### # LINKS # ###################################################### @@ -172,19 +597,18 @@ class Layer(models.Model, SiteFlags): # attribute_title = models.CharField(max_length=255, blank=True, null=True) attribute_event = models.CharField(max_length=35, choices=EVENT_CHOICES, default='click') attribute_fields = models.ManyToManyField('AttributeInfo', blank=True) + # RDH: 2024-05-24: Annotated DOES have visualization logic tied to it, + # BUT: all known records are set to 'False' annotated = models.BooleanField(default=False) + # RDH: 2024-05-24: compress_display is NOT USED anymore. compress_display = models.BooleanField(default=False) mouseover_field = models.CharField(max_length=75, blank=True, null=True, default=None, help_text='feature level attribute used in mouseover display') - #use field to specify attribute on layer that you wish to be considered in adding conditional style formatting - lookup_field = models.CharField(max_length=255, blank=True, null=True, help_text="To override the style based on specific attributes, provide the attribute name here and define your attributes in the Lookup table below.") - #use widget along with creating Lookup Info records to apply conditional styling to your layer - lookup_table = models.ManyToManyField('LookupInfo', blank=True) - ###################################################### # ESPIS # ###################################################### #ESPIS Upgrade - RDH 7/23/2017 + #ESPIS Deprecated in 2024, to be removed in v25 espis_enabled = models.BooleanField(default=False) ESPIS_REGION_CHOICES = ( ('Mid Atlantic', 'Mid Atlantic'), @@ -211,24 +635,38 @@ def has_companion(self): return True return False + + ###################################################### + # Data Catalog Stuff # + ###################################################### @property def data_url(self): - - parent_theme = self.parent - - if parent_theme: - # Format the parent theme's name to be URL-friendly - # This can be custom tailored if you store slugs differently - parent_theme_slug = parent_theme.name.replace(" ", "-").lower() + + # Return None if DATA_CATALOG_ENABLED is False, or if no parent or slug_name is found + if settings.DATA_CATALOG_ENABLED: + parent_theme = False + try: + parent_theme = self.parent + if not parent_theme in self.themes: + parent_theme = self.themes.order_by('order', 'name', 'id').first() + except IndexError: + pass - # Ensure there's a slug_name to use for constructing the URL - if self.slug_name: - # Construct the URL + if parent_theme and parent_theme.is_visible: + # Format the parent theme's name to be URL-friendly + # This can be custom tailored if you store slugs differently + parent_theme_slug = parent_theme.top_parent.name.replace(" ", "-") - data_catalog_url = "/data-catalog/{}/{}".format(parent_theme_slug, self.slug_name) - return data_catalog_url + # Ensure there's a slug_name to use for constructing the URL + if self.slug_name and not self.slug_name == None: + slug_name = self.slug_name + else: + slug_name = slugify(self.name) + # Construct the URL + if slug_name: + data_catalog_url = "/data-catalog/{}/#layer-info-{}".format(parent_theme_slug, slug_name) + return data_catalog_url - # Return None if DATA_CATALOG_ENABLED is False, or if no parent or slug_name is found return None @property @@ -254,24 +692,66 @@ def catalog_html(self): except Exception as e: print(e) + @property + def data_download_link(self): + if self.data_download and self.data_download.lower() == 'none': + return None + if self.parent and not self.data_download and self.is_sublayer: + return self.parent.data_download + else: + return self.data_download + + @property + def metadata_link(self): + if self.metadata and self.metadata.lower() == 'none': + return None + if not self.metadata: + if self.is_sublayer and self.parent: + return self.parent.metadata + else: + return None + else: + return self.metadata + + @property + def tiles_link(self): + if self.is_shareable and self.layer_type in ['XYZ', 'ArcRest', 'WMS', 'slider']: + domain = get_domain(8000) + return self.slug_name + return None + @property def lookups(self): - return {'field': self.lookup_field, - 'details': [{'value': lookup.value, 'color': lookup.color, 'stroke_color': lookup.stroke_color, 'stroke_width': lookup.stroke_width, 'dashstyle': lookup.dashstyle, 'fill': lookup.fill, 'graphic': lookup.graphic, 'graphic_scale': lookup.graphic_scale} for lookup in self.lookup_table.all()]} + if not self.specific_instance == None and ( + hasattr(self.specific_instance, 'lookup_field') and + hasattr(self.specific_instance, 'lookup_table') + ): + return {'field': self.specific_instance.lookup_field, + 'details': [{'value': lookup.value, 'color': lookup.color, 'stroke_color': lookup.stroke_color, 'stroke_width': lookup.stroke_width, 'dashstyle': lookup.dashstyle, 'fill': lookup.fill, 'graphic': lookup.graphic, 'graphic_scale': lookup.graphic_scale} for lookup in self.specific_instance.lookup_table.all()]} + return { + 'field': None, + 'details': [] + } def dimensionRecursion(self, dimensions, associations): - associationArray = {} + if not dimensions: + return None + dimension = dimensions.pop(0) + associationArray = {} + for value in sorted(dimension.multilayerdimensionvalue_set.all(), key=lambda x: x.order): value_associations = associations.filter(pk__in=[x.pk for x in value.associations.all()]) - - if len(dimensions) > 0: - associationArray[str(value.value)] = self.dimensionRecursion(list(dimensions), value_associations) - else: - if len(value_associations) == 1 and value_associations[0].layer: + + if dimensions: # If there are more dimensions to process + nested_association_array = self.dimensionRecursion(list(dimensions), value_associations) + associationArray[str(value.value)] = nested_association_array + else: # No more dimensions, just collect the layers + if len(value_associations) >= 1 and value_associations[0].layer: associationArray[str(value.value)] = value_associations[0].layer.pk else: associationArray[str(value.value)] = None + return associationArray @property @@ -310,9 +790,13 @@ def associated_multilayers(self): else: return {} + @property + def data_overview(self): + return self.overview + @property def data_overview_text(self): - if not self.overview and self.parent: + if not self.overview and self.is_sublayer and self.parent: return self.parent.overview else: return self.overview @@ -325,19 +809,240 @@ def tooltip(self): return self.parent.description else: return self.data_overview_text + + @property + def is_shareable(self): + # RDH: Data_Manager had this option for parent layers, but ALL were == True. + return self.shareable_url + + @property + def top_parents(self): + # Get the ContentType for the Layer model + layer_content_type = ContentType.objects.get_for_model(self.__class__) + + # Find the ChildOrder instances that refer to this layer + child_orders = ChildOrder.objects.filter(object_id=self.id, content_type=layer_content_type) + + parents = [] + for child in child_orders.order_by('parent_theme__order'): + parent = child.parent_theme + while parent.parent != None: + parent = parent.parent + parents.append(parent) + + return parents + + @property + def top_parent(self): + if self.parent == None: + return None + else: + return self.parent.top_parent + + + @property + def parent_orders(self): + # Get the ContentType for the Layer model + layer_content_type = ContentType.objects.get_for_model(self.__class__) + + # Find the ChildOrder instance that refers to this layer + child_orders = ChildOrder.objects.filter( + object_id=self.id, content_type=layer_content_type + ).order_by( + 'parent_theme__order', 'order', 'parent_theme__name', 'parent_theme__id' + ) + return child_orders + + @property + def parents(self): + # Child orders have no concept of 'site'. To ensure 'site' is respected between layers + # and themes, we can query 'objects' by the possible ids of matching themes + parent_theme_ids = [x.parent_theme.pk for x in self.parent_orders] + parent_themes = Theme.all_objects.filter(pk__in=parent_theme_ids).order_by('order', 'name', 'id') + + return parent_themes @property def parent(self): - + parent_themes = self.parents + for pt in parent_themes: + # A layer can be a sublayer in one theme and a top-level layer in another. + # This identifies the highest level parent theme + # This is helpful for matching the old data_manager API v1: bookmark_link <- is_sublayer <- parent + # It's not terribly important otherwise + if pt.parent == None: + return pt + if parent_themes.count() > 0: + return parent_themes.first() + return None + + @property + def ancestor_ids(self): + lineage_ids = [] + parents = self.parents + for parent in parents: + lineage_ids += [parent.pk,] + lineage_ids += parent.ancestor_ids + return list(set(lineage_ids)) + + @property + def is_sublayer(self): + if self.parent == None: + return False + return self.parent.parent != None + + + @property + def themes(self): # Get the ContentType for the Layer model layer_content_type = ContentType.objects.get_for_model(self.__class__) # Find the ChildOrder instance that refers to this layer - child_order = ChildOrder.objects.filter(object_id=self.id, content_type=layer_content_type).first() + child_orders = ChildOrder.objects.filter(object_id=self.id, content_type=layer_content_type) + return Theme.all_objects.filter(pk__in=[co.parent_theme.id for co in child_orders]).order_by('order', 'name', 'id') + + @property + def bookmark_link(self): + if self.bookmark and "%5D={}&".format(self.id) in self.bookmark: + return self.bookmark + + if self.is_sublayer and self.parent.bookmark and len(self.parent.bookmark) > 0: + return self.parent.bookmark.replace('', str(self.id)) + + # RDH 2024-05-02: All parents are now Themes, not Layers. + # if self.is_parent: + # for theme in self.themes.all(): + # return theme.url() + + # RDH: Most Marine Life layers seem to have bogus bookmarks. If the first line of this def + # isn't true, then we likely need to give users something that will work. This should do it. + root_str = '/visualize/#x=-73.24&y=38.93&z=7&logo=true&controls=true&basemap=Ocean' + layer_str = '&dls%5B%5D=true&dls%5B%5D={}&dls%5B%5D={}'.format(str(self.opacity), self.id) + companion_str = '' + if self.has_companion: + for companionship in self.companionships.all(): + for companion in companionship.companions.exclude(pk=self.pk): + companion_str += '&dls%5B%5D=false&dls%5B%5D={}&dls%5B%5D={}'.format(str(companion.opacity), companion.id) + themes_str = '' + if self.themes.all().count() > 0: + themes_str = '&themes%5Bids%5D%5B%5D={}'.format(self.parent.id) + + panel_str = '&tab=data&legends=false&layers=true' + + return "{}{}{}{}{}".format(root_str, layer_str, companion_str, themes_str, panel_str) + + #RDH: Kept to maintain identical V1 results with data manager. This functionality will be deprecated in v25 + def get_espis_link(self): + if settings.ESPIS_ENABLED and self.espis_enabled: + search_dict = {} + if self.espis_search: + search_dict['q'] = self.espis_search + if self.espis_region: + if self.espis_region == "Mid Atlantic": + search_dict['bbox'] = "-81.71531609374854, 35.217958254501944, -69.19090203125185, 45.12716611403635" + if len(search_dict) > 0: + try: + # python 3 + from urllib.parse import urlencode + except (ModuleNotFoundError, ImportError) as e: + #python 2 + from urllib import urlencode + return 'https://esp-boem.hub.arcgis.com/search?{}'.format(urlencode(search_dict)) + + return False - if child_order: - return child_order.parent_theme + @property + def model(self): + layer_type_to_model = { + 'WMS': LayerWMS, + 'ArcRest': LayerArcREST, + 'ArcFeatureServer': LayerArcFeatureService, + 'Vector': LayerVector, + 'XYZ': LayerXYZ, + # Add more mappings as necessary + } + if self.layer_type in layer_type_to_model.keys(): + return layer_type_to_model.get(self.layer_type) return None + + @property + def specific_instance(self): + if not self.model == None: + try: + return self.model.objects.get(layer=self) + except ObjectDoesNotExist as e: + pass + return None + + def __str__(self): + return "{} [L-{}]".format(self.name, self.pk) + + def shortDict(self, site=None, order=None, parent=None): + children = [] + if parent == None: + parent = self.parent + if parent == None: + parent_dict = {} + if order == None: + order = 0 + else: + parent_dict = {'name': parent.display_name} + if order == None: + layer_type = ContentType.objects.get_for_model(self.__class__) + try: + order = ChildOrder.objects.get(parent_theme=parent, object_id=self.pk, content_type=layer_type).order + except Exception as e: + order = 0 + pass + layers_dict = { + 'id': self.id, + 'type': 'layer', + 'parent': parent_dict, + 'order': order, + 'name': self.name, + 'slug_name': self.slug_name, + 'bookmark_link': self.bookmark_link, + 'is_sublayer': self.is_sublayer, + 'children': children, + } + return layers_dict + + def save(self, *args, **kwargs): + content_type = ContentType.objects.get_for_model(self.__class__) + dirty_cache_keys = [ + 'layers_layer_serialized_details_{}'.format(self.pk), + ] + parent_orders = ChildOrder.objects.filter(object_id=self.pk, content_type=content_type) + ancestor_ids = self.ancestor_ids + for site_id in [x.pk for x in Site.objects.all()] + ['',]: + for parent in parent_orders: + # clean keys tied to /children/ api + dirty_cache_keys.append('layers_childorder_{}_{}'.format(parent.pk, site_id)) + for ancestor_id in ancestor_ids: + # clean keys tied to shortDict and the Data Catalog + dirty_cache_keys.append('layers_theme_shortdict_{}_{}'.format(ancestor_id, site_id)) + + for key in dirty_cache_keys: + cache.delete(key) + with connection.cursor() as cursor: + cursor.execute("NOTIFY {}, 'deletecache:{}'".format(settings.DB_CHANNEL, key)) + try: + with transaction.atomic(): + super(Layer, self).save(*args, **kwargs) + except IntegrityError as e: + if 'duplicate key value violates unique constraint' in str(e): + model = type(self) + unique_key = str(e).split('Key (')[-1].split(')=(')[0] + update_model_sequence(model, unique_key, manager=model.all_objects) + with transaction.atomic(): + super(Layer, self).save(*args, **kwargs) + else: + raise IntegrityError(e) + + class Meta: + indexes = [ + models.Index(fields=['id',]), + ] class Companionship(models.Model): # ForeignKey creates a one-to-many relationship @@ -348,7 +1053,79 @@ class Companionship(models.Model): # (Each companionship can relate to multiple Layers and vice versa) companions = models.ManyToManyField(Layer, related_name='companion_to') -class VectorType(Layer): +class ChildOrder(models.Model): + CHILD_CONTENT_TYPE_CHOICES = ( + models.Q(app_label='layers', model=Theme.__name__.lower()) | + models.Q(app_label='layers', model=Layer.__name__.lower()) + ) + + parent_theme = models.ForeignKey(Theme, on_delete=models.CASCADE, related_name='parent_theme') + + # The generic relation to point to either Theme or Layer + content_type = models.ForeignKey(ContentType, limit_choices_to=CHILD_CONTENT_TYPE_CHOICES, on_delete=models.CASCADE, verbose_name='Child Type') + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + + order = models.PositiveIntegerField(blank=True, null=True, default=10) + + ###################################################### + # DATES # + ###################################################### + date_created = models.DateTimeField(auto_now_add=True) + date_modified = models.DateTimeField(auto_now=True) + + # @property + # def site(self): + # parent_site_ids = [x.pk for x in self.parent_theme.site.all()] + # content_site_ids = [x.pk for x in self.content_object.site.all()] + # return Site.objects.filter(pk__in=parent_site_ids).filter(pk__in=content_site_ids) + + def save(self, *args, **kwargs): + dirty_cache_keys = [] + # clean keys tied to /children/ api + for site in Site.objects.all(): + dirty_cache_keys.append('layers_childorder_{}_{}'.format(self.pk, site.pk)) + for key in dirty_cache_keys: + cache.delete(key) + with connection.cursor() as cursor: + cursor.execute("NOTIFY {}, 'deletecache:{}'".format(settings.DB_CHANNEL, key)) + if not self.object_id == None: + # During import, if this is a dry run there will be no child object for this order. + try: + with transaction.atomic(): + super(ChildOrder, self).save(*args, **kwargs) + except IntegrityError as e: + if 'duplicate key value violates unique constraint' in str(e): + model = type(self) + unique_key = str(e).split('Key (')[-1].split(')=(')[0] + update_model_sequence(model, unique_key, manager=model.objects) + with transaction.atomic(): + super(ChildOrder, self).save(*args, **kwargs) + else: + raise IntegrityError(e) + + # def __str__(self): + # try: + # "{} [{}]".format(self.content_object.name, self.content_object.pk) + # except Exception as e: + # print(e) + # return 'name_failure' + + class Meta: + ordering = ['order'] + indexes = [ + models.Index(fields=['id',]), + models.Index(fields=['parent_theme',]), + models.Index(fields=['content_type',]), + ] + +class LayerType(models.Model): + layer = models.ForeignKey(Layer, on_delete=models.CASCADE, unique=True) + + class Meta: + abstract = True + +class VectorType(LayerType): CUSTOM_STYLE_CHOICES = ( (None, '------'), ('color', 'color'), @@ -400,19 +1177,23 @@ class VectorType(Layer): #if you need to resize vector graphic image so it looks appropriate on map #to make image smaller, use value less than 1, to make image larger, use values larger than 1 graphic_scale = models.FloatField(blank=True, null=True, default=1.0, verbose_name="Vector Graphic Scale", help_text="Scale for the vector graphic from original size.") - opacity = models.FloatField(default=.5, blank=True, null=True, verbose_name="Initial Opacity") + + #use field to specify attribute on layer that you wish to be considered in adding conditional style formatting + lookup_field = models.CharField(max_length=255, blank=True, null=True, help_text="To override the style based on specific attributes, provide the attribute name here and define your attributes in the Lookup table below.") + #use widget along with creating Lookup Info records to apply conditional styling to your layer + lookup_table = models.ManyToManyField('LookupInfo', blank=True) class Meta: abstract = True -class RasterType(Layer): +class RasterType(LayerType): query_by_point = models.BooleanField(default=False, help_text='Do not buffer selection clicks (not recommended for point or line data)') class Meta: abstract = True -class ArcServer(Layer): +class ArcServer(LayerType): arcgis_layers = models.CharField(max_length=255, blank=True, null=True, help_text='comma separated list of arcgis layer IDs') password_protected = models.BooleanField(default=False, help_text='check this if the server requires a password to show layers') disable_arcgis_attributes = models.BooleanField(default=False, help_text='Click to disable clickable ArcRest layers') @@ -423,29 +1204,32 @@ class Meta: class LayerArcREST(ArcServer, RasterType): def save(self, *args, **kwargs): if not self.id: # Check if this is a new instance - self.layer_type = 'ArcRest' + self.layer.layer_type = 'ArcRest' + self.layer.save() super(LayerArcREST, self).save(*args, **kwargs) class LayerArcFeatureService(ArcServer, VectorType): def save(self, *args, **kwargs): if not self.id: # Check if this is a new instance - self.layer_type = 'ArcFeatureServer' + self.layer.layer_type = 'ArcFeatureServer' + self.layer.save() super(LayerArcFeatureService, self).save(*args, **kwargs) class LayerXYZ(RasterType): def save(self, *args, **kwargs): if not self.id: # Check if this is a new instance - self.layer_type = 'XYZ' + self.layer.layer_type = 'XYZ' + self.layer.save() super(LayerXYZ, self).save(*args, **kwargs) class LayerVector(VectorType): def save(self, *args, **kwargs): if not self.id: # Check if this is a new instance - self.layer_type = 'Vector' + self.layer.layer_type = 'Vector' + self.layer.save() super(LayerVector, self).save(*args, **kwargs) class LayerWMS(RasterType): - # Are we using wms_help for anything? wms_help = models.BooleanField(default=False, help_text='Enable simple selection for WMS fields. Only supports WMS 1.1.1') WMS_VERSION_CHOICES = ( (None, ''), @@ -461,37 +1245,16 @@ class LayerWMS(RasterType): wms_timing = models.CharField(max_length=255, blank=True, null=True, default=None, help_text='http://docs.geoserver.org/stable/en/user/services/wms/time.html#specifying-a-time', verbose_name='WMS Time') wms_time_item = models.CharField(max_length=255, blank=True, null=True, default=None, help_text='Time Attribute Field, if different from "TIME". Proxy only.', verbose_name='WMS Time Field') wms_styles = models.CharField(max_length=255, blank=True, null=True, default=None, help_text='pre-determined styles, if exist', verbose_name='WMS Styles') - wms_additional = models.TextField(blank=True, null=True, default="", help_text='additional WMS key-value pairs: &key=value...', verbose_name='WMS Additional Fields') + wms_additional = models.TextField(blank=True, null=True, default=None, help_text='additional WMS key-value pairs: &key=value...', verbose_name='WMS Additional Fields') wms_info = models.BooleanField(default=False, help_text='enable Feature Info requests on click') wms_info_format = models.CharField(max_length=255, blank=True, null=True, default=None, help_text='Available supported feature info formats') + def save(self, *args, **kwargs): if not self.id: # Check if this is a new instance - self.layer_type = 'WMS' + self.layer.layer_type = 'WMS' + self.layer.save() super(LayerWMS, self).save(*args, **kwargs) -class Library(Layer): - queryable = models.BooleanField(default=False, help_text='Select when layers are queryable - e.g. MDAT and CAS') - -class ChildOrder(models.Model): - parent_theme = models.ForeignKey(Theme, on_delete=models.CASCADE, related_name='children') - - # The generic relation to point to either Theme or Layer - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField() - content_object = GenericForeignKey('content_type', 'object_id') - - order = models.PositiveIntegerField() - - ###################################################### - # DATES # - ###################################################### - date_created = models.DateTimeField(auto_now_add=True) - date_modified = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ['order'] - - class MultilayerDimension(models.Model): uuid = models.UUIDField(default=uuid.uuid4, unique=True) name = models.CharField(max_length=200, help_text='name to be used for selection in admin tool forms') @@ -546,10 +1309,10 @@ class MultilayerDimensionValue(models.Model): associations = models.ManyToManyField(MultilayerAssociation) def __unicode__(self): - return '%s: %s' % (self.dimension, self.value) + return '{}: {}'.format(self.dimension, self.value) def __str__(self): - return '%s: %s' % (self.dimension, self.value) + return '{}: {}'.format(self.dimension, self.value) class Meta: ordering = ('order',) @@ -616,7 +1379,7 @@ class AttributeInfo(models.Model): preserve_format = models.BooleanField(default=False, help_text='Prevent portal from making any changes to the data to make it human-readable') def __unicode__(self): - return unicode('%s' % (self.field_name)) + return unicode('{}'.format(self.field_name)) def __str__(self): return str(self.field_name) @@ -668,7 +1431,7 @@ class LookupInfo(models.Model): def __unicode__(self): if self.description: return unicode('{}: {}'.format(self.value, self.description)) - return unicode('%s' % (self.value)) + return unicode('{}'.format(self.value)) def __str__(self): if self.description: diff --git a/layers/serializers.py b/layers/serializers.py index 89aaaba..41dc8be 100644 --- a/layers/serializers.py +++ b/layers/serializers.py @@ -1,18 +1,20 @@ -from rest_framework import serializers +from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.template.loader import render_to_string from django.forms.models import model_to_dict +from django.template.loader import render_to_string +from django.urls import reverse from layers.models import Theme, Layer, ChildOrder, Companionship, LayerWMS, LayerArcREST, LayerArcFeatureService, LayerVector, LayerXYZ +from rest_framework import serializers #need to add catalog html to shared_layer_fields after adding it to subtheme serializer and to layer model -shared_layer_fields = ["id", "name", "uuid", "layer_type", "url", "proxy_url", "is_disabled", "disabled_message", "order", +shared_layer_fields = ["id", "name", "uuid", "type", "url", "proxy_url", "is_disabled", "disabled_message", "opacity", "show_legend", "legend", "legend_title", "legend_subtitle", "description", "overview", "data_url", - "data_source", "data_notes", "metadata", "source", "annotated", "utfurl", "lookups", "attributes", + "data_source", "data_notes", "metadata", "source", "annotated", "utfurl", "lookups", "attributes", # "parent", Removed, since it's in LayerSerializer - "kml", "data_download", "learn_more", "map_tiles", "label_field", "date_modified", "minZoom", "maxZoom", "has_companion", + "kml", "data_download", "learn_more", "tiles", "label_field", "date_modified", "minZoom", "maxZoom", "has_companion", "is_multilayer_parent", "is_multilayer", "dimensions", "associated_multilayers"] -vector_layer_fields = ["custom_style", "outline_width", "outline_color", "outline_opacity", - "fill_opacity", "color", "point_radius", "graphic", "graphic_scale", "opacity"] +vector_layer_fields = ["custom_style", "outline_width", "outline_color", "outline_opacity", + "fill_opacity", "color", "point_radius", "graphic", "graphic_scale"] layer_wms_fields = ["wms_slug", "wms_version", "wms_format", "wms_srs", "wms_timing", "wms_time_item", "wms_styles", "wms_additional", "wms_info", "wms_info_format"] @@ -21,10 +23,17 @@ raster_type_fields = ["query_by_point"] -library_fields = ["queryable"] +library_fields = [] -def get_companion_layers(layer): - companionships = Companionship.objects.filter(layer=layer) +def get_companion_layers(obj): + if hasattr(obj, 'layer'): + layer_instance = obj.layer + else: + # If `obj` is already a Layer instance or for other cases + layer_instance = obj + + # Now, use `layer_instance` to filter Companionship instances + companionships = Companionship.objects.filter(layer=layer_instance) companion_layers = [] for companionship in companionships: companion_layers.extend(companionship.companions.all()) @@ -59,68 +68,104 @@ def accumulate_child_layers(theme, accumulated_layers): return serialized_child_layers -def get_layer_order(layer): - if isinstance(layer, dict): - # If it's a dict, extract layer_type from the dict - layer_type = layer.get('layer_type') - else: - # If it's a model instance, use the attribute directly - layer_type = layer.layer_type - if layer_type == 'WMS': - model_class = LayerWMS - elif layer_type == "ArcRest": - model_class = LayerArcREST - elif layer_type == "ArcFeatureServer": - model_class = LayerArcFeatureService - elif layer_type == "Vector": - model_class = LayerVector - elif layer_type == 'XYZ': - model_class = LayerXYZ - elif layer_type == "radio": - model_class = Theme - elif layer_type == "checkbox": - model_class = Theme - else: - model_class = layer.__class__ +def get_layer_order(obj): + layer_content_type = ContentType.objects.get_for_model(Layer) + if hasattr(obj, 'layer'): + obj_id = obj.layer.id + else: + obj_id = obj.id + if isinstance(obj, Theme): + layer_content_type = ContentType.objects.get_for_model(Theme) + # Mapping of types to their respective model classes try: - content_type = ContentType.objects.get_for_model(model_class) - child_orders = ChildOrder.objects.filter(content_type=content_type, object_id=layer['id'] if isinstance(layer, dict) else layer.id) - first_child_order = child_orders.first() - return first_child_order.order if first_child_order else 0 + child_order = ChildOrder.objects.filter(content_type=layer_content_type, object_id=obj_id).first() + return child_order.order if child_order else None except ChildOrder.DoesNotExist: - return 0 -#inherit v2 serializer to make v1 serializer + return None -class LayerSerializer(serializers.ModelSerializer): - parent = serializers.SerializerMethodField() +class DynamicLayerFieldsMixin: + """ + A mixin to dynamically add methods to a serializer for retrieving fields from a related Layer object. + """ + @classmethod + def add_layer_field_methods(cls, fields): + """ + Dynamically adds SerializerMethodField and their getters for specified fields from the Layer model. + """ + + def make_getter(field): + if field == 'parent': + def getter(self, instance): + # Custom logic for handling the 'parent' field + if hasattr(instance, 'layer'): + current_parent = instance.layer.parent + if current_parent is not None and current_parent.parent is not None: + while current_parent.parent.parent is not None: + current_parent = current_parent.parent + return SubThemeSerializer(current_parent).data + return None + return getattr(instance, field, None) + elif field == 'type': # Handling for 'type' field + def getter(self, instance): + return getattr(instance.layer, 'layer_type', None) + return getter + elif field == 'tiles': # Handling for 'type' field + def getter(self, instance): + return getattr(instance.layer, 'tiles_link', None) + return getter + elif field == 'metadata': + def getter(self, instance): + if hasattr(instance.layer, 'metadata_link'): + return getattr(instance.layer, 'metadata_link', None) + return getattr(instance.layer, field, None) + elif field in ['search_query', 'queryable']: + def getter(self, instance): + return getattr(instance.layer, 'search_query', False) + else: + # For all other fields, use the default handling + def getter(self, instance): + return getattr(instance.layer, field, None) + return getter + + for field in fields: + method_name = f'get_{field}' + getter = make_getter(field) + setattr(cls, method_name, getter) + cls._declared_fields[field] = serializers.SerializerMethodField(method_name=method_name) + +class LayerSerializer(DynamicLayerFieldsMixin, serializers.ModelSerializer): + subLayers = serializers.SerializerMethodField() + companion_layers = serializers.SerializerMethodField() + type = serializers.SerializerMethodField() + tiles = serializers.SerializerMethodField() + queryable = serializers.BooleanField(default=False, read_only=True) + class Meta: model = Layer - fields = ["parent", "catalog_html"] - - def get_parent(self, layer: Layer): - # Has a direct parent, and the direct parent is not on the topmost level. - if layer.parent != None and layer.parent.parent != None: - # Flatten parent hierarchy until we reach a first level parent. - current_parent = layer.parent - while current_parent.parent.parent != None: - current_parent = current_parent.parent - return current_parent.id - else: - return None - + fields = ["parent", "catalog_html", "queryable",] + shared_layer_fields + def get_companion_layers(self, obj): + companion_layers = get_companion_layers(obj) + return CompanionLayerSerializer(companion_layers, many=True, context={'companion_parent':obj}).data + def get_subLayers(self, obj): + subLayers = get_serialized_sublayers(obj) + return subLayers + def get_type(self, obj): + return obj.layer_type + def get_tiles(self, obj): + return obj.tiles_link + def get_queryable(self, obj): + return self.search_query + class LayerWMSSerializer(LayerSerializer): order = serializers.SerializerMethodField() - # Set default values for unrelated fields for layer type to support V1 arcgis_layers = serializers.CharField(default=None, read_only=True) password_protected = serializers.BooleanField(default=False, read_only=True) disable_arcgis_attributes = serializers.BooleanField(default=False, read_only=True) - queryable = serializers.BooleanField(default=False, read_only=True) - custom_style = serializers.CharField(default=None, read_only=True) outline_width = serializers.IntegerField(default=None, read_only=True) outline_color = serializers.CharField(default=None, read_only=True) @@ -130,22 +175,14 @@ class LayerWMSSerializer(LayerSerializer): point_radius = serializers.IntegerField(default=None, read_only=True) graphic = serializers.CharField(default=None, read_only=True) graphic_scale = serializers.FloatField(default=1.0, read_only=True) - opacity = serializers.FloatField(default=.5, read_only=True) - subLayers = serializers.SerializerMethodField() - companion_layers = serializers.SerializerMethodField() class Meta(LayerSerializer.Meta): model = LayerWMS - fields = LayerSerializer.Meta.fields + shared_layer_fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers"] + ["subLayers"] - def get_companion_layers(self, obj): - companion_layers = get_companion_layers(obj) - return CompanionLayerSerializer(companion_layers, many=True).data - def get_subLayers(self, obj): - subLayers = get_serialized_sublayers(obj) - return subLayers + fields = LayerSerializer.Meta.fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers"] + ["subLayers" , "order"] def get_order(self, obj): return get_layer_order(obj) - +LayerWMSSerializer.add_layer_field_methods(LayerSerializer.Meta.fields) + class LayerArcRESTSerializer(LayerSerializer): order = serializers.SerializerMethodField() wms_slug = serializers.CharField(default=None, read_only=True) @@ -159,8 +196,6 @@ class LayerArcRESTSerializer(LayerSerializer): wms_info = serializers.BooleanField(default=False, read_only=True) wms_info_format = serializers.CharField(default=None, read_only=True) - queryable = serializers.BooleanField(default=False, read_only=True) - custom_style = serializers.CharField(default=None, read_only=True) outline_width = serializers.IntegerField(default=None, read_only=True) outline_color = serializers.CharField(default=None, read_only=True) @@ -170,23 +205,15 @@ class LayerArcRESTSerializer(LayerSerializer): point_radius = serializers.IntegerField(default=None, read_only=True) graphic = serializers.CharField(default=None, read_only=True) graphic_scale = serializers.FloatField(default=1.0, read_only=True) - opacity = serializers.FloatField(default=.5, read_only=True) - subLayers = serializers.SerializerMethodField() - companion_layers = serializers.SerializerMethodField() class Meta(LayerSerializer.Meta): model = LayerArcREST - fields = LayerSerializer.Meta.fields + shared_layer_fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers"] + ["subLayers"] - + fields = LayerSerializer.Meta.fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers"] + ["subLayers", "order"] + def get_order(self, obj): return get_layer_order(obj) - def get_companion_layers(self, obj): - companion_layers = get_companion_layers(obj) - return CompanionLayerSerializer(companion_layers, many=True).data - def get_subLayers(self, obj): - subLayers = get_serialized_sublayers(obj) - return subLayers - + +LayerArcRESTSerializer.add_layer_field_methods(LayerSerializer.Meta.fields) class LayerArcFeatureServiceSerializer(LayerSerializer): order = serializers.SerializerMethodField() @@ -203,23 +230,13 @@ class LayerArcFeatureServiceSerializer(LayerSerializer): query_by_point = serializers.BooleanField(default=False, read_only=True) - queryable = serializers.BooleanField(default=False, read_only=True) - subLayers = serializers.SerializerMethodField() - companion_layers = serializers.SerializerMethodField() - class Meta(LayerSerializer.Meta): model = LayerArcFeatureService - fields = LayerSerializer.Meta.fields + shared_layer_fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers"] + ["subLayers"] - + fields = LayerSerializer.Meta.fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers"] + ["subLayers", "order"] + def get_order(self, obj): return get_layer_order(obj) - def get_companion_layers(self, obj): - companion_layers = get_companion_layers(obj) - return CompanionLayerSerializer(companion_layers, many=True).data - def get_subLayers(self, obj): - subLayers = get_serialized_sublayers(obj) - return subLayers - +LayerArcFeatureServiceSerializer.add_layer_field_methods(LayerSerializer.Meta.fields) class LayerXYZSerializer(LayerSerializer): order = serializers.SerializerMethodField() @@ -234,8 +251,6 @@ class LayerXYZSerializer(LayerSerializer): wms_info = serializers.BooleanField(default=False, read_only=True) wms_info_format = serializers.CharField(default=None, read_only=True) - queryable = serializers.BooleanField(default=False, read_only=True) - custom_style = serializers.CharField(default=None, read_only=True) outline_width = serializers.IntegerField(default=None, read_only=True) outline_color = serializers.CharField(default=None, read_only=True) @@ -245,27 +260,19 @@ class LayerXYZSerializer(LayerSerializer): point_radius = serializers.IntegerField(default=None, read_only=True) graphic = serializers.CharField(default=None, read_only=True) graphic_scale = serializers.FloatField(default=1.0, read_only=True) - opacity = serializers.FloatField(default=.5, read_only=True) arcgis_layers = serializers.CharField(default=None, read_only=True) password_protected = serializers.BooleanField(default=False, read_only=True) disable_arcgis_attributes = serializers.BooleanField(default=False, read_only=True) - subLayers = serializers.SerializerMethodField() - companion_layers = serializers.SerializerMethodField() - + + class Meta(LayerSerializer.Meta): model = LayerXYZ - fields = LayerSerializer.Meta.fields + shared_layer_fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers"] + ["subLayers"] - + fields = LayerSerializer.Meta.fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers"] + ["subLayers", "order"] + def get_order(self, obj): return get_layer_order(obj) - def get_companion_layers(self, obj): - companion_layers = get_companion_layers(obj) - return CompanionLayerSerializer(companion_layers, many=True).data - def get_subLayers(self, obj): - subLayers = get_serialized_sublayers(obj) - return subLayers - +LayerXYZSerializer.add_layer_field_methods(LayerSerializer.Meta.fields) class LayerVectorSerializer(LayerSerializer): order = serializers.SerializerMethodField() @@ -280,28 +287,89 @@ class LayerVectorSerializer(LayerSerializer): wms_info = serializers.BooleanField(default=False, read_only=True) wms_info_format = serializers.CharField(default=None, read_only=True) - queryable = serializers.BooleanField(default=False, read_only=True) - arcgis_layers = serializers.CharField(default=None, read_only=True) password_protected = serializers.BooleanField(default=False, read_only=True) query_by_point = serializers.BooleanField(default=False, read_only=True) disable_arcgis_attributes = serializers.BooleanField(default=False, read_only=True) subLayers = serializers.SerializerMethodField() companion_layers = serializers.SerializerMethodField() - + class Meta(LayerSerializer.Meta): - model = LayerVector - fields = LayerSerializer.Meta.fields + shared_layer_fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers"] + ["subLayers"] - + model = LayerVector + fields = LayerSerializer.Meta.fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers"] + ["subLayers", "order"] + def get_order(self, obj): return get_layer_order(obj) - def get_companion_layers(self, obj): - companion_layers = get_companion_layers(obj) - return CompanionLayerSerializer(companion_layers, many=True).data - def get_subLayers(self, obj): - subLayers = get_serialized_sublayers(obj) - return subLayers - +LayerVectorSerializer.add_layer_field_methods(LayerSerializer.Meta.fields) + +def get_serializer_by_layer_type(layer_type): + layer_type_to_serializer = { + 'WMS': LayerWMSSerializer, + 'ArcRest': LayerArcRESTSerializer, + 'ArcFeatureServer': LayerArcFeatureServiceSerializer, + 'Vector': LayerVectorSerializer, + 'XYZ': LayerXYZSerializer, + # Add more mappings as necessary + } + return layer_type_to_serializer.get(layer_type) + +def get_specific_layer_instance(layer): + if isinstance(layer, Layer): + # model = get_model_by_layer_type(layer.layer_type) + try: + if layer.layer_type == "slider": + return layer + else: + return layer.model.objects.get(layer=layer) + except: + pass + return None + + + +def get_serialized_layer(instance): + specific_layer_instance = None + if isinstance(instance, Layer): + # Find the related layer model based on the layer_type + layer_type = instance.layer_type + serializer_class = get_serializer_by_layer_type(layer_type) + if serializer_class: + # Find the specific layer instance (e.g., LayerWMS) related to this Layer + specific_layer_instance = instance.specific_instance + if not specific_layer_instance == None: + serializer = serializer_class(specific_layer_instance) + else: + # Fallback if the specific layer instance was not found + return {} + else: + # Fallback for unexpected layer types + return {} + elif isinstance(instance, LayerWMS): + serializer = LayerWMSSerializer(instance) + elif isinstance(instance, LayerArcREST): + serializer = LayerArcRESTSerializer(instance) + elif isinstance(instance, LayerArcFeatureService): + serializer = LayerArcFeatureServiceSerializer(instance) + elif isinstance(instance, LayerVector): + serializer = LayerVectorSerializer(instance) + elif isinstance(instance, LayerXYZ): + serializer = LayerXYZSerializer(instance) + elif isinstance(instance, Theme): + # Check if the theme is a subtheme (has a parent theme) + if instance.parent: + # Use SubThemeSerializer for subthemes + serializer = SubThemeSerializer(instance) + else: + # Use a different serializer for parent themes if necessary + serializer = ThemeSerializer(instance) + else: + # Fallback for unexpected types + return {} + + # Flatten the representation to include the serialized data directly + serialized_data = serializer.data + + return (serialized_data, specific_layer_instance) class ChildOrderSerializer(serializers.ModelSerializer): # Serialize the generic related object @@ -309,7 +377,22 @@ def to_representation(self, instance): # Retrieve the related object related_object = instance.content_object # Serialize the related object based on its class - if isinstance(related_object, LayerWMS): + if isinstance(related_object, Layer): + # Find the related layer model based on the layer_type + layer_type = related_object.layer_type + serializer_class = get_serializer_by_layer_type(layer_type) + if serializer_class: + # Find the specific layer instance (e.g., LayerWMS) related to this Layer + specific_layer_instance = related_object.specific_instance + if specific_layer_instance: + serializer = serializer_class(specific_layer_instance) + else: + # Fallback if the specific layer instance was not found + return {} + else: + # Fallback for unexpected layer types + return {} + elif isinstance(related_object, LayerWMS): serializer = LayerWMSSerializer(related_object) elif isinstance(related_object, LayerArcREST): serializer = LayerArcRESTSerializer(related_object) @@ -338,20 +421,23 @@ def to_representation(self, instance): serialized_data['name'] = getattr(instance.content_object, 'name', '') serialized_data['id'] = getattr(instance.content_object, 'id', 0) return serialized_data - + class Meta: model = ChildOrder + fields = [] + # use this serializer for only the top level themes # create a new serializer for subthemes, so that it matches the layer format class ThemeSerializer(serializers.ModelSerializer): learn_link = serializers.SerializerMethodField() + queryable = serializers.SerializerMethodField() # this is called layers only to match v1 but this includes subthemes and layers layers = ChildOrderSerializer(many=True, read_only=True, source='children') class Meta: model = Theme - fields = ["id", "name", "display_name", "layers", "learn_link", "is_visible", "description"] + fields = ["id", "name", "display_name", "layers", "learn_link", "is_visible", "description", "queryable"] def to_representation(self, instance): # Call the super method to get the default representation @@ -359,44 +445,59 @@ def to_representation(self, instance): # Order the 'layers' by the 'order' field and return only layer IDs if 'layers' in ret: - # Create a mapping of layer id to its order and name - layer_mapping = {layer['id']: (layer.get('order', 0), layer.get('name', '')) for layer in ret['layers']} - - # Sort layer ids based on the order and name - sorted_layer_ids = sorted( - layer_mapping.keys(), - key=lambda id: (layer_mapping[id][0], layer_mapping[id][1], id) - ) - - # Update the 'layers' field to be a list of sorted ids - ret['layers'] = sorted_layer_ids - + sorted_layers_details = [] + + # Iterate over each child order to get related layer details + for child_order in instance.children.all(): + content_object = child_order.content_object + + if content_object is not None: + layer_details = { + 'id': content_object.id, + 'name': content_object.name, + 'order': child_order.order + } + sorted_layers_details.append(layer_details) + else: + # Handle the case where content_object is None, perhaps log it or skip + continue # Skip this child_order + + # Sort layers based on the order, then by name + sorted_layers_details.sort(key=lambda x: (x['order'], x['name'])) + + # Extract just the IDs from the sorted details + sorted_ids = [detail['id'] for detail in sorted_layers_details if detail] # Ensure detail is not None + # Update 'layers' in the returned dictionary with sorted ids + ret['layers'] = sorted_ids return ret - + def get_learn_link(self, obj): return obj.learn_link + + def get_queryable(self, obj): + return False + + +class ShortThemeSerializer(serializers.ModelSerializer): + class Meta: + model = Theme + fields = ["id", "name", "display_name", "is_visible",] class SubThemeSerializer(serializers.ModelSerializer): order = serializers.SerializerMethodField() - url = serializers.CharField(default="", read_only=True) - proxy_url = serializers.BooleanField(default=False, read_only=True) - is_disabled = serializers.BooleanField(default=False, read_only=True) - disabled_message = serializers.CharField(default="", read_only=True) - show_legend = serializers.BooleanField(default=True, read_only=True) - legend = serializers.CharField(default=None, read_only=True) - legend_title = serializers.CharField(default=None, read_only=True) - legend_subtitle = serializers.CharField(default=None, read_only=True) + url = serializers.CharField(default="", read_only=True) + proxy_url = serializers.BooleanField(default=False, read_only=True) + is_disabled = serializers.BooleanField(default=False, read_only=True) + disabled_message = serializers.CharField(default=None, read_only=True) overview = serializers.CharField(default="", read_only=True) - data_source = serializers.CharField(default=None, read_only=True) - data_notes = serializers.CharField(default="", read_only=True) - # need to add catalog_html - metadata = serializers.CharField(read_only=True, default=None) + data_source = serializers.CharField(default=None, read_only=True) + data_notes = serializers.CharField(default=None, read_only=True) + type = serializers.SerializerMethodField() source = serializers.CharField(read_only=True, default=None) - annotated = serializers.BooleanField(default=False, read_only=True) - kml = serializers.CharField(read_only=True, default=None) + annotated = serializers.BooleanField(default=False, read_only=True) + kml = serializers.CharField(read_only=True, default=None) data_download = serializers.CharField(read_only=True, default=None) - learn_more = serializers.CharField(read_only=True, default=None) - map_tiles = serializers.CharField(read_only=True, default=None) + tiles = serializers.CharField(read_only=True, default=None) label_field = serializers.CharField(read_only=True, default=None) minZoom = serializers.FloatField(read_only=True, default=None) maxZoom = serializers.FloatField(read_only=True, default=None) @@ -428,7 +529,6 @@ class SubThemeSerializer(serializers.ModelSerializer): point_radius = serializers.IntegerField(default=None, read_only=True) graphic = serializers.CharField(default=None, read_only=True) graphic_scale = serializers.FloatField(default=1.0, read_only=True) - opacity = serializers.FloatField(default=.5, read_only=True) subLayers = serializers.SerializerMethodField() has_companion = serializers.BooleanField(default=False, read_only=True) companion_layers = serializers.ListField(default=[], read_only=True) @@ -442,34 +542,33 @@ class SubThemeSerializer(serializers.ModelSerializer): lookups = serializers.SerializerMethodField() catalog_html = serializers.SerializerMethodField() utfurl = serializers.CharField(default=None, read_only=True) + # Themes do not have 'opacity' by default + opacity = serializers.FloatField(default=0.5) class Meta: model = Theme - fields = shared_layer_fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers", "parent", "catalog_html"] + ["subLayers"] - + fields = shared_layer_fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers", "parent", "catalog_html"] + ["subLayers", "order", "queryable",] + def get_order(self, obj): return get_layer_order(obj) def get_parent(self, obj): return None def to_representation(self, instance): ret = super().to_representation(instance) - ret['description'] = ret['description'] if ret['description'] is not None else "" + ret['description'] = ret['description'] return ret def get_subLayers(self, obj): subLayers = get_serialized_sublayers(obj) return subLayers + def get_type(self, obj): + if hasattr(obj, 'theme_type') and obj.theme_type in ['radio', 'checkbox']: + return obj.theme_type def get_data_url(self, obj): - parent_theme = obj.parent - - if parent_theme: - # Format the parent theme's name to be URL-friendly - # This can be custom tailored if you store slugs differently - parent_theme_slug = parent_theme.name.replace(" ", "-").lower() - - # Ensure there's a slug_name to use for constructing the URL - if obj.slug_name: - # Construct the URL - data_catalog_url = "/data-catalog/{}/{}".format(parent_theme_slug, obj.slug_name) - return data_catalog_url + if settings.DATA_CATALOG_ENABLED: + theme = obj.parent + if theme: + theme_url = reverse('portal.data_catalog.views.theme', args=[theme.name]) + if theme_url: + return "{0}#layer-info-{1}".format(theme_url, obj.slug_name) return None def get_attributes(self, obj): return {'compress_attributes': False, @@ -482,17 +581,20 @@ def get_lookups(self, obj): return {'field': None, 'details': []} def get_catalog_html(self, obj): - try: return render_to_string( "data_catalog/includes/cacheless_layer_info.html", { - 'layer': model_to_dict(obj), + 'layer': obj, # 'sub_layers': self.sublayers.exclude(layer_type="placeholder") } ) except Exception as e: print(e) + return None + def get_queryable(self, obj): + return False + @@ -500,20 +602,21 @@ def get_catalog_html(self, obj): class CompanionLayerSerializer(serializers.ModelSerializer): order = serializers.SerializerMethodField() overview = serializers.SerializerMethodField() - description = serializers.SerializerMethodField() parent = serializers.SerializerMethodField() - + tiles = serializers.SerializerMethodField() + type = serializers.SerializerMethodField() + class Meta: - model = Layer - fields = shared_layer_fields + ["parent"] - + model = Layer + fields = shared_layer_fields + ["parent", "order"] + def get_overview(self, obj): return obj.data_overview_text - def get_description(self, obj): - return obj.tooltip def get_order(self, obj): return get_layer_order(obj) def get_parent(self, obj): + if hasattr(self, 'context') and 'companion_parent' in self.context.keys(): + return self.context['companion_parent'].layer.id # This query looks for Companionship instances where the current layer is among the companions companionship = Companionship.objects.filter(companions=obj).first() @@ -524,23 +627,29 @@ def get_parent(self, obj): return None def to_representation(self, instance): ret = super().to_representation(instance) + + specific_layer_instance = get_specific_layer_instance(instance) + if specific_layer_instance == None: + specific_layer_instance = instance + layer_specific_fields = { - 'arcgis_layers': None, - 'password_protected': False, + 'arcgis_layers': None, + 'password_protected': False, "disable_arcgis_attributes":False, "query_by_point":False, "custom_style": None, "outline_width": None, "outline_color": None, "fill_opacity": None, + "outline_opacity": None, "color": None, "point_radius": None, "graphic": None, "graphic_scale": 1.0, - "opacity": .5, "wms_slug": None, "wms_version": None, "wms_srs": None, + "wms_format": None, "wms_timing": None, "wms_time_item": None, "wms_styles": None, @@ -549,65 +658,69 @@ def to_representation(self, instance): "wms_info_format": None, } # Conditional logic for specific fields - if isinstance(instance, LayerWMS): + if isinstance(specific_layer_instance, LayerWMS): layer_specific_fields.update({ - "wms_slug": instance.wms_slug, - "wms_version": instance.wms_version, - "wms_srs": instance.wms_srs, - "wms_timing": instance.wms_timing, - "wms_time_item": instance.wms_time_item, - "wms_styles": instance.wms_styles, - "wms_additional": instance.wms_additional, - "wms_info": instance.wms_info, - "wms_info_format": instance.wms_info_format, - "query_by_point": instance.query_by_point, + "wms_slug": specific_layer_instance.wms_slug, + "wms_version": specific_layer_instance.wms_version, + "wms_srs": specific_layer_instance.wms_srs, + "wms_timing": specific_layer_instance.wms_timing, + "wms_time_item": specific_layer_instance.wms_time_item, + "wms_styles": specific_layer_instance.wms_styles, + "wms_additional": specific_layer_instance.wms_additional, + "wms_info": specific_layer_instance.wms_info, + "wms_info_format": specific_layer_instance.wms_info_format, + "query_by_point": specific_layer_instance.query_by_point, }) - elif isinstance(instance, LayerArcREST): + elif isinstance(specific_layer_instance, LayerArcREST): layer_specific_fields.update({ - "arcgis_layers":instance.arcgis_layers, - "password_protected":instance.password_protected, - "disable_arcgis_attributes":instance.disable_arcgis_attributes, - "query_by_point":instance.query_by_point, + "arcgis_layers":specific_layer_instance.arcgis_layers, + "password_protected":specific_layer_instance.password_protected, + "disable_arcgis_attributes":specific_layer_instance.disable_arcgis_attributes, + "query_by_point":specific_layer_instance.query_by_point, }) - elif isinstance(instance, LayerArcFeatureService): + elif isinstance(specific_layer_instance, LayerArcFeatureService): layer_specific_fields.update({ - "arcgis_layers":instance.arcgis_layers, - "password_protected":instance.password_protected, - "disable_arcgis_attributes":instance.disable_arcgis_attributes, - "custom_style": instance.custom_style, - "outline_width": instance.outline_width, - "outline_color": instance.outline_color, - "fill_opacity": instance.fill_opacity, - "color": instance.color, - "point_radius": instance.point_radius, - "graphic": instance.graphic, - "graphic_scale": instance.graphic_scale, - "opacity": instance.opacity, + "arcgis_layers":specific_layer_instance.arcgis_layers, + "password_protected":specific_layer_instance.password_protected, + "disable_arcgis_attributes":specific_layer_instance.disable_arcgis_attributes, + "custom_style": specific_layer_instance.custom_style, + "outline_width": specific_layer_instance.outline_width, + "outline_color": specific_layer_instance.outline_color, + "fill_opacity": specific_layer_instance.fill_opacity, + "color": specific_layer_instance.color, + "point_radius": specific_layer_instance.point_radius, + "graphic": specific_layer_instance.graphic, + "graphic_scale": specific_layer_instance.graphic_scale, + }) - elif isinstance(instance,LayerVector): + elif isinstance(specific_layer_instance,LayerVector): layer_specific_fields.update({ - "custom_style": instance.custom_style, - "outline_width": instance.outline_width, - "outline_color": instance.outline_color, - "fill_opacity": instance.fill_opacity, - "color": instance.color, - "point_radius": instance.point_radius, - "graphic": instance.graphic, - "graphic_scale": instance.graphic_scale, - "opacity": instance.opacity, + "custom_style": specific_layer_instance.custom_style, + "outline_width": specific_layer_instance.outline_width, + "outline_color": specific_layer_instance.outline_color, + "fill_opacity": specific_layer_instance.fill_opacity, + "color": specific_layer_instance.color, + "point_radius": specific_layer_instance.point_radius, + "graphic": specific_layer_instance.graphic, + "graphic_scale": specific_layer_instance.graphic_scale, }) - elif isinstance(instance, LayerXYZ): + elif isinstance(specific_layer_instance, LayerXYZ): layer_specific_fields.update({ - "query_by_point":instance.query_by_point, + "query_by_point":specific_layer_instance.query_by_point, }) ret.update(layer_specific_fields) return ret + def get_tiles(self,obj): + return obj.tiles_link + def get_type(self,obj): + return obj.layer_type def check_is_sublayer(obj): if isinstance(obj, Theme): return False else: return True + class SubLayerSerializer(serializers.ModelSerializer): order = serializers.SerializerMethodField() @@ -615,11 +728,14 @@ class SubLayerSerializer(serializers.ModelSerializer): overview = serializers.SerializerMethodField() description = serializers.SerializerMethodField() parent = serializers.SerializerMethodField() + tiles = serializers.SerializerMethodField() + type = serializers.SerializerMethodField() + queryable = serializers.SerializerMethodField() class Meta: - model = Layer - fields = shared_layer_fields + ["is_sublayer", "parent"] - + model = Layer + fields = shared_layer_fields + ["is_sublayer", "parent", "order", "queryable",] + def get_order(self, obj): return get_layer_order(obj) def get_is_sublayer(self, obj): @@ -628,11 +744,18 @@ def get_overview(self, obj): return obj.data_overview_text def get_description(self, obj): return obj.tooltip + def get_queryable(self, obj): + return False def to_representation(self, instance): ret = super().to_representation(instance) + + specific_layer_instance = get_specific_layer_instance(instance) + if specific_layer_instance == None: + specific_layer_instance = instance + layer_specific_fields = { - 'arcgis_layers': None, - 'password_protected': False, + 'arcgis_layers': None, + 'password_protected': False, "disable_arcgis_attributes":False, "query_by_point":False, "custom_style": None, @@ -643,7 +766,6 @@ def to_representation(self, instance): "point_radius": None, "graphic": None, "graphic_scale": 1.0, - "opacity": .5, "wms_slug": None, "wms_version": None, "wms_srs": None, @@ -655,63 +777,110 @@ def to_representation(self, instance): "wms_info_format": None, } # Conditional logic for specific fields - if isinstance(instance, LayerWMS): + if isinstance(specific_layer_instance, LayerWMS): layer_specific_fields.update({ - "wms_slug": instance.wms_slug, - "wms_version": instance.wms_version, - "wms_srs": instance.wms_srs, - "wms_timing": instance.wms_timing, - "wms_time_item": instance.wms_time_item, - "wms_styles": instance.wms_styles, - "wms_additional": instance.wms_additional, - "wms_info": instance.wms_info, - "wms_info_format": instance.wms_info_format, - "query_by_point": instance.query_by_point, + "wms_slug": specific_layer_instance.wms_slug, + "wms_version": specific_layer_instance.wms_version, + "wms_srs": specific_layer_instance.wms_srs, + "wms_timing": specific_layer_instance.wms_timing, + "wms_time_item": specific_layer_instance.wms_time_item, + "wms_styles": specific_layer_instance.wms_styles, + "wms_additional": specific_layer_instance.wms_additional, + "wms_info": specific_layer_instance.wms_info, + "wms_info_format": specific_layer_instance.wms_info_format, + "query_by_point": specific_layer_instance.query_by_point, }) - elif isinstance(instance, LayerArcREST): + elif isinstance(specific_layer_instance, LayerArcREST): layer_specific_fields.update({ - "arcgis_layers":instance.arcgis_layers, - "password_protected":instance.password_protected, - "disable_arcgis_attributes":instance.disable_arcgis_attributes, - "query_by_point":instance.query_by_point, + "arcgis_layers":specific_layer_instance.arcgis_layers, + "password_protected":specific_layer_instance.password_protected, + "disable_arcgis_attributes":specific_layer_instance.disable_arcgis_attributes, + "query_by_point":specific_layer_instance.query_by_point, }) - elif isinstance(instance, LayerArcFeatureService): + elif isinstance(specific_layer_instance, LayerArcFeatureService): layer_specific_fields.update({ - "arcgis_layers":instance.arcgis_layers, - "password_protected":instance.password_protected, - "disable_arcgis_attributes":instance.disable_arcgis_attributes, - "custom_style": instance.custom_style, - "outline_width": instance.outline_width, - "outline_color": instance.outline_color, - "fill_opacity": instance.fill_opacity, - "color": instance.color, - "point_radius": instance.point_radius, - "graphic": instance.graphic, - "graphic_scale": instance.graphic_scale, - "opacity": instance.opacity, + "arcgis_layers":specific_layer_instance.arcgis_layers, + "password_protected":specific_layer_instance.password_protected, + "disable_arcgis_attributes":specific_layer_instance.disable_arcgis_attributes, + "custom_style": specific_layer_instance.custom_style, + "outline_width": specific_layer_instance.outline_width, + "outline_color": specific_layer_instance.outline_color, + "fill_opacity": specific_layer_instance.fill_opacity, + "color": specific_layer_instance.color, + "point_radius": specific_layer_instance.point_radius, + "graphic": specific_layer_instance.graphic, + "graphic_scale": specific_layer_instance.graphic_scale, }) - elif isinstance(instance,LayerVector): + elif isinstance(specific_layer_instance,LayerVector): layer_specific_fields.update({ - "custom_style": instance.custom_style, - "outline_width": instance.outline_width, - "outline_color": instance.outline_color, - "fill_opacity": instance.fill_opacity, - "color": instance.color, - "point_radius": instance.point_radius, - "graphic": instance.graphic, - "graphic_scale": instance.graphic_scale, - "opacity": instance.opacity, + "custom_style": specific_layer_instance.custom_style, + "outline_width": specific_layer_instance.outline_width, + "outline_color": specific_layer_instance.outline_color, + "fill_opacity": specific_layer_instance.fill_opacity, + "color": specific_layer_instance.color, + "point_radius": specific_layer_instance.point_radius, + "graphic": specific_layer_instance.graphic, + "graphic_scale": specific_layer_instance.graphic_scale, }) - elif isinstance(instance, LayerXYZ): + elif isinstance(specific_layer_instance, LayerXYZ): layer_specific_fields.update({ - "query_by_point":instance.query_by_point, + "query_by_point":specific_layer_instance.query_by_point, }) ret.update(layer_specific_fields) return ret def get_parent(self, obj): - + layer_content_type = ContentType.objects.get_for_model(obj.__class__) child_orders = ChildOrder.objects.filter(object_id=obj.id, content_type=layer_content_type) if child_orders: return child_orders[0].parent_theme.id - return None \ No newline at end of file + return None + def get_tiles(self,obj): + return obj.tiles_link + def get_type(self,obj): + return obj.layer_type + +class SliderLayerSerializer(serializers.ModelSerializer): + order = serializers.SerializerMethodField() + parent = serializers.SerializerMethodField() + type = serializers.CharField(default="slider", read_only=True) + tiles = serializers.SerializerMethodField() + queryable = serializers.BooleanField(default=False, read_only=True) + wms_slug = serializers.CharField(default=None, read_only=True) + wms_version = serializers.CharField(default=None, read_only=True) + wms_format = serializers.CharField(default=None, read_only=True) + wms_srs = serializers.CharField(default=None, read_only=True) + wms_timing = serializers.CharField(default=None, read_only=True) + wms_time_item = serializers.CharField(default=None, read_only=True) + wms_styles = serializers.CharField(default=None, read_only=True) + wms_additional = serializers.CharField(default="", read_only=True) + wms_info = serializers.BooleanField(default=False, read_only=True) + wms_info_format = serializers.CharField(default=None, read_only=True) + + custom_style = serializers.CharField(default=None, read_only=True) + outline_width = serializers.IntegerField(default=None, read_only=True) + outline_color = serializers.CharField(default=None, read_only=True) + outline_opacity = serializers.FloatField(default=None, read_only=True) + fill_opacity = serializers.FloatField(default=None, read_only=True) + color = serializers.CharField(default=None, read_only=True) + point_radius = serializers.IntegerField(default=None, read_only=True) + graphic = serializers.CharField(default=None, read_only=True) + graphic_scale = serializers.FloatField(default=1.0, read_only=True) + + arcgis_layers = serializers.CharField(default=None, read_only=True) + password_protected = serializers.BooleanField(default=False, read_only=True) + disable_arcgis_attributes = serializers.BooleanField(default=False, read_only=True) + query_by_point = serializers.BooleanField(default=False, read_only=True) + companion_layers = serializers.ListField(default=[], read_only=True) + subLayers = serializers.ListField(default=[], read_only=True) + class Meta(LayerSerializer.Meta): + model = Layer + fields = LayerSerializer.Meta.fields + layer_arcgis_fields + layer_wms_fields + raster_type_fields + library_fields + vector_layer_fields + ["companion_layers"] + ["subLayers", "order"] + def get_order(self, obj): + return get_layer_order(obj) + def get_parent(self, obj): + return None + def get_tiles(self,obj): + tiles_name = obj.slug_name + + return tiles_name \ No newline at end of file diff --git a/layers/static/admin/css/layer_admin.css b/layers/static/admin/css/layer_admin.css new file mode 100644 index 0000000..fec21cb --- /dev/null +++ b/layers/static/admin/css/layer_admin.css @@ -0,0 +1,8 @@ +.inline-deletelink { + display: none !important; +} +.parent-div > * { + display: block; + width: 100%; + box-sizing: border-box; /* This ensures padding does not add to the width */ +} \ No newline at end of file diff --git a/layers/static/admin/css/layer_http_status.css b/layers/static/admin/css/layer_http_status.css new file mode 100644 index 0000000..37e43ef --- /dev/null +++ b/layers/static/admin/css/layer_http_status.css @@ -0,0 +1,48 @@ +.success, +.fail, +.refreshing { + color: #dedede; + font-size: 9px; + padding: 2px 4px; + border-radius: 3px; +} + +.success { + background-color: rgb(3, 191, 3); +} + +.fail { + background-color: rgb(255, 31, 31); +} + +.refreshing { + background-color: rgb(255, 255, 0); + color: black; +} + +.btn-refresh { + background-color: transparent; + background-image: url('data: image/svg+xml, %3Csvg%20fill%3D%22%23000000%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2230px%22%20height%3D%2230px%22%20viewBox%3D%220%200%201.95%201.95%22%20enable-background%3D%22new%200%200%2052%2052%22%20xml%3Aspace%3D%22preserve%22%3E%3Cpath%20d%3D%22M1.744%200.15h-0.112c-0.03%200%20-0.056%200.026%20-0.056%200.056v0.263c0%200.034%20-0.019%200.049%20-0.045%200.026%20-0.011%20-0.015%20-0.022%20-0.026%20-0.037%20-0.037%20-0.188%20-0.188%20-0.45%20-0.266%20-0.72%20-0.214%20-0.094%200.019%20-0.184%200.056%20-0.263%200.109%20-0.229%200.15%20-0.36%200.394%20-0.364%200.656%20-0.004%200.203%200.075%200.405%200.217%200.551%200.15%200.158%200.352%200.244%200.57%200.244%200.191%200%200.371%20-0.068%200.514%20-0.188%200.026%20-0.022%200.026%20-0.06%200.004%20-0.083l-0.079%20-0.079c-0.019%20-0.019%20-0.052%20-0.022%20-0.075%20-0.004%20-0.135%200.112%20-0.319%200.158%20-0.502%200.112%20-0.049%20-0.011%20-0.098%20-0.034%20-0.142%20-0.06C0.439%201.373%200.337%201.125%200.397%200.877c0.011%20-0.049%200.034%20-0.098%200.06%20-0.142C0.563%200.551%200.746%200.45%200.941%200.45c0.15%200%200.292%200.06%200.397%200.165%200.019%200.015%200.034%200.034%200.045%200.052%200.011%200.03%20-0.015%200.045%20-0.049%200.045h-0.263c-0.03%200%20-0.056%200.026%20-0.056%200.056v0.116c0%200.03%200.022%200.052%200.052%200.052h0.686c0.026%200%200.049%20-0.022%200.049%20-0.049V0.206C1.8%200.176%201.774%200.15%201.744%200.15%22%2F%3E%3C%2Fsvg%3E'); + background-repeat: no-repeat; + background-position: center; + background-size: 16px 16px; + border: none; + border-radius: 50%; + cursor: pointer; + display: inline-block; + height: 20px; + margin: 2px 2px 2px 6px; + padding: 0; + transform: rotateZ(0deg); + transition: .25s all linear; + vertical-align: middle; + width: 20px; +} + +.btn-refresh:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +.btn-refresh.active { + transform: rotateZ(360deg); +} \ No newline at end of file diff --git a/layers/static/admin/js/layer_admin.js b/layers/static/admin/js/layer_admin.js new file mode 100644 index 0000000..46e4e24 --- /dev/null +++ b/layers/static/admin/js/layer_admin.js @@ -0,0 +1,95 @@ +document.addEventListener("DOMContentLoaded", function() { + function updateInlines() { + const layerType = document.querySelector("#id_layer_type").value; // Adjust the selector as needed + const nested_inlines = document.querySelectorAll(".djn-group-root"); + + nested_inlines.forEach(function(inline) { + inline.style.display = 'none'; + + // If the inline ID matches the layerType or specific conditions for 'slider' + if (inline.id.includes(layerType.toLowerCase()) || (layerType === 'slider' && + (inline.id.includes('multilayerdimension') || inline.id.includes('parent_layer')))) { + inline.style.display = ''; + } + else if (inline.id.includes("arcfeatureservice") && layerType === "ArcFeatureServer") { + inline.style.display = ""; + } + }); + } + const headers = document.querySelectorAll('.inline-related h3'); + headers.forEach(header => { + header.remove() + + }); + + const layerTypeField = document.querySelector("#id_layer_type"); + if (layerTypeField) { + layerTypeField.addEventListener("change", updateInlines); + updateInlines(); + assign_field_values_from_source_technology(); + } + + assign_field_values_from_source_technology = function() { + if ($('#id_layer_type').val() == "ArcRest" || $('#id_layer_type').val() == "ArcFeatureServer") { + var url = $('#id_url').val(); + var export_index = url.toLowerCase().indexOf('/export'); + if ( export_index >= 0) { + url = url.substring(0, export_index); + } + if (url.toLowerCase().indexOf('/mapserver') >= 0 || url.toLowerCase().indexOf('/featureserver') >= 0) { + $.ajax({ + url: url + "/layers?f=json", + success: function(data) { + if (typeof data != "object") { + data = JSON.parse(data); + } + layers = []; + for (var i = 0; i < data.layers.length; i++) { + var layer = data.layers[i]; + if (layer.minScale) { + var minZoom = (Math.log(591657550.500000 /(layer.minScale/2))/Math.log(2)).toPrecision(3); + } else { + var minZoom = undefined; + } + if (layer.maxScale) { + var maxZoom = (Math.log(591657550.500000 /(layer.maxScale/2))/Math.log(2)).toPrecision(3); + } else { + var maxZoom = undefined; + } + layers.push({ + id:layer.id.toString(), + name: layer.name, + minZoom: minZoom, + maxZoom: maxZoom, + minResolution: layer.minScale, + maxResolution: layer.maxScale + }); + } + var layer_table_element = "
"; + for (var i = 0; i < layers.length; i++) { + layer = layers[i]; + var row = ""; + layer_table_element = layer_table_element + row; + } + layer_table_element = layer_table_element + "
IDNameLink
" + layer.id + "" + layer.name + "Details
"; + $('.arcgis-details-layer-table').remove(); + $('div.field-box.field-arcgis_layers').append(layer_table_element); + + var zoom_table_element = "
"; + for (var i = 0; i < layers.length; i++) { + layer = layers[i]; + var row = ""; + zoom_table_element = zoom_table_element + row; + } + zoom_table_element = zoom_table_element + "
IDNameMin ZoomMax Zoom
" + layer.id + "" + layer.name + "" + layer.minZoom + "" + layer.maxZoom + "
"; + $('.arcgis-zoom-layer-table').remove(); + $('div.field-box.field-minZoom').append(zoom_table_element); + + + } + }) + } + } + } + assign_field_values_from_source_technology() +}); \ No newline at end of file diff --git a/layers/static/admin/js/layer_http_status.js b/layers/static/admin/js/layer_http_status.js new file mode 100644 index 0000000..7fe31e8 --- /dev/null +++ b/layers/static/admin/js/layer_http_status.js @@ -0,0 +1,121 @@ +/** + * This script is used to display the HTTP status of a layer in the admin interface. + * It fetches the status from a given URL and updates the status element accordingly. + * It also provides a refresh button to manually update the status. + * It runs only on the LayerAdmin page. + */ +document.addEventListener('DOMContentLoaded', () => { + const statusElements = document.querySelectorAll('.http-status'); + statusElements.forEach(el => { + if (!el.dataset) { + console.warn("Element does not have dataset properties:", el); + return; + } + const url = el.dataset.url; + const status = el.dataset.status; + if (status != "None") { + const parsedStatus = parseInt(status, 10); + if (!isNaN(parsedStatus)) { + displayStatus(el, parsedStatus); + } else { + console.warn(`Invalid HTTP status: ${status}`); + } + } else { + updateLayerStatus(el); + } + // Append a refresh button next to the status element. + el.parentNode.appendChild(createRefreshButton(el, url)); + }); +}); + +/** + * Fetch the HTTP status from the given URL. + * @param {string} url + * @param {HTMLElement} el + * @returns {Promise} The HTTP status code. + * @description Update the status element with the fetched status. + */ +async function updateStatus(el, url) { + // Show refreshing state. + el.classList.add("refreshing"); + el.textContent = "Refreshing..."; + try { + const status = await fetchStatus(url); + displayStatus(el, status); + } catch (error) { + console.error("Error fetching status:", error); + displayStatus(el, 404); + } finally { + el.classList.remove("refreshing"); + } +} + +/** + * + * @param {string} url + * @returns {Promise} The HTTP status code. + * @description Fetch the HTTP status from the given URL. + */ +async function fetchStatus(url) { + const response = await fetch(url); + return response.status; +} + +/** + * Add class to element based on the status code. + * @param {HTMLElement} el + * @param {number} status + * @description Display the status in the element. + */ +function displayStatus(el, status) { + const success = status === 200; + el.textContent = success ? `OK ${status}` : `Fail ${status}`; + el.classList.toggle('success', success); + el.classList.toggle('fail', !success); +} + +/** + * Update the layer status and display the result. + * @param {HTMLElement} el + * @description Fetch the status from the server and update the element. + */ +async function updateLayerStatus(el) { + const layerId = el.dataset.layerId; + // window.location.pathname will be the data manager URL. + const refreshUrl = window.location.pathname + "update-layer-status/" + layerId + "/"; + el.classList.add("refreshing"); + el.textContent = "Refreshing..."; + try { + const response = await fetch(refreshUrl); + const data = await response.json(); + displayStatus(el, data.status); + // TODO: update the last successful check field in the UI. + } catch (error) { + displayStatus(el, 404); + } finally { + el.classList.remove("refreshing"); + } +} + +/** + * Create a refresh button next to the status element. + * @param {HTMLElement} el + * @param {string} url + * @returns {HTMLElement} The refresh button element. + * @description Create a button to refresh the status. + */ +function createRefreshButton(el, url) { + const button = document.createElement("button"); + button.className = "btn-refresh"; + button.type = "button"; + button.title = "Refresh Status"; + button.addEventListener("click", async (e) => { + e.preventDefault(); + button.disabled = true; + button.classList.add("active"); + await updateLayerStatus(el); + button.classList.remove("active"); + button.disabled = false; + }); + return button; +} diff --git a/layers/static/admin/layers/css/layer_form.css b/layers/static/admin/layers/css/layer_form.css new file mode 100644 index 0000000..56d3dce --- /dev/null +++ b/layers/static/admin/layers/css/layer_form.css @@ -0,0 +1,96 @@ +.select2-container .select2-selection--single .select2-selection__rendered.select2-textarea { + white-space: normal; + text-overflow: unset; + height: 100%; + overflow: auto; + line-height: 20px; +} + +.select2-container--default .select2-selection--single .select2-selection__arrow { + visibility: hidden; +} + +/* override 'dark mode' printing light text on select2 white background */ +span.select2-results { + color: black; +} + +/************************************* + * Spinner Dialog + ************************************/ +.ui-dialog.ui-widget.ui-widget-content { + border-radius: 10px; + padding: 0 +} +.ui-dialog.ui-widget.ui-widget-content .ui-dialog-titlebar { + border-radius: 9px 9px 0 0; +} + +#spinner-dialog img { + display: block; + margin: 30px auto; +} + +/************************************* + * Collapse animation support + ************************************/ +/* fieldset.late-transition.collapse table, +fieldset.late-transition.collapse input, +fieldset.late-transition.collapse div { */ +fieldset.late-transition.collapse * { + max-height: 1000px; + transition: max-height 0.5s, padding-top 0.5s, padding-bottom 0.5s; +} +fieldset.late-transition.collapse *:not(.related-widget-wrapper):not(.related-widget-wrapper *) { + height: auto; +} + +fieldset.late-transition.collapse div.argis-details-table-wrapper, +fieldset.late-transition.collapse div.argis-zoom-table-wrapper { + max-height: 500px; + overflow-y: auto; + max-width: 50vw; + float: right; + border: 1px solid #888; + padding-right: 1rem; +} +fieldset.late-transition.collapse div.argis-details-table-wrapper { + margin-top: -50px; +} + +fieldset.late-transition.collapse h2 { + transition: background 0.5s; +} + +/* fieldset.late-transition.collapse.collapsed table, +fieldset.late-transition.collapse.collapsed input, +fieldset.late-transition.collapse.collapsed div { */ +fieldset.late-transition.collapse.collapsed * { + max-height: 0; + display: block; + padding: 0; + border: none; +} + +fieldset.late-transition.collapse h2, +fieldset.late-transition.collapse.collapsed h2 { + max-height: 31px; + padding: 4px; + display: flex; +} + +a.collapse-toggle { + display: inline; +} + + +/************************************* + * Additional Page Layout + ************************************/ +.aligned label { + padding: unset; +} + +div.form-row>div { + margin: 0.2rem; +} \ No newline at end of file diff --git a/layers/static/css/picker.css b/layers/static/css/picker.css new file mode 100644 index 0000000..c4b0074 --- /dev/null +++ b/layers/static/css/picker.css @@ -0,0 +1,164 @@ +/* Basic reset */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: Arial, sans-serif; +} + +.container { + display: flex; + flex-direction: column; + height: 100vh; +} + +.sidebar { + position: relative; + top: 33px; + left: 0; + background-color: #ffffff; + z-index: 1000; + width: 420px; + padding: 0px; + height: calc(100% - 47px); /* this is the height for just the myTabContent, not including the actual tabs */ + overflow-y: auto; /* Scrollable sidebar */ + border: 1px solid #ddd +} + +.fa-info-circle { + float: left; + color: #00a564; + margin-right: 5px; +} +.miller-columns-container { + padding: 10px; +} + +.miller-column { + margin-right: 5px; +} + +.column-list { + list-style: none; +} + +.column-item { + padding: 8px; + border-bottom: 1px solid #dddddd; + cursor: pointer; + font-size: 16px; + position: relative; + display: block; /* Align items in a row */ + +} + +.column-item.expanded > .fas.fa-chevron-right { + transform: rotate(90deg); /* Adjust as needed to point down */ +} + +.children-item.expanded > .fas.fa-chevron-right { + transform: rotate(90deg); /* Adjust as needed to point down */ +} +.far.fa-circle { + float:right; + color:#00a564; +} + +.fa-check-circle{ + float:right; + color:#00a564; +} + +.children-item:hover { + background-color: #e9e9e9; +} + + +.children-item { + background-color: transparent; + padding: 8px; + margin-bottom: 1px; + cursor: pointer; + font-size: 16px; + display: block; +} +.children-item .fas.fa-chevron-right { + float: right; +} +.column-item .fas.fa-chevron-right { + float: right; +} + +.column-item:hover { + background-color: #e9e9e9; +} + +.has-children > .nested-column { + display: none; /* initially hidden, show on click */ +} + +.has-children:hover > .nested-column { + display: block; /* show on hover or handle via JS for click */ + position: absolute; + left: 100%; /* Adjust as needed */ + top: 0; + background-color: #f5f5f5; + box-shadow: 2px 0px 5px rgba(0,0,0,0.2); + z-index: 10; +} + +.nested-column .column-list { + width: 250px; /* Same width as parent column */ +} + +.hidden-column { + display: none; +} + +.tooltip { + position: relative; + display: inline-block; + } + + /* Tooltip text */ + .tooltip .tooltiptext { + visibility: hidden; + width: 120px; + background-color: #00a564; + color: #fff; + text-align: center; + padding: 5px 0; + border-radius: 6px; + font-size: 13px; + /* Position the tooltip text below the tooltip container */ + position: absolute; + z-index: 1; + top: 150%; /* Change from bottom: 100% to top: 150% to move it below */ + left: 50%; + margin-left: -60px; /* Use half of the width to center the tooltip */ + + /* Fade in tooltip */ + opacity: 0; + transition: opacity 0.3s; + } + + /* Tooltip arrow */ + .tooltip .tooltiptext::before { + content: ""; + position: absolute; + bottom: 100%; /* Place the arrow at the bottom of the tooltip text */ + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent transparent #00a564 transparent; /* Flip the border color for the arrow */ + } + + /* Show the tooltip text when hovering */ + .tooltip:hover .tooltiptext { + visibility: visible; + opacity: 1; + } \ No newline at end of file diff --git a/layers/static/layer_admin.js b/layers/static/layer_admin.js deleted file mode 100644 index 8faf7df..0000000 --- a/layers/static/layer_admin.js +++ /dev/null @@ -1,18 +0,0 @@ -document.addEventListener("DOMContentLoaded", function() { - function updateFormDisplay() { - const layerType = document.querySelector("#id_layer_type").value; - console.log("Layer Type:", layerType); // Debugging output - const isArcGIS = layerType === "ArcRest"; // Make sure this matches exactly - console.log("Is ArcGIS:", isArcGIS); // Debugging output - - document.querySelectorAll(".form-row.field-arcgis_layers, .form-row.field-password_protected, .form-row.field-query_by_point, .form-row.field-disable_arcgis_attributes").forEach(function(row) { - console.log("Row to update:", row); // Debugging output - row.style.display = isArcGIS ? "block" : "none"; - }); - } - const layerTypeField = document.querySelector("#id_layer_type"); - if (layerTypeField) { - layerTypeField.addEventListener("change", updateFormDisplay); - updateFormDisplay(); // Initial update on page load - } -}); \ No newline at end of file diff --git a/layers/static/layers/js/admin_layer_form.js b/layers/static/layers/js/admin_layer_form.js new file mode 100644 index 0000000..76d776d --- /dev/null +++ b/layers/static/layers/js/admin_layer_form.js @@ -0,0 +1,664 @@ +// TODO: Refactor this file to use ES6 syntax and JavaScript best practices. +const AdminLayerForm = (() => { + const show_layertype_form = function (layertype) { + + if (layertype == null) { + layertype = $('#id_layer_type').val(); + } + + var url = $('#id_url').val(); + + if (url.length > 0) { + + if ($('#id_layer_type').val() == "WMS" && $('#id_layerwms_set-0-wms_help').is(':checked')) { + get_wms_capabilities(); + } + + var organization_section = $('.field-order.field-themes').parent(); + var metadata_section = $('.field-description').parent(); + var legend_section = $('.field-show_legend').parent(); + var arcgis_section = $('.field-arcgis_layers').parent(); + var wms_section = $('.field-wms_help').parent(); + var attribute_section = $('.field-attribute_fields').parent(); + var style_section = $('.field-opacity').parent(); + var multi_association_section = $('#id_parent_layer-TOTAL_FORMS').parent(); + var multi_dimensions_section = $('#id_multilayerdimension_set-TOTAL_FORMS').parent(); + + switch (layertype) { + case '---------': + hide_section(arcgis_section); + hide_section(wms_section); + hide_section(style_section); + break; + case 'WMS': + hide_section(arcgis_section); + show_section(wms_section); + hide_section(style_section); + break; + case 'ArcRest': + show_section(arcgis_section); + hide_section(wms_section); + hide_section(style_section); + break; + case 'ArcFeatureServer': + show_section(arcgis_section); + hide_section(wms_section); + hide_section(style_section); + break; + case 'Vector': + hide_section(arcgis_section); + hide_section(wms_section); + show_section(style_section); + break; + case 'VectorTile': + hide_section(arcgis_section); + hide_section(wms_section); + show_section(style_section); + break; + default: + hide_section(arcgis_section); + hide_section(wms_section); + hide_section(style_section); + break; + } + } + } + + const show_section = function (section) { + section.removeClass('collapsed'); + section.children('h2').children('a').text('Hide'); + } + + const hide_section = function (section) { + section.addClass('collapsed'); + section.children('h2').children('a').text('Show'); + } + + const get_wms_capabilities = function () { + if ($('#id_layer_type').val() == "WMS" && $('#id_url').length > 0 && $('#id_layerwms_set-0-wms_help').prop('checked') == true) { + show_spinner(); + var url = $('#id_url').val(); + $.ajax({ + url: '/data_manager/wms_capabilities/', + data: { + url: url + }, + success: function (data) { + // SWITCH WMS INPUTS TO SELECTORS + var blank_option = ''; + + // Replace WMS Layer Name + var slug_val = $('#id_layerwms_set-0-wms_slug').val(); + var layer_name_html = ''; + $('#id_layerwms_set-0-wms_slug').replaceWith(layer_name_html); + if (data.layers.indexOf(slug_val) >= 0) { + $('#id_layerwms_set-0-wms_slug').val(slug_val); + } + slug_val = $('#id_layerwms_set-0-wms_slug').val(); + $('#id_layerwms_set-0-wms_slug').change(function () { + get_wms_capabilities(); + }); + + // Set wms version (only 1.1.1 supported) + $('#id_layerwms_set-0-wms_version').val(data.version); + $('.field-wms_version.field-box').hide() + + // Replace WMS Format + var format_val = $('#id_layerwms_set-0-wms_format').val(); + var format_html = ''; + $('#id_layerwms_set-0-wms_format').replaceWith(format_html); + if (data.formats.indexOf(format_val) >= 0) { + $('#id_layerwms_set-0-wms_format').val(format_val); + } + + // Replace SRS + var srs_val = $('#id_layerwms_set-0-wms_srs').val(); + var srs_html = ''; + $('#id_layerwms_set-0-wms_srs').replaceWith(srs_html); + if (slug_val.length > 0 && data.srs[slug_val].indexOf(srs_val) >= 0) { + $('#id_layerwms_set-0-wms_srs').val(srs_val); + } + + $('#id_layerwms_set-0-wms_srs').change(function () { + if ($('#id_layerwms_set-0-wms_srs').val().toLowerCase() == 'epsg:3857') { + $('#id_layerwms_set-0-wms_time_item').prop('disabled', true); + $('#id_layerwms_set-0-wms_additional').prop('disabled', false); + } else { + $('#id_layerwms_set-0-wms_time_item').prop('disabled', false); + $('#id_layerwms_set-0-wms_additional').prop('disabled', true); + } + }); + + // Replace Styles + var style_keys = []; + if (slug_val.length > 0) { + style_keys = Object.keys(data.styles[slug_val]); + } + if (style_keys.length == 0) { + $('#id_layerwms_set-0-wms_styles').val(null); + $('#id_layerwms_set-0-wms_styles').prop('disabled', true); + } else { + $('#id_layerwms_set-0-wms_styles').prop('disabled', false); + var style_val = $('#id_layerwms_set-0-wms_styles').val(); + var style_html = ''; + $('#id_layerwms_set-0-wms_styles').replaceWith(style_html); + if (slug_val.length > 0 && Object.keys(data.styles[slug_val]).indexOf(style_val) >= 0) { + $('#id_layerwms_set-0-wms_styles').val(style_val); + } + } + + // Replace Time + $('#wms_timing_default').remove(); + $('#wms_timing_position_label').remove(); + $('#wms_timing_position_options').remove(); + + if (slug_val.length <= 0 || data.time[slug_val].default == null) { + $('#id_layerwms_set-0-wms_timing').val(null); + $('#id_layerwms_set-0-wms_timing').prop('disabled', true); + } else { + $('#id_layerwms_set-0-wms_timing').prop('disabled', false); + $('

*** Default = ' + data.time[slug_val].default + '***

').insertAfter('#id_layerwms_set-0-wms_timing'); + if (data.time[slug_val].positions.length > 0) { + $('

Position options:

').insertAfter('#wms_timing_default'); + var wms_timing_positions_html = '
    '; + for (var i = 0; i < data.time[slug_val].positions.length; i++) { + wms_timing_positions_html += '
  • ' + data.time[slug_val].positions[i] + '
  • '; + } + wms_timing_positions_html += '
'; + $(wms_timing_positions_html).insertAfter('#wms_timing_position_label'); + } + } + + /* CAPABILITIES */ + if (Object.keys(data.capabilities).length > 0) { + var info_bool_field = $('#id_layerwms_set-0-wms_info'); + var info_format_field = $('#id_layerwms_set-0-wms_info_format'); + if (data.capabilities.hasOwnProperty('featureInfo') && data.capabilities.featureInfo.available) { + $('.form-row.field-wms_info.field-wms_info_format').show(); + info_format_field.prop('disabled', false); + info_bool_field.prop('disabled', false); + + var info_formats = data.capabilities.featureInfo.formats; + var info_format_val = info_format_field.val(); + var info_format_html = ''; + info_format_field.replaceWith(info_format_html); + + if (info_formats.indexOf(info_format_val) >= 0) { + $('#id_layerwms_set-0-wms_info_format').val(info_format_val); + } + + } else { + // set featureInfo to false, hide section + info_bool_field.prop('checked', false); + info_bool_field.prop('disabled', true); + info_format_field.val(null); + info_format_field.prop('disabled', true); + $('.form-row.field-wms_info.field-wms_info_format').hide(); + } + } + check_queryable(data.queryable); + hide_spinner(); + }, + error: function (data) { + var url = $('#id_url').val(); + err_msg = 'ERROR: Layer url ' + url + ' does not appear to be a valid WMS endpoint.' + hide_spinner(); + window.alert(err_msg); + } + }); + } else { + // SWITCH SELECTORS TO INPUTS + + // Replace WMS Layer Name + if ($('#id_layerwms_set-0-wms_slug').is('select')) { + var slug_val = $('#id_layerwms_set-0-wms_slug').val(); + $('#id_layerwms_set-0-wms_slug').replaceWith('' + + ''); + } + + // Release lock on WMS version field + $('.field-wms_version.field-box').show(); + + // Replace WMS format + if ($('#id_layerwms_set-0-wms_format').is('select')) { + format_val = $('#id_layerwms_set-0-wms_format').val(); + $('#id_layerwms_set-0-wms_format').replaceWith('' + + ''); + } + + // Replace SRS + if ($('#id_layerwms_set-0-wms_srs').is('select')) { + srs_val = $('#id_layerwms_set-0-wms_srs').val(); + $('#id_layerwms_set-0-wms_srs').replaceWith('' + + ''); + } + + $('#id_layerwms_set-0-wms_srs').blur(function () { + if ($('#id_layerwms_set-0-wms_srs').val() == 'EPSG:3857') { + $('#id_layerwms_set-0-wms_time_item').prop('disabled', true); + $('#id_layerwms_set-0-wms_additional').prop('disabled', false); + } else { + $('#id_layerwms_set-0-wms_time_item').prop('disabled', false); + $('#id_layerwms_set-0-wms_additional').prop('disabled', true); + } + }); + + // Replace Styles + if ($('#id_layerwms_set-0-wms_styles').is('select')) { + style_val = $('#id_layerwms_set-0-wms_styles').val(); + $('#id_layerwms_set-0-wms_styles').replaceWith('' + + ''); + } + + // Replace Time + $('#id_layerwms_set-0-wms_timing').prop('disabled', false); + $('#wms_timing_default').remove(); + $('#wms_timing_position_label').remove(); + $('#wms_timing_position_options').remove(); + + } + } + + const check_queryable = function (queryable_layers) { + var selected_layer = $('#id_layerwms_set-0-wms_slug').val(); + if (queryable_layers.indexOf(selected_layer) >= 0) { + $('#id_layerwms_set-0-wms_info').attr('disabled', false); + $('#id_layerwms_set-0-wms_info_format').attr('disabled', false); + } else { + $('#id_layerwms_set-0-wms_info').attr('checked', false); + $('#id_layerwms_set-0-wms_info').attr('disabled', true); + $('#id_layerwms_set-0-wms_info_format').attr('disabled', true); + } + if (!$('#queryable_layer_list').length > 0) { + if ($('.form-row.field-wms_info.field-wms_info_format').length > 0) { + $('.form-row.field-wms_info.field-wms_info_format').append('
'); + } else { + console.log('need to re-write identification of WMS Info section of form. See layer_form.js "check_queryable()"'); + } + } + if ($('#queryable_layer_list').length > 0) { + q_layers_html = "Queryable Layers: " + queryable_layers.join(', '); + $('#queryable_layer_list').html(q_layers_html); + } + } + + const change_layer_url = function (self) { + var url = $(this).val(); + var type = null; + if (typeof (get_service_type) == "function") { + type = get_service_type(url); + if ($('#id_layer_type option').map(function () { return $(this).val(); }).toArray().indexOf(type) >= 0) { + $('#id_layer_type').val(type); + $('#id_layer_type').trigger('change'); + } + } else { + console.log('No get_service_type() function defined for CATALOG_TECHNOLOGY: ' + CATALOG_TECHNOLOGY); + } + } + + /** + * ALSO USED IN GeoPOrtal2.js + */ + const replace_all_select2_with_input = function () { + var sel2_fields = $('.select2').siblings('select.select2-hidden-accessible').not('#id_catalog_name'); + for (var i = 0; i < sel2_fields.length; i++) { + var field_id = $(sel2_fields[i]).attr('id'); + var field_name = $(sel2_fields[i]).attr('name'); + var field_value = $(sel2_fields[i]).val(); + var textarea_fields = ['id_description']; + if (textarea_fields.indexOf(field_id) >= 0) { + var field_open = '