diff --git a/backend/apps/api/v1/admin.py b/backend/apps/api/v1/admin.py index 025c10c8..bd34b8cf 100644 --- a/backend/apps/api/v1/admin.py +++ b/backend/apps/api/v1/admin.py @@ -34,6 +34,7 @@ ObservationLevelInlineForm, PollInlineForm, ReorderColumnsForm, + ReorderObservationLevelsForm, ReorderTablesForm, TableForm, TableInlineForm, @@ -160,18 +161,41 @@ def has_change_permission(self, request, obj=None): return False -class ObservationLevelInline(admin.StackedInline): +class ObservationLevelInline(OrderedStackedInline): model = ObservationLevel form = ObservationLevelInlineForm extra = 0 - fields = [ - "id", - "entity", - ] - autocomplete_fields = [ + show_change_link = True + readonly_fields = [ "entity", + "order", + "move_up_down_links", ] + fields = readonly_fields + template = 'admin/observation_level_inline.html' + ordering = ["order"] + def get_formset(self, request, obj=None, **kwargs): + self.parent_obj = obj + return super().get_formset(request, obj, **kwargs) + + def get_ordering_prefix(self): + """Return the appropriate ordering prefix based on parent model""" + if isinstance(self.parent_obj, Table): + return 'table' + elif isinstance(self.parent_obj, RawDataSource): + return 'rawdatasource' + elif isinstance(self.parent_obj, InformationRequest): + return 'informationrequest' + elif isinstance(self.parent_obj, Analysis): + return 'analysis' + return super().get_ordering_prefix() + + def has_add_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False class TableInline(OrderedTranslatedInline): model = Table @@ -459,6 +483,48 @@ def reset_column_order(modeladmin, request, queryset): reset_column_order.short_description = "Reiniciar ordem das colunas" +def reorder_observation_levels(modeladmin, request, queryset): + """Reorder observation levels in respect to parent""" + if "do_action" in request.POST: + form = ReorderObservationLevelsForm(request.POST) + if form.is_valid(): + if queryset.count() != 1: + messages.error( + request, + "To pass the names manually you must select only one parent.", + ) + return + + parent = queryset.first() + ordered_entities = form.cleaned_data["ordered_entities"].split() + + # Get observation levels for this parent + if hasattr(parent, 'observation_levels'): + obs_levels = parent.observation_levels.all() + + # Create a mapping of entity names to observation levels + obs_by_entity = {ol.entity.name: ol for ol in obs_levels} + + # Update order based on provided entity names + for i, entity_name in enumerate(ordered_entities): + if entity_name in obs_by_entity: + obs_by_entity[entity_name].order = i + obs_by_entity[entity_name].save() + + messages.success(request, "Observation levels reordered successfully") + else: + messages.error(request, "Selected object has no observation levels") + else: + form = ReorderObservationLevelsForm() + return render( + request, + "admin/reorder_observation_levels.html", + {"title": "Reorder observation levels", "parents": queryset, "form": form}, + ) + +reorder_observation_levels.short_description = "Alterar ordem dos níveis de observação" + + ################################################################################ # Model Admins ################################################################################ @@ -601,6 +667,7 @@ class TableAdmin(OrderedInlineModelAdminMixin, TabbedTranslationAdmin): actions = [ reorder_columns, reset_column_order, + reorder_observation_levels, update_table_metadata, update_table_neighbors, update_page_views, @@ -778,7 +845,37 @@ class ColumnOriginalNameAdmin(TabbedTranslationAdmin): inlines = [CoverageInline] +def reset_observation_level_order(modeladmin, request, queryset): + """Reset observation level order in respect to parent""" + # Group observation levels by their parent + by_table = {} + by_raw_data_source = {} + by_information_request = {} + by_analysis = {} + + for obs in queryset: + if obs.table_id: + by_table.setdefault(obs.table_id, []).append(obs) + elif obs.raw_data_source_id: + by_raw_data_source.setdefault(obs.raw_data_source_id, []).append(obs) + elif obs.information_request_id: + by_information_request.setdefault(obs.information_request_id, []).append(obs) + elif obs.analysis_id: + by_analysis.setdefault(obs.analysis_id, []).append(obs) + + # Reset order within each parent group + for parent_levels in [by_table, by_raw_data_source, by_information_request, by_analysis]: + for levels in parent_levels.values(): + sorted_levels = sorted(levels, key=lambda x: x.entity.name) + for i, obs_level in enumerate(sorted_levels): + obs_level.order = i + obs_level.save() + +reset_observation_level_order.short_description = "Reiniciar ordem dos níveis de observação" + + class ObservationLevelAdmin(admin.ModelAdmin): + actions = [reset_observation_level_order] readonly_fields = [ "id", ] @@ -796,6 +893,9 @@ class ObservationLevelAdmin(admin.ModelAdmin): ] list_filter = [ "entity__category__name", + "table", + "raw_data_source", + "information_request", ] list_display = [ "__str__", @@ -805,7 +905,10 @@ class ObservationLevelAdmin(admin.ModelAdmin): ] -class RawDataSourceAdmin(TabbedTranslationAdmin): +class RawDataSourceAdmin(OrderedInlineModelAdminMixin, TabbedTranslationAdmin): + actions = [ + reorder_observation_levels, + ] list_display = ["name", "dataset", "created_at", "updated_at"] search_fields = ["name", "dataset__name"] readonly_fields = ["id", "created_at", "updated_at"] @@ -819,11 +922,15 @@ class RawDataSourceAdmin(TabbedTranslationAdmin): ] inlines = [ CoverageInline, + ObservationLevelInline, PollInline, ] -class InformationRequestAdmin(TabbedTranslationAdmin): +class InformationRequestAdmin(OrderedInlineModelAdminMixin, TabbedTranslationAdmin): + actions = [ + reorder_observation_levels, + ] list_display = ["__str__", "dataset", "created_at", "updated_at"] search_fields = ["__str__", "dataset__name"] readonly_fields = ["id", "created_at", "updated_at"] @@ -1111,6 +1218,9 @@ class AnalysisTypeAdmin(TabbedTranslationAdmin): class AnalysisAdmin(TabbedTranslationAdmin): + actions = [ + reorder_observation_levels, + ] readonly_fields = [ "id", ] diff --git a/backend/apps/api/v1/forms/__init__.py b/backend/apps/api/v1/forms/__init__.py index 54199b47..9ead2290 100644 --- a/backend/apps/api/v1/forms/__init__.py +++ b/backend/apps/api/v1/forms/__init__.py @@ -13,3 +13,4 @@ ) from backend.apps.api.v1.forms.reorder_columns_form import ReorderColumnsForm # noqa: F401 from backend.apps.api.v1.forms.reorder_tables_form import ReorderTablesForm # noqa: F401 +from backend.apps.api.v1.forms.reorder_observation_levels_form import ReorderObservationLevelsForm # noqa: F401 \ No newline at end of file diff --git a/backend/apps/api/v1/forms/admin_form.py b/backend/apps/api/v1/forms/admin_form.py index 36d7fede..7dc470ed 100644 --- a/backend/apps/api/v1/forms/admin_form.py +++ b/backend/apps/api/v1/forms/admin_form.py @@ -118,7 +118,18 @@ class Meta(UUIDHiddenIdForm.Meta): class ObservationLevelInlineForm(UUIDHiddenIdForm): class Meta(UUIDHiddenIdForm.Meta): model = ObservationLevel - fields = "__all__" + fields = [ + "id", + "entity", + "table", + "raw_data_source", + "information_request", + "analysis", + ] + readonly_fields = [ + "order", + "move_up_down_links", + ] class CoverageInlineForm(UUIDHiddenIdForm): diff --git a/backend/apps/api/v1/forms/reorder_observation_levels_form.py b/backend/apps/api/v1/forms/reorder_observation_levels_form.py new file mode 100644 index 00000000..46dd4ad9 --- /dev/null +++ b/backend/apps/api/v1/forms/reorder_observation_levels_form.py @@ -0,0 +1,8 @@ +from django import forms + +class ReorderObservationLevelsForm(forms.Form): + ordered_entities = forms.CharField( + required=False, + widget=forms.Textarea(attrs={"rows": 10, "cols": 40}), + help_text="Enter entity names one per line in desired order", + ) \ No newline at end of file diff --git a/backend/apps/api/v1/migrations/0046_observationlevel_order.py b/backend/apps/api/v1/migrations/0046_observationlevel_order.py new file mode 100644 index 00000000..fbc194ef --- /dev/null +++ b/backend/apps/api/v1/migrations/0046_observationlevel_order.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from django.db import migrations, models + +class Migration(migrations.Migration): + dependencies = [ + ("v1", "0045_add_measurement_categories_and_units"), + ] + + operations = [ + migrations.AddField( + model_name="observationlevel", + name="order", + field=models.PositiveIntegerField( + db_index=True, default=0, editable=False, verbose_name="order" + ), + preserve_default=False, + ), + ] \ No newline at end of file diff --git a/backend/apps/api/v1/migrations/0047_initialize_observation_level_order.py b/backend/apps/api/v1/migrations/0047_initialize_observation_level_order.py new file mode 100644 index 00000000..b8425838 --- /dev/null +++ b/backend/apps/api/v1/migrations/0047_initialize_observation_level_order.py @@ -0,0 +1,42 @@ +from django.db import migrations + +def initialize_observation_level_order(apps, schema_editor): + ObservationLevel = apps.get_model('v1', 'ObservationLevel') + + # Group by each possible parent type and set order + for table_id in ObservationLevel.objects.values_list('table_id', flat=True).distinct(): + if table_id: + for i, ol in enumerate(ObservationLevel.objects.filter(table_id=table_id)): + ol.order = i + ol.save() + + for rds_id in ObservationLevel.objects.values_list('raw_data_source_id', flat=True).distinct(): + if rds_id: + for i, ol in enumerate(ObservationLevel.objects.filter(raw_data_source_id=rds_id)): + ol.order = i + ol.save() + + for ir_id in ObservationLevel.objects.values_list('information_request_id', flat=True).distinct(): + if ir_id: + for i, ol in enumerate(ObservationLevel.objects.filter(information_request_id=ir_id)): + ol.order = i + ol.save() + + for analysis_id in ObservationLevel.objects.values_list('analysis_id', flat=True).distinct(): + if analysis_id: + for i, ol in enumerate(ObservationLevel.objects.filter(analysis_id=analysis_id)): + ol.order = i + ol.save() + +def reverse_migration(apps, schema_editor): + ObservationLevel = apps.get_model('v1', 'ObservationLevel') + ObservationLevel.objects.all().update(order=0) + +class Migration(migrations.Migration): + dependencies = [ + ('v1', '0046_observationlevel_order'), + ] + + operations = [ + migrations.RunPython(initialize_observation_level_order, reverse_migration), + ] \ No newline at end of file diff --git a/backend/apps/api/v1/migrations/0048_alter_observationlevel_options_and_more.py b/backend/apps/api/v1/migrations/0048_alter_observationlevel_options_and_more.py new file mode 100644 index 00000000..4ae3ed76 --- /dev/null +++ b/backend/apps/api/v1/migrations/0048_alter_observationlevel_options_and_more.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.16 on 2024-11-04 03:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('v1', '0047_initialize_observation_level_order'), + ] + + operations = [ + migrations.AlterModelOptions( + name='observationlevel', + options={'ordering': ['order'], 'verbose_name': 'Observation Level', 'verbose_name_plural': 'Observation Levels'}, + ), + ] diff --git a/backend/apps/api/v1/models.py b/backend/apps/api/v1/models.py index b14aa424..4d56a08b 100644 --- a/backend/apps/api/v1/models.py +++ b/backend/apps/api/v1/models.py @@ -14,6 +14,9 @@ from backend.custom.storage import OverwriteStorage, upload_to, validate_image from backend.custom.utils import check_kebab_case, check_snake_case +import logging + +logger = logging.getLogger('django.request') class Area(BaseModel): """Area model""" @@ -1728,7 +1731,7 @@ class Meta: ordering = ["slug"] -class ObservationLevel(BaseModel): +class ObservationLevel(BaseModel, OrderedModel): """Model definition for ObservationLevel.""" id = models.UUIDField(primary_key=True, default=uuid4) @@ -1764,6 +1767,8 @@ class ObservationLevel(BaseModel): related_name="observation_levels", ) + order_with_respect_to = ('table', 'raw_data_source', 'information_request', 'analysis') + graphql_nested_filter_fields_whitelist = ["id"] def __str__(self): @@ -1775,25 +1780,23 @@ class Meta: db_table = "observation_level" verbose_name = "Observation Level" verbose_name_plural = "Observation Levels" - ordering = ["id"] + ordering = ["order"] - def clean(self) -> None: - """Assert that only one of "table", "raw_data_source", "information_request" is set""" - count = 0 - if self.table: - count += 1 - if self.raw_data_source: - count += 1 - if self.information_request: - count += 1 - if self.analysis: - count += 1 - if count != 1: - raise ValidationError( - "One and only one of 'table', 'raw_data_source', " - "'information_request', 'analysis' must be set." - ) - return super().clean() + def get_ordering_queryset(self): + """Get queryset for ordering within the appropriate parent""" + qs = super().get_ordering_queryset() + + # Filter by the appropriate parent field + if self.table_id: + return qs.filter(table_id=self.table_id) + elif self.raw_data_source_id: + return qs.filter(raw_data_source_id=self.raw_data_source_id) + elif self.information_request_id: + return qs.filter(information_request_id=self.information_request_id) + elif self.analysis_id: + return qs.filter(analysis_id=self.analysis_id) + + return qs class DateTimeRange(BaseModel): diff --git a/backend/apps/api/v1/templates/admin/observation_level_inline.html b/backend/apps/api/v1/templates/admin/observation_level_inline.html new file mode 100644 index 00000000..f95ee531 --- /dev/null +++ b/backend/apps/api/v1/templates/admin/observation_level_inline.html @@ -0,0 +1,15 @@ +{% load i18n admin_urls %} + +{# Include the standard stacked inline template #} +{% include "admin/edit_inline/stacked.html" %} + +{# Add the custom add button #} +{% if inline_admin_formset.formset.instance.pk %} +
+{% endif %} \ No newline at end of file diff --git a/backend/apps/api/v1/templates/admin/reorder_observation_levels.html b/backend/apps/api/v1/templates/admin/reorder_observation_levels.html new file mode 100644 index 00000000..00653b68 --- /dev/null +++ b/backend/apps/api/v1/templates/admin/reorder_observation_levels.html @@ -0,0 +1,32 @@ +{% extends "admin/base_site.html" %} + +{% block content %} + +{% endblock %} \ No newline at end of file