diff --git a/api/admin.py b/api/admin.py index 09de0002f..10754a214 100644 --- a/api/admin.py +++ b/api/admin.py @@ -1,4 +1,5 @@ import csv +import json import time from django.conf import settings @@ -11,6 +12,8 @@ from django.db.models.functions import Concat from django.http import HttpResponse, HttpResponseRedirect from django.urls import reverse +from django.utils import timezone +from django.utils.formats import date_format from django.utils.html import format_html, format_html_join from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -199,6 +202,11 @@ class EventLinkInline(admin.TabularInline, TranslationInlineModelAdmin): class EventAdmin(CompareVersionAdmin, RegionRestrictedAdmin, TranslationAdmin): + + @admin.display(ordering="ifrc_severity_level_update_date") + def level_updated_at(self, obj): + return obj.ifrc_severity_level_update_date + country_in = "countries__pk__in" region_in = "regions__pk__in" @@ -212,7 +220,9 @@ class EventAdmin(CompareVersionAdmin, RegionRestrictedAdmin, TranslationAdmin): ] list_display = ( "name", - "ifrc_severity_level", + "severity_level_link", + "level_updated_at", + "cc_status", "glide", "auto_generated", "auto_generated_source", @@ -229,6 +239,45 @@ class EventAdmin(CompareVersionAdmin, RegionRestrictedAdmin, TranslationAdmin): "parent_event", ) + def severity_level_link(self, obj): + """Display severity level as a link to Crisis Categorisation""" + # Check if there's an existing crisis categorisation for this event + first_crisis_cat = models.CrisisCategorisationByCountry.objects.filter(event=obj).first() + + if first_crisis_cat: + # Link to existing record + url = reverse("admin:api_crisiscategorisationbycountry_change", args=[first_crisis_cat.pk]) + title = "Edit crisis categorisation" + else: + # Link to add new record with event pre-filled + url = ( + reverse("admin:api_crisiscategorisationbycountry_add") + + f"?event={obj.pk}" + + f"&crisis_categorisation={obj.ifrc_severity_level}" + ) + title = "Edit crisis categorisation" + + severity_display = obj.get_ifrc_severity_level_display() + severity_emoji_map = { + 0: "🟡", + 1: "🟠", + 2: "🔴", + } + severity_emoji = severity_emoji_map.get(obj.ifrc_severity_level) + if severity_display and severity_emoji: + severity_display = f"{severity_emoji} {severity_display}" + return format_html('{}', url, title, severity_display) + + severity_level_link.short_description = "Crisis Categorisation" + severity_level_link.admin_order_field = "ifrc_severity_level" + + @admin.display(description="CC Status") + def cc_status(self, obj): + latest_cc = models.CrisisCategorisationByCountry.objects.filter(event=obj).order_by("-updated_at").first() + if not latest_cc or latest_cc.status is None: + return "-" + return latest_cc.get_status_display() + def appeals(self, instance): if getattr(instance, "appeals").exists(): return format_html_join( @@ -241,10 +290,10 @@ def appeals(self, instance): # To add the 'Notify subscribers now' button # WikiJS links added change_form_template = "admin/emergency_change_form.html" - change_list_template = "admin/emergency_change_list.html" + change_list_template = "admin/emergency_change_list_with_history.html" # Overwriting readonly fields for Edit mode - def changeform_view(self, request, *args, **kwargs): + def changeform_view(self, request, object_id=None, form_url="", extra_context=None): if not request.user.is_superuser: self.readonly_fields = ( "appeals", @@ -259,7 +308,37 @@ def changeform_view(self, request, *args, **kwargs): "auto_generated_source", ) - return super(EventAdmin, self).changeform_view(request, *args, **kwargs) + # Set severity level from GET parameter + if object_id and request.GET.get("set_ifrc_severity_level"): + # Not good, because this way no history will be saved: + try: + severity_level = int(request.GET.get("set_ifrc_severity_level")) + if severity_level in [0, 1, 2]: # Validate it's a valid choice + obj = self.get_object(request, object_id) + if obj: + original = models.Event.objects.get(pk=obj.pk) + severity_changed = original.ifrc_severity_level != severity_level + if severity_changed: + new_update_date = timezone.now() + if ( + original.ifrc_severity_level_update_date is not None + and original.ifrc_severity_level_update_date > new_update_date + ): + messages.error(request, "A severity level update date can not be earlier than the previous one.") + else: + obj.ifrc_severity_level = severity_level + obj.ifrc_severity_level_update_date = new_update_date + obj.save(update_fields=["ifrc_severity_level", "ifrc_severity_level_update_date"]) + models.EventSeverityLevelHistory.objects.create( + event=obj, + ifrc_severity_level=original.ifrc_severity_level, + ifrc_severity_level_update_date=original.ifrc_severity_level_update_date, + created_by=request.user, + ) + except (ValueError, TypeError): + pass + + return super(EventAdmin, self).changeform_view(request, object_id, form_url, extra_context) # Evaluate if the regular 'Save' or 'Notify subscribers now' button was pushed def response_change(self, request, obj): @@ -314,6 +393,53 @@ def field_reports(self, instance): field_reports.short_description = "Field Reports" + def changelist_view(self, request, extra_context=None): + response = super().changelist_view(request, extra_context) + + # Check if we have a result list to process + if hasattr(response, "context_data") and "cl" in response.context_data: + cl = response.context_data["cl"] + result_list = list(cl.result_list) + expanded_results = [] + + for event in result_list: + # Add the main event row + expanded_results.append( + { + "object": event, + "is_history": False, + "history_record": None, + } + ) + + # Add history rows if they exist + history_records = models.EventSeverityLevelHistory.objects.filter(event=event).order_by( + "-ifrc_severity_level_update_date" + ) + + for history in history_records: + expanded_results.append( + { + "object": event, + "is_history": True, + "history_record": { + "date": ( + date_format(history.ifrc_severity_level_update_date, "N j, Y, g:i a") + if history.ifrc_severity_level_update_date + else "" + ), + "severity": history.get_ifrc_severity_level_display(), + "severity_value": history.ifrc_severity_level, + }, + } + ) + + # Convert to JSON for JavaScript + + response.context_data["expanded_results"] = json.dumps(expanded_results, default=str) + + return response + class GdacsAdmin(CompareVersionAdmin, RegionRestrictedAdmin, TranslationAdmin): country_in = "countries__pk__in" @@ -1063,6 +1189,396 @@ def updated_at(self, obj): ) +@admin.register(models.CrisisCategorisationByCountry) +class CrisisCategorisationByCountryAdmin(admin.ModelAdmin): + list_display = [ + "event", + "country", + "crisis_categorisation", + "crisis_score", + "status", + "updated_at", + ] + list_filter = ["crisis_categorisation", "event"] + list_select_related = ["event", "country"] + search_fields = ["event__name", "country__name"] + autocomplete_fields = ["event"] + readonly_fields = [ + "created_at", + "updated_at", + "event_countries_overview", + "pre_crisis_vulnerability", + "crisis_complexity", + "scope_and_scale", + "humanitarian_conditions", + "capacity_and_response", + # "pre_crisis_vulnerability_hazard_exposure_intermediate", + # "pre_crisis_vulnerability_vulnerability_intermediate", + # "pre_crisis_vulnerability_coping_mechanism_intermediate", + # "crisis_complexity_humanitarian_access_acaps", + # "scope_and_scale_number_of_affected_population", + # "scope_and_scale_total_population_of_the_affected_area", + # "scope_and_scale_percentage_affected_population", + # "scope_and_scale_impact_index_inform", + # "humanitarian_conditions_casualties_injrd_deaths_missing", + # "humanitarian_conditions_severity", + # "humanitarian_conditions_people_in_need", + # "capacity_and_response_ifrc_international_staff", + # "capacity_and_response_ifrc_national_staff", + # "capacity_and_response_ifrc_total_staff", + # "capacity_and_response_regional_office", + # "capacity_and_response_ops_capacity_ranking", + # "capacity_and_response_number_of_ns_staff", + # "capacity_and_response_ratio_staff_volunteer", + # "capacity_and_response_number_of_ns_volunteer", + # "capacity_and_response_number_of_dref_ea_last_3_years", + ] + + def get_readonly_fields(self, request, obj=None): + if obj: # This is a "Change" – we do not allow to change the country + return self.readonly_fields + ["event", "country"] + return self.readonly_fields + + class Media: + js = ("js/crisis_categorisation_headers.js",) + + def event_countries_overview(self, obj): + """Display a table of all countries for this event with their crisis scores""" + if not obj.event: + return "" + + # Get all crisis categorisations for this event + categorisations = ( + models.CrisisCategorisationByCountry.objects.filter(event=obj.event) + .select_related("country") + .order_by("country__name") + ) + + if not categorisations.exists(): + return mark_safe('

No countries categorised for this event yet.

') + + # Build HTML table + html = """ + + + + + + + + + + + + + + + + """ + + for cat in categorisations: + cc_display = cat.get_crisis_categorisation_display() if cat.crisis_categorisation is not None else "-" + cc_emoji_map = { + 0: "🟡", + 1: "🟠", + 2: "🔴", + } + cc_emoji = cc_emoji_map.get(cat.crisis_categorisation) + if cc_display != "-" and cc_emoji: + cc_display = f"{cc_emoji} {cc_display}" + + # Highlight current country + row_class = "current-country" if cat.country == obj.country else "" + change_url = f"../../{cat.pk}/change/" + + html += f""" + + + + + + + + + + + """ + + # Add final row with averages if there are multiple countries + if categorisations.count() > 1: + # Calculate averages + def calc_avg(field_name): + values = [getattr(cat, field_name) for cat in categorisations if getattr(cat, field_name) is not None] + if values: + return round(sum(values) / len(values), 2) + return "-" + + avg_crisis_score = calc_avg("crisis_score") + avg_pre_crisis = calc_avg("pre_crisis_vulnerability") + avg_crisis_complexity = calc_avg("crisis_complexity") + avg_scope_scale = calc_avg("scope_and_scale") + avg_humanitarian = calc_avg("humanitarian_conditions") + avg_capacity = calc_avg("capacity_and_response") + + # Create links for Y, O, R to event page with severity level filters + event_url_base = f"../../../event/{obj.event.pk}/change/" + y_link = ( + f'Yellow' + ) + o_link = ( + f'Orange' + ) + r_link = ( + f'Red' + ) + + if obj.status > 2: + finalize = f"Categorise emergency as {y_link} / {o_link} / {r_link}" + else: + finalize = "Get this categorisation approved." + + html += f""" + + + + + + + + + + + """ + + html += """ + +
CountryCCCrisis score1. Pre-crisis vulnerability2. Crisis complexity3. Scope & scale4. Humanitarian conditions5. Capacity & response
{cat.country.name}{cc_display}{cat.crisis_score if cat.crisis_score else "-"}{cat.pre_crisis_vulnerability if cat.pre_crisis_vulnerability else "-"}{cat.crisis_complexity if cat.crisis_complexity else "-"}{cat.scope_and_scale if cat.scope_and_scale else "-"}{cat.humanitarian_conditions if cat.humanitarian_conditions else "-"}{cat.capacity_and_response if cat.capacity_and_response else "-"}
Summary{finalize}{avg_crisis_score}{avg_pre_crisis}{avg_crisis_complexity}{avg_scope_scale}{avg_humanitarian}{avg_capacity}
+ """ + + return mark_safe(html) + + event_countries_overview.short_description = mark_safe("" + _("Crisis Categorisation Overview") + "") + + fieldsets = ( + ( + None, + { + "fields": ( + "event", + "event_countries_overview", + "country", + "crisis_categorisation", + "crisis_score", + ) + }, + ), + ( + "1. Pre-Crisis Vulnerability", + { + "fields": ( + "pre_crisis_vulnerability", + ("pre_crisis_vulnerability_hazard_exposure", "pre_crisis_vulnerability_hazard_exposure_comment"), + ( + "pre_crisis_vulnerability_hazard_exposure_intermediate", + "pre_crisis_vulnerability_hazard_exposure_intermediate_comment", + ), + ("pre_crisis_vulnerability_vulnerability", "pre_crisis_vulnerability_vulnerability_comment"), + ( + "pre_crisis_vulnerability_vulnerability_intermediate", + "pre_crisis_vulnerability_vulnerability_intermediate_comment", + ), + ("pre_crisis_vulnerability_coping_mechanism", "pre_crisis_vulnerability_coping_mechanism_comment"), + ( + "pre_crisis_vulnerability_coping_mechanism_intermediate", + "pre_crisis_vulnerability_coping_mechanism_intermediate_comment", + ), + ) + }, + ), + ( + "2. Crisis Complexity", + { + "fields": ( + "crisis_complexity", + ("crisis_complexity_humanitarian_access_score", "crisis_complexity_humanitarian_access_score_comment"), + ("crisis_complexity_humanitarian_access_acaps", "crisis_complexity_humanitarian_access_acaps_comment"), + ("crisis_complexity_government_response", "crisis_complexity_government_response_comment"), + ("crisis_complexity_media_attention", "crisis_complexity_media_attention_comment"), + ("crisis_complexity_ifrc_security_phase", "crisis_complexity_ifrc_security_phase_comment"), + ) + }, + ), + ( + "3. Scope & Scale", + { + "fields": ( + "scope_and_scale", + ( + "scope_and_scale_number_of_affected_population_score", + "scope_and_scale_number_of_affected_population_score_comment", + ), + ( + "scope_and_scale_number_of_affected_population", + "scope_and_scale_number_of_affected_population_comment", + ), + ( + "scope_and_scale_percentage_affected_population_score", + "scope_and_scale_percentage_affected_population_score_comment", + ), + ( + "scope_and_scale_total_population_of_the_affected_area", + "scope_and_scale_total_population_of_the_affected_area_comment", + ), + ( + "scope_and_scale_percentage_affected_population", + "scope_and_scale_percentage_affected_population_comment", + ), + ("scope_and_scale_impact_index_score", "scope_and_scale_impact_index_score_comment"), + ("scope_and_scale_impact_index_inform", "scope_and_scale_impact_index_inform_comment"), + ) + }, + ), + ( + "4. Humanitarian Conditions", + { + "fields": ( + "humanitarian_conditions", + ("humanitarian_conditions_casualties_score", "humanitarian_conditions_casualties_score_comment"), + ( + "humanitarian_conditions_casualties_injrd_deaths_missing", + "humanitarian_conditions_casualties_injrd_deaths_missing_comment", + ), + ("humanitarian_conditions_severity_score", "humanitarian_conditions_severity_score_comment"), + ("humanitarian_conditions_severity", "humanitarian_conditions_severity_comment"), + ("humanitarian_conditions_people_in_need_score", "humanitarian_conditions_people_in_need_score_comment"), + ("humanitarian_conditions_people_in_need", "humanitarian_conditions_people_in_need_comment"), + ) + }, + ), + ( + "5. Capacity & Response", + { + "fields": ( + "capacity_and_response", + ("capacity_and_response_ifrc_capacity_score", "capacity_and_response_ifrc_capacity_score_comment"), + ("capacity_and_response_ifrc_international_staff", "capacity_and_response_ifrc_international_staff_comment"), + ("capacity_and_response_ifrc_national_staff", "capacity_and_response_ifrc_national_staff_comment"), + ("capacity_and_response_ifrc_total_staff", "capacity_and_response_ifrc_total_staff_comment"), + ("capacity_and_response_regional_office", "capacity_and_response_regional_office_comment"), + ("capacity_and_response_ops_capacity_score", "capacity_and_response_ops_capacity_score_comment"), + ("capacity_and_response_ops_capacity_ranking", "capacity_and_response_ops_capacity_ranking_comment"), + ("capacity_and_response_ns_staff_score", "capacity_and_response_ns_staff_score_comment"), + ("capacity_and_response_number_of_ns_staff", "capacity_and_response_number_of_ns_staff_comment"), + ( + "capacity_and_response_ratio_staff_to_volunteer_score", + "capacity_and_response_ratio_staff_to_volunteer_score_comment", + ), + ("capacity_and_response_ratio_staff_volunteer", "capacity_and_response_ratio_staff_volunteer_comment"), + ("capacity_and_response_number_of_ns_volunteer", "capacity_and_response_number_of_ns_volunteer_comment"), + ("capacity_and_response_number_of_dref_score", "capacity_and_response_number_of_dref_score_comment"), + ( + "capacity_and_response_number_of_dref_ea_last_3_years", + "capacity_and_response_number_of_dref_ea_last_3_years_comment", + ), + ( + "capacity_and_response_presence_support_pns_in_country", + "capacity_and_response_presence_support_pns_in_country_comment", + ), + ) + }, + ), + ( + "––––––––––––––––––––", + {"fields": ("commentary", "general_document", "status")}, + ), + ( + "Timestamps", + { + "fields": ("created_at", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + + def get_queryset(self, request): + return super().get_queryset(request).select_related("event", "country") + + @admin.register(models.Export) class ExportTokenAdmin(admin.ModelAdmin): pass diff --git a/api/migrations/0228_crisiscategorisationbycountry.py b/api/migrations/0228_crisiscategorisationbycountry.py new file mode 100644 index 000000000..c220397fc --- /dev/null +++ b/api/migrations/0228_crisiscategorisationbycountry.py @@ -0,0 +1,120 @@ +# Generated by Django 4.2.27 on 2026-02-05 13:34 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import smart_selects.db_fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0227_alter_eventseveritylevelhistory_options'), + ] + + operations = [ + migrations.CreateModel( + name='CrisisCategorisationByCountry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('crisis_categorisation', models.IntegerField(blank=True, choices=[(0, 'Yellow'), (1, 'Orange'), (2, 'Red')], null=True, verbose_name='crisis categorisation')), + ('crisis_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='crisis score')), + ('pre_crisis_vulnerability', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='pre-crisis vulnerability')), + ('pre_crisis_vulnerability_hazard_exposure', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='hazard exposure')), + ('pre_crisis_vulnerability_hazard_exposure_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('pre_crisis_vulnerability_hazard_exposure_intermediate', models.DecimalField(blank=True, decimal_places=1, help_text='Not counted for the score, just FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='hazard exposure (intermediate score)')), + ('pre_crisis_vulnerability_hazard_exposure_intermediate_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('pre_crisis_vulnerability_vulnerability', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='vulnerability')), + ('pre_crisis_vulnerability_vulnerability_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('pre_crisis_vulnerability_vulnerability_intermediate', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='vulnerability (intermediate score)')), + ('pre_crisis_vulnerability_vulnerability_intermediate_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('pre_crisis_vulnerability_coping_mechanism', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='coping mechanism')), + ('pre_crisis_vulnerability_coping_mechanism_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('pre_crisis_vulnerability_coping_mechanism_intermediate', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='coping mechanism (intermediate score)')), + ('pre_crisis_vulnerability_coping_mechanism_intermediate_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('crisis_complexity', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='crisis complexity')), + ('crisis_complexity_humanitarian_access_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='humanitarian access score')), + ('crisis_complexity_humanitarian_access_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('crisis_complexity_humanitarian_access_acaps', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Humanitarian access ACAPS (complex crisis)')), + ('crisis_complexity_humanitarian_access_acaps_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('crisis_complexity_government_response', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='government response')), + ('crisis_complexity_government_response_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('crisis_complexity_media_attention', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='media attention')), + ('crisis_complexity_media_attention_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('crisis_complexity_ifrc_security_phase', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='IFRC security phase')), + ('crisis_complexity_ifrc_security_phase_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('scope_and_scale', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='scope & scale')), + ('scope_and_scale_number_of_affected_population_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='# Affected population score')), + ('scope_and_scale_number_of_affected_population_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('scope_and_scale_number_of_affected_population', models.IntegerField(blank=True, help_text='FYI', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000)], verbose_name='# Affected population')), + ('scope_and_scale_number_of_affected_population_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('scope_and_scale_percentage_affected_population_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='% Affected population score')), + ('scope_and_scale_percentage_affected_population_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('scope_and_scale_total_population_of_the_affected_area', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='total population of the affected area *')), + ('scope_and_scale_total_population_of_the_affected_area_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('scope_and_scale_percentage_affected_population', models.DecimalField(blank=True, decimal_places=2, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='% of Affected population')), + ('scope_and_scale_percentage_affected_population_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('scope_and_scale_impact_index_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='impact index score')), + ('scope_and_scale_impact_index_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('scope_and_scale_impact_index_inform', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='impact index INFORM')), + ('scope_and_scale_impact_index_inform_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('humanitarian_conditions', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='humanitarian conditions')), + ('humanitarian_conditions_casualties_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='casualties score')), + ('humanitarian_conditions_casualties_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('humanitarian_conditions_casualties_injrd_deaths_missing', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='casualties injured + deaths + missing')), + ('humanitarian_conditions_casualties_injrd_deaths_missing_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('humanitarian_conditions_severity_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='severity score *')), + ('humanitarian_conditions_severity_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('humanitarian_conditions_severity', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='severity')), + ('humanitarian_conditions_severity_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('humanitarian_conditions_people_in_need_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='# People in need score *')), + ('humanitarian_conditions_people_in_need_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('humanitarian_conditions_people_in_need', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='# People in need')), + ('humanitarian_conditions_people_in_need_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='capacity & response')), + ('capacity_and_response_ifrc_capacity_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='IFRC capacity score')), + ('capacity_and_response_ifrc_capacity_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_ifrc_international_staff', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='IFRC International staff')), + ('capacity_and_response_ifrc_international_staff_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_ifrc_national_staff', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='IFRC National staff')), + ('capacity_and_response_ifrc_national_staff_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_ifrc_total_staff', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='IFRC Total staff')), + ('capacity_and_response_ifrc_total_staff_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_regional_office', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Regional Office')), + ('capacity_and_response_regional_office_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_ops_capacity_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='OPS Capacity score')), + ('capacity_and_response_ops_capacity_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_ops_capacity_ranking', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Ops capacity Ranking (IFRC Global Risk - 2021)')), + ('capacity_and_response_ops_capacity_ranking_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_ns_staff_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='NS staff score')), + ('capacity_and_response_ns_staff_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_number_of_ns_staff', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Number of NS staff (FDRS) 2025')), + ('capacity_and_response_number_of_ns_staff_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_ratio_staff_to_volunteer_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Ratio staff to volunteer score')), + ('capacity_and_response_ratio_staff_to_volunteer_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_ratio_staff_volunteer', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Ratio staff volunteer')), + ('capacity_and_response_ratio_staff_volunteer_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_number_of_ns_volunteer', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Number of NS volunteer (FDRS)')), + ('capacity_and_response_number_of_ns_volunteer_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_number_of_dref_score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='# DREF Score')), + ('capacity_and_response_number_of_dref_score_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_number_of_dref_ea_last_3_years', models.DecimalField(blank=True, decimal_places=1, help_text='FYI', max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='# DREF and EA in the last 3 years')), + ('capacity_and_response_number_of_dref_ea_last_3_years_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('capacity_and_response_presence_support_pns_in_country', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Expert Judgement: on active presence and support of PNS in country')), + ('capacity_and_response_presence_support_pns_in_country_comment', models.CharField(blank=True, max_length=999, null=True, verbose_name='')), + ('commentary', models.TextField(blank=True, null=True, verbose_name='Add commentary')), + ('status', models.IntegerField(choices=[(0, 'In progress'), (1, 'Draft'), (2, 'Pending validation'), (3, 'Validated'), (4, 'Published'), (5, 'Merged')], default=0, verbose_name='status')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('country', smart_selects.db_fields.ChainedForeignKey(auto_choose=True, chained_field='event', chained_model_field='event', on_delete=django.db.models.deletion.CASCADE, to='api.country')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='crisis_categorisations', to='api.event', verbose_name='event')), + ('general_document', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='crisis_categorisations', to='api.generaldocument', verbose_name='File attachment')), + ], + options={ + 'verbose_name': 'crisis categorisation by country', + 'verbose_name_plural': 'crisis categorisations by country', + 'ordering': ('event', 'country'), + 'unique_together': {('event', 'country')}, + }, + ), + ] diff --git a/api/migrations/0229_alter_crisiscategorisationbycountry_capacity_and_response_ifrc_international_staff_and_more.py b/api/migrations/0229_alter_crisiscategorisationbycountry_capacity_and_response_ifrc_international_staff_and_more.py new file mode 100644 index 000000000..76c1b0ce5 --- /dev/null +++ b/api/migrations/0229_alter_crisiscategorisationbycountry_capacity_and_response_ifrc_international_staff_and_more.py @@ -0,0 +1,114 @@ +# Generated by Django 4.2.27 on 2026-02-05 16:58 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0228_crisiscategorisationbycountry'), + ] + + operations = [ + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='capacity_and_response_ifrc_international_staff', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='IFRC International staff'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='capacity_and_response_ifrc_national_staff', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='IFRC National staff'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='capacity_and_response_ifrc_total_staff', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='IFRC Total staff'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='capacity_and_response_number_of_dref_ea_last_3_years', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='# DREF and EA in the last 3 years'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='capacity_and_response_number_of_ns_staff', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Number of NS staff (FDRS) 2025'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='capacity_and_response_number_of_ns_volunteer', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Number of NS volunteer (FDRS)'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='capacity_and_response_ops_capacity_ranking', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Ops capacity Ranking (IFRC Global Risk - 2021)'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='capacity_and_response_ratio_staff_volunteer', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Ratio staff volunteer'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='capacity_and_response_regional_office', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Regional Office'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='crisis_complexity_humanitarian_access_acaps', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='Humanitarian access ACAPS (complex crisis)'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='humanitarian_conditions_casualties_injrd_deaths_missing', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='casualties injured + deaths + missing'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='humanitarian_conditions_people_in_need', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='# People in need'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='humanitarian_conditions_severity', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='severity'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='pre_crisis_vulnerability_coping_mechanism_intermediate', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='coping mechanism (intermediate score)'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='pre_crisis_vulnerability_hazard_exposure_intermediate', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='hazard exposure (intermediate score)'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='pre_crisis_vulnerability_vulnerability_intermediate', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='vulnerability (intermediate score)'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='scope_and_scale_impact_index_inform', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='impact index INFORM'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='scope_and_scale_number_of_affected_population', + field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000)], verbose_name='# Affected population'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='scope_and_scale_percentage_affected_population', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='% of Affected population'), + ), + migrations.AlterField( + model_name='crisiscategorisationbycountry', + name='scope_and_scale_total_population_of_the_affected_area', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)], verbose_name='total population of the affected area *'), + ), + ] diff --git a/api/models.py b/api/models.py index 9e00c3aca..8d0c4a40e 100644 --- a/api/models.py +++ b/api/models.py @@ -1,5 +1,6 @@ import uuid from datetime import datetime, timedelta +from decimal import ROUND_HALF_UP, Decimal import pytz import reversion @@ -13,7 +14,13 @@ # from django.db import models from django.contrib.gis.db import models from django.contrib.postgres.fields import ArrayField -from django.core.validators import FileExtensionValidator, RegexValidator, validate_slug +from django.core.validators import ( + FileExtensionValidator, + MaxValueValidator, + MinValueValidator, + RegexValidator, + validate_slug, +) from django.db.models import Q # from django.db.models import Prefetch @@ -22,6 +29,7 @@ from django.utils.translation import gettext_lazy as _ from django.utils.translation import override as translation_override from modeltranslation.utils import build_localized_fieldname +from smart_selects.db_fields import ChainedForeignKey from tinymce.models import HTMLField from lang.translation import AVAILABLE_LANGUAGES @@ -936,6 +944,767 @@ class Meta: verbose_name_plural = _("emergency severity level histories") +class CrisisCategorisationStatus(models.IntegerChoices): + IN_PROGRESS = 0, _("In progress") + DRAFT = 1, _("Draft") + PENDING = 2, _("Pending validation") + VALIDATED = 3, _("Validated") + PUBLISHED = 4, _("Published") + MERGED = 5, _("Merged") + + +@reversion.register() +class CrisisCategorisationByCountry(models.Model): + """Crisis categorisation for a specific country within an event""" + + event = models.ForeignKey( + Event, + verbose_name=_("event"), + on_delete=models.CASCADE, + related_name="crisis_categorisations", + ) + country = ChainedForeignKey( + "Country", + chained_field="event", # The field on THIS model (CrisisCategorisationByCountry) + chained_model_field="event", # The field on the TARGET model (Country) that refers to Event + show_all=False, # Only show filtered results + auto_choose=True, # If only one option exists, select it automatically. GREAT! + sort=True, + ) + # country = models.ForeignKey( + # Country, + # verbose_name=_("country"), + # on_delete=models.CASCADE, + # related_name="crisis_categorisations", + # ) + crisis_categorisation = models.IntegerField( + choices=AlertLevel.choices, + verbose_name=_("crisis categorisation"), + null=True, + blank=True, + ) + crisis_score = models.DecimalField( + verbose_name=_("crisis score"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + + # Pre-Crisis Vulnerability section + pre_crisis_vulnerability = models.DecimalField( + verbose_name=_("pre-crisis vulnerability"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + pre_crisis_vulnerability_hazard_exposure = models.DecimalField( + verbose_name=_("hazard exposure"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + pre_crisis_vulnerability_hazard_exposure_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + pre_crisis_vulnerability_hazard_exposure_intermediate = models.DecimalField( + verbose_name=_("hazard exposure (intermediate score)"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # help_text=_("Not counted for the score, just FYI"), + ) + pre_crisis_vulnerability_hazard_exposure_intermediate_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + pre_crisis_vulnerability_vulnerability = models.DecimalField( + verbose_name=_("vulnerability"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + pre_crisis_vulnerability_vulnerability_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + pre_crisis_vulnerability_vulnerability_intermediate = models.DecimalField( + verbose_name=_("vulnerability (intermediate score)"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + pre_crisis_vulnerability_vulnerability_intermediate_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + pre_crisis_vulnerability_coping_mechanism = models.DecimalField( + verbose_name=_("coping mechanism"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + pre_crisis_vulnerability_coping_mechanism_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + pre_crisis_vulnerability_coping_mechanism_intermediate = models.DecimalField( + verbose_name=_("coping mechanism (intermediate score)"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + pre_crisis_vulnerability_coping_mechanism_intermediate_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + + # Crisis Complexity section + crisis_complexity = models.DecimalField( + verbose_name=_("crisis complexity"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + crisis_complexity_humanitarian_access_score = models.DecimalField( + verbose_name=_("humanitarian access score"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + crisis_complexity_humanitarian_access_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + crisis_complexity_humanitarian_access_acaps = models.DecimalField( + verbose_name=_("Humanitarian access ACAPS (complex crisis)"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + crisis_complexity_humanitarian_access_acaps_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + crisis_complexity_government_response = models.DecimalField( + verbose_name=_("government response"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + crisis_complexity_government_response_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + crisis_complexity_media_attention = models.DecimalField( + verbose_name=_("media attention"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + crisis_complexity_media_attention_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + crisis_complexity_ifrc_security_phase = models.DecimalField( + verbose_name=_("IFRC security phase"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + crisis_complexity_ifrc_security_phase_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + + # Scope & Scale section + scope_and_scale = models.DecimalField( + verbose_name=_("scope & scale"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + scope_and_scale_number_of_affected_population_score = models.DecimalField( + verbose_name=_("# Affected population score"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + scope_and_scale_number_of_affected_population_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + scope_and_scale_number_of_affected_population = models.IntegerField( + verbose_name=_("# Affected population"), + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(1000000000)], + # # help_text=_("Not counted for the score, just FYI"), + ) + scope_and_scale_number_of_affected_population_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + scope_and_scale_percentage_affected_population_score = models.DecimalField( + verbose_name=_("% Affected population score"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + scope_and_scale_percentage_affected_population_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + scope_and_scale_total_population_of_the_affected_area = models.DecimalField( + verbose_name=_("total population of the affected area *"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + scope_and_scale_total_population_of_the_affected_area_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + scope_and_scale_percentage_affected_population = models.DecimalField( + verbose_name=_("% of Affected population"), + max_digits=3, + decimal_places=2, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(100)], + # # help_text=_("Not counted for the score, just FYI"), + ) + scope_and_scale_percentage_affected_population_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + scope_and_scale_impact_index_score = models.DecimalField( + verbose_name=_("impact index score"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + scope_and_scale_impact_index_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + scope_and_scale_impact_index_inform = models.DecimalField( + verbose_name=_("impact index INFORM"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + scope_and_scale_impact_index_inform_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + + # Humanitarian Conditions section + humanitarian_conditions = models.DecimalField( + verbose_name=_("humanitarian conditions"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + humanitarian_conditions_casualties_score = models.DecimalField( + verbose_name=_("casualties score"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + humanitarian_conditions_casualties_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + humanitarian_conditions_casualties_injrd_deaths_missing = models.DecimalField( + verbose_name=_("casualties injured + deaths + missing"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + humanitarian_conditions_casualties_injrd_deaths_missing_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + humanitarian_conditions_severity_score = models.DecimalField( + verbose_name=_("severity score *"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + humanitarian_conditions_severity_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + humanitarian_conditions_severity = models.DecimalField( + verbose_name=_("severity"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + humanitarian_conditions_severity_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + humanitarian_conditions_people_in_need_score = models.DecimalField( + verbose_name=_("# People in need score *"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + humanitarian_conditions_people_in_need_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + humanitarian_conditions_people_in_need = models.DecimalField( + verbose_name=_("# People in need"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + humanitarian_conditions_people_in_need_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + + # Capacity & Response section + capacity_and_response = models.DecimalField( + verbose_name=_("capacity & response"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + capacity_and_response_ifrc_capacity_score = models.DecimalField( + verbose_name=_("IFRC capacity score"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + capacity_and_response_ifrc_capacity_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_ifrc_international_staff = models.DecimalField( + verbose_name=_("IFRC International staff"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + capacity_and_response_ifrc_international_staff_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_ifrc_national_staff = models.DecimalField( + verbose_name=_("IFRC National staff"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + capacity_and_response_ifrc_national_staff_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_ifrc_total_staff = models.DecimalField( + verbose_name=_("IFRC Total staff"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + capacity_and_response_ifrc_total_staff_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_regional_office = models.DecimalField( + verbose_name=_("Regional Office"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + capacity_and_response_regional_office_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_ops_capacity_score = models.DecimalField( + verbose_name=_("OPS Capacity score"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + capacity_and_response_ops_capacity_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_ops_capacity_ranking = models.DecimalField( + verbose_name=_("Ops capacity Ranking (IFRC Global Risk - 2021)"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + capacity_and_response_ops_capacity_ranking_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_ns_staff_score = models.DecimalField( + verbose_name=_("NS staff score"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + capacity_and_response_ns_staff_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_number_of_ns_staff = models.DecimalField( + verbose_name=_("Number of NS staff (FDRS) 2025"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + capacity_and_response_number_of_ns_staff_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_ratio_staff_to_volunteer_score = models.DecimalField( + verbose_name=_("Ratio staff to volunteer score"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + capacity_and_response_ratio_staff_to_volunteer_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_ratio_staff_volunteer = models.DecimalField( + verbose_name=_("Ratio staff volunteer"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + capacity_and_response_ratio_staff_volunteer_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_number_of_ns_volunteer = models.DecimalField( + verbose_name=_("Number of NS volunteer (FDRS)"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + capacity_and_response_number_of_ns_volunteer_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_number_of_dref_score = models.DecimalField( + verbose_name=_("# DREF Score"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + capacity_and_response_number_of_dref_score_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_number_of_dref_ea_last_3_years = models.DecimalField( + verbose_name=_("# DREF and EA in the last 3 years"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + # # help_text=_("Not counted for the score, just FYI"), + ) + capacity_and_response_number_of_dref_ea_last_3_years_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + capacity_and_response_presence_support_pns_in_country = models.DecimalField( + verbose_name=_("Expert Judgement: on active presence and support of PNS in country"), + max_digits=3, + decimal_places=1, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(9)], + ) + capacity_and_response_presence_support_pns_in_country_comment = models.CharField( + max_length=999, + verbose_name="", + null=True, + blank=True, + ) + + # Commentary + commentary = models.TextField( + verbose_name=_("Add commentary"), + null=True, + blank=True, + ) + + # Attachment + general_document = models.ForeignKey( + "GeneralDocument", + verbose_name=_("File attachment"), + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="crisis_categorisations", + ) + + status = models.IntegerField(choices=CrisisCategorisationStatus.choices, verbose_name=_("status"), default=0) + created_at = models.DateTimeField(verbose_name=_("created at"), auto_now_add=True) + updated_at = models.DateTimeField(verbose_name=_("updated at"), auto_now=True) + + class Meta: + unique_together = ("event", "country") + verbose_name = _("crisis categorisation by country") + verbose_name_plural = _("crisis categorisations by country") + ordering = ("event", "country") + + def __str__(self): + return f"{self.event.name} - {self.country.name}" + + def clean(self): + """Validate that the country is associated with the event""" + from django.core.exceptions import ValidationError + + if self.event and self.country: + if not self.event.countries.filter(pk=self.country.pk).exists(): + raise ValidationError( + {"country": _("The selected country must be one of the countries associated with this event.")} + ) + + def _average_fields(self, field_names): + values = [] + for field_name in field_names: + value = getattr(self, field_name) + if value is None: + continue + values.append(Decimal(str(value))) + + if not values: + return None + + avg = sum(values) / Decimal(len(values)) + return avg.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) + + def save(self, *args, **kwargs): + """Override save to always validate before saving""" + self.pre_crisis_vulnerability = self._average_fields( + [ + "pre_crisis_vulnerability_hazard_exposure", + "pre_crisis_vulnerability_vulnerability", + "pre_crisis_vulnerability_coping_mechanism", + ] + ) + self.crisis_complexity = self._average_fields( + [ + "crisis_complexity_humanitarian_access_score", + "crisis_complexity_government_response", + "crisis_complexity_media_attention", + "crisis_complexity_ifrc_security_phase", + ] + ) + self.scope_and_scale = self._average_fields( + [ + "scope_and_scale_number_of_affected_population_score", + "scope_and_scale_percentage_affected_population_score", + "scope_and_scale_impact_index_score", + ] + ) + self.humanitarian_conditions = self._average_fields( + [ + "humanitarian_conditions_casualties_score", + "humanitarian_conditions_severity_score", + "humanitarian_conditions_people_in_need_score", + ] + ) + self.capacity_and_response = self._average_fields( + [ + "capacity_and_response_ifrc_capacity_score", + "capacity_and_response_ops_capacity_score", + "capacity_and_response_ns_staff_score", + "capacity_and_response_ratio_staff_to_volunteer_score", + "capacity_and_response_number_of_dref_score", + "capacity_and_response_presence_support_pns_in_country", + ] + ) + self.full_clean() + return super().save(*args, **kwargs) + + @reversion.register() class EventFeaturedDocument(models.Model): event = models.ForeignKey( diff --git a/api/templates/admin/emergency_change_list_with_history.html b/api/templates/admin/emergency_change_list_with_history.html new file mode 100644 index 000000000..acbde77f2 --- /dev/null +++ b/api/templates/admin/emergency_change_list_with_history.html @@ -0,0 +1,151 @@ +{% extends "admin/emergency_change_list.html" %} +{% load i18n static %} + +{% block extrahead %} + {{ block.super }} + + +{% endblock %} + + diff --git a/assets b/assets index ecf93b474..1faa98273 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit ecf93b474a86f62038e0d9330dc3935834130d0f +Subproject commit 1faa982732d421f810e8c429f1b2b09d7831dc50 diff --git a/go-static/css/after.css b/go-static/css/after.css index 0941e212d..31db15a3b 100644 --- a/go-static/css/after.css +++ b/go-static/css/after.css @@ -5,3 +5,256 @@ p.deletelink-box { margin-top: 0; } + +/* Crisis Categorisation By Country - Event field width */ +#id_event { + min-width: 400px; +} + +/* Crisis Categorisation By Country - Inline field layout */ + +/* Make form rows display fields side by side */ +.change-form .form-row { + display: flex; + flex-wrap: wrap; + gap: 15px; + align-items: flex-start; +} + +/* First field in a row (score fields) - wider for longer labels */ +.change-form .form-row > div:first-child:not(:only-child) { + flex: 0 0 30%; + min-width: 200px; +} + +/* Second field in a row (comment fields) - 68% width */ +.change-form .form-row > div:last-child:not(:only-child) { + flex: 0 0 68%; + min-width: 300px; +} + +/* Single fields (not in tuples) take full width */ +.change-form .form-row > div:only-child { + flex: 1 1 100%; + max-width: 100%; +} + +/* Ensure labels don't break the layout and don't wrap */ +.change-form .form-row label { + display: block; + margin-bottom: 5px; + white-space: nowrap; + overflow: visible; +} + +/* Input fields should not exceed their container */ +.change-form .form-row input[type="text"], +.change-form .form-row input[type="number"], +.change-form .form-row select, +.change-form .form-row textarea { + max-width: 100%; + box-sizing: border-box; +} + +/* Crisis Categorisation: ensure IntegerField inputs match DecimalField width but aren't oversized */ +.model-crisiscategorisationbycountry.change-form .vIntegerField { + width: 14em; +} + +/* Comment fields (second column) should fill their container */ +.change-form .form-row > div:last-child:not(:only-child) input[type="text"] { + width: 100%; +} + +/* Fieldset with positioned header field */ +.change-form fieldset.module { + position: relative; +} + +.change-form fieldset.module > h2 { + padding-right: 200px; /* Space for the input field */ +} + +/* Table header row for fieldsets - custom HTML version */ +.change-form .fieldset-table-header { + display: flex; + gap: 15px; + background: #f8f8f8; + padding: 8px 10px; + margin: 4px 0 0; /* Gap between header and subheader */ + border-bottom: 1px solid #ddd; + font-weight: 600; + font-size: 11px; + color: #666; +} + +.change-form .fieldset-table-header .header-indicator { + flex: 0 0 30%; + min-width: 200px; +} + +.change-form .fieldset-table-header .header-comment { + flex: 0 0 68%; + min-width: 300px; +} + +/* Dark theme support for subheaders */ +@media (prefers-color-scheme: dark) { + .change-form .fieldset-table-header { + background: #2b2b2b; + border-bottom-color: #444; + color: #b0b0b0; + } +} + + +/* Position first form-row with single field to the header area */ +.change-form fieldset.module > .form-row:first-of-type > div:only-child { + position: absolute; + top: 3px; + right: 15px; + margin: 0; + z-index: 10; +} + +/* Exception: commentary and event fields should NOT be positioned in header */ +.change-form fieldset.module > .form-row:first-of-type > div:only-child:has(textarea[name="commentary"]), +.change-form fieldset.module > .form-row:first-of-type > div:only-child:has(input[name="commentary"]), +.change-form fieldset.module > .form-row:first-of-type > div:only-child:has(select[name="event"]) { + position: static; +} + +.change-form fieldset.module > .form-row:first-of-type > div:only-child label { + display: none; +} + +/* Show label for commentary and event fields */ +.change-form fieldset.module > .form-row:first-of-type > div:only-child:has(textarea[name="commentary"]) label, +.change-form fieldset.module > .form-row:first-of-type > div:only-child:has(input[name="commentary"]) label, +.change-form fieldset.module > .form-row:first-of-type > div:only-child:has(select[name="event"]) label { + display: block; +} + +.change-form fieldset.module > .form-row:first-of-type > div:only-child input, +.change-form fieldset.module > .form-row:first-of-type > div:only-child select { + width: 100px; + max-width: 100px; + margin: 0; + padding: 4px 6px; + font-size: 13px; +} + +/* Reset width for commentary and event fields */ +.change-form fieldset.module > .form-row:first-of-type > div:only-child textarea[name="commentary"], +.change-form fieldset.module > .form-row:first-of-type > div:only-child input[name="commentary"], +.change-form fieldset.module > .form-row:first-of-type > div:only-child select[name="event"] { + width: 100%; + max-width: 100%; + padding: initial; + font-size: initial; +} + +/* Hide the form-row container but show its content via absolute positioning */ +.change-form fieldset.module > .form-row:first-of-type:has(> div:only-child) { + height: 0; + margin: 0; + padding: 0; + overflow: visible; +} + +/* Show normal height for commentary and event form-rows */ +.change-form fieldset.module > .form-row:first-of-type:has(textarea[name="commentary"]), +.change-form fieldset.module > .form-row:first-of-type:has(input[name="commentary"]), +.change-form fieldset.module > .form-row:first-of-type:has(select[name="event"]) { + height: auto; + margin: initial; + padding: initial; +} + +/* Did not work to align Event left +.change-form .form-row.field-event > div { + display: flex; + flex-direction: column; + align-items: flex-start; +} */ + +/* Clickable help icon + tooltip attached to Crisis Categorisation headers */ +.precrisis-help-icon, +.cc-help-icon { + display: inline-block; + width: 16px; + height: 16px; + line-height: 16px; + text-align: center; + border-radius: 50%; + color: white; + font-size: 12px; + font-weight: bold; + cursor: pointer; + margin-left: 5px; + vertical-align: middle; + user-select: none; + position: relative; + top: -0.3rem; +} + +/* Tooltip bubble */ +.precrisis-help-tooltip, +.cc-help-tooltip { + display: none; + position: absolute; + bottom: calc(100% + 8px); + left: 0; + background: #fff; + color: #333; + padding: 8px 12px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 12px; + max-width: 90vw; + max-height: 80vh; + overflow: auto; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + margin-top: 0; +} + +.precrisis-help-tooltip img, +.cc-help-tooltip img { + display: block; + max-width: 100%; + height: auto; +} + +.precrisis-help-tooltip::before, +.cc-help-tooltip::before { + content: ''; + position: absolute; + bottom: -5px; + left: 20px; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid #ccc; +} + +.precrisis-help-tooltip.visible, +.cc-help-tooltip.visible { + display: block; +} + +/* Dark theme support */ +@media (prefers-color-scheme: dark) { + .precrisis-help-tooltip, + .cc-help-tooltip { + background: #1a1a1a; + color: #eee; + border: 1px solid #444; + } + + .precrisis-help-tooltip::before, + .cc-help-tooltip::before { + border-top-color: #1a1a1a; + } +} diff --git a/go-static/images/cc/capacity_and_response.gif b/go-static/images/cc/capacity_and_response.gif new file mode 100644 index 000000000..1bc4c2143 Binary files /dev/null and b/go-static/images/cc/capacity_and_response.gif differ diff --git a/go-static/images/cc/crisis_complexity.gif b/go-static/images/cc/crisis_complexity.gif new file mode 100644 index 000000000..5a336b5f4 Binary files /dev/null and b/go-static/images/cc/crisis_complexity.gif differ diff --git a/go-static/images/cc/humanitarian_conditions.gif b/go-static/images/cc/humanitarian_conditions.gif new file mode 100644 index 000000000..b41243315 Binary files /dev/null and b/go-static/images/cc/humanitarian_conditions.gif differ diff --git a/go-static/images/cc/pre_crisis_vulnerability.gif b/go-static/images/cc/pre_crisis_vulnerability.gif new file mode 100644 index 000000000..fd353a516 Binary files /dev/null and b/go-static/images/cc/pre_crisis_vulnerability.gif differ diff --git a/go-static/images/cc/scope_and_scale.gif b/go-static/images/cc/scope_and_scale.gif new file mode 100644 index 000000000..2051f21c0 Binary files /dev/null and b/go-static/images/cc/scope_and_scale.gif differ diff --git a/go-static/js/crisis_categorisation_headers.js b/go-static/js/crisis_categorisation_headers.js new file mode 100644 index 000000000..2215ac908 --- /dev/null +++ b/go-static/js/crisis_categorisation_headers.js @@ -0,0 +1,324 @@ +// Add custom HTML header row to Crisis Categorisation fieldsets +document.addEventListener('DOMContentLoaded', function() { + // Minimal styling for injected icons/tooltips + const style = document.createElement('style'); + style.textContent = ` + .external-link-icon { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 6px; + vertical-align: middle; + color: inherit; + text-decoration: none; + opacity: 0.85; + } + .external-link-icon:hover { opacity: 1; } + .external-link-icon svg { + width: 14px; + height: 14px; + fill: currentColor; + } + + /* Field help icons color adjustment */ + .field-help-icon { + color: #444 !important; + } + @media (prefers-color-scheme: dark) { + .field-help-icon { + color: #b0b0b0 !important; + } + } + + /* Position field tooltips below the icon */ + .field-help-tooltip { + top: 100% !important; + bottom: auto !important; + margin-top: 8px !important; + } + + /* Arrow adjustment for top-pointing (tooltip below) */ + .field-help-tooltip::before { + top: -5px !important; + bottom: auto !important; + border-top: none !important; + border-bottom: 5px solid #ccc !important; + } + + @media (prefers-color-scheme: dark) { + .field-help-tooltip::before { + border-bottom-color: #1a1a1a !important; + } + } + `; + document.head.appendChild(style); + + const fieldsets = document.querySelectorAll('.change-form fieldset.module'); + + const headerNodesByText = new Map(); + let escKeyHandlerAdded = false; + + // Target fieldsets that should have the custom header + const targetHeaders = [ + '1. Pre-Crisis Vulnerability', + '2. Crisis Complexity', + '3. Scope & Scale', + '4. Humanitarian Conditions', + '5. Capacity & Response' + ]; + + fieldsets.forEach(function(fieldset) { + const h2 = fieldset.querySelector('h2'); + const firstFormRow = fieldset.querySelector('.form-row:first-of-type'); + + // Only add header if h2 exists and matches target headers + if (h2 && firstFormRow) { + const headerText = h2.textContent.trim(); + + headerNodesByText.set(headerText, h2); + + // Check if this fieldset is one of the target fieldsets + if (targetHeaders.includes(headerText)) { + // Create the custom HTML header + const headerRow = document.createElement('div'); + headerRow.className = 'fieldset-table-header'; + headerRow.innerHTML = ` +
Indicator
+
Comment / source
+ `; + + // Insert after the first form-row (which contains the header field) + firstFormRow.parentNode.insertBefore(headerRow, firstFormRow.nextSibling); + } + } + }); + + function addHeaderHelpTooltip(headerText, imageFilename, imageAlt, extraIconClass) { + const headerNode = headerNodesByText.get(headerText); + if (!headerNode) { + return; + } + + if (headerNode.querySelector('.cc-help-icon')) { + return; + } + + const fallbackHelpTextContent = 'Help image failed to load.'; + + const helpIcon = document.createElement('span'); + helpIcon.className = `help-icon cc-help-icon ${extraIconClass || ''}`.trim(); + helpIcon.innerHTML = 'ⓘ'; + helpIcon.title = 'Click for more information'; + + const tooltip = document.createElement('div'); + tooltip.className = 'help-tooltip cc-help-tooltip'; + + const helpImage = document.createElement('img'); + helpImage.src = `/static/images/cc/${imageFilename}`; + helpImage.alt = imageAlt; + helpImage.loading = 'lazy'; + helpImage.addEventListener('error', function() { + tooltip.textContent = fallbackHelpTextContent; + }); + + tooltip.appendChild(helpImage); + + headerNode.appendChild(document.createTextNode(' ')); + headerNode.appendChild(helpIcon); + + headerNode.style.position = 'relative'; + headerNode.appendChild(tooltip); + + helpIcon.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + const shouldShow = !tooltip.classList.contains('visible'); + document.querySelectorAll('.cc-help-tooltip.visible').forEach(function(openTooltip) { + openTooltip.classList.remove('visible'); + }); + tooltip.classList.toggle('visible', shouldShow); + }); + + document.addEventListener('click', function(e) { + if (!headerNode.contains(e.target)) { + tooltip.classList.remove('visible'); + } + }); + + if (!escKeyHandlerAdded) { + escKeyHandlerAdded = true; + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' || e.key === 'Esc') { + document.querySelectorAll('.cc-help-tooltip.visible').forEach(function(openTooltip) { + openTooltip.classList.remove('visible'); + }); + } + }); + } + } + + addHeaderHelpTooltip( + '1. Pre-Crisis Vulnerability', + 'pre_crisis_vulnerability.gif', + 'Pre-Crisis Vulnerability help', + 'precrisis-help-icon' + ); + addHeaderHelpTooltip( + '2. Crisis Complexity', + 'crisis_complexity.gif', + 'Crisis Complexity help', + 'crisiscomplexity-help-icon' + ); + addHeaderHelpTooltip( + '3. Scope & Scale', + 'scope_and_scale.gif', + 'Scope & Scale help', + 'scopeandscale-help-icon' + ); + addHeaderHelpTooltip( + '4. Humanitarian Conditions', + 'humanitarian_conditions.gif', + 'Humanitarian Conditions help', + 'humanitarianconditions-help-icon' + ); + addHeaderHelpTooltip( + '5. Capacity & Response', + 'capacity_and_response.gif', + 'Capacity & Response help', + 'capacityandresponse-help-icon' + ); + + const informMapExplorerUrl = 'https://drmkc.jrc.ec.europa.eu/inform-index/INFORM-Risk/Map-Explorer'; + + function addExternalLinkIcon(fieldSelector, url) { + const field = document.querySelector(fieldSelector); + if (!field) { + return; + } + + const label = field.querySelector('label'); + if (!label) { + return; + } + + if (label.querySelector('.external-link-icon')) { + return; + } + + const link = document.createElement('a'); + link.className = 'external-link-icon'; + link.href = url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.title = 'Open INFORM Risk Map Explorer'; + link.setAttribute('aria-label', 'Open INFORM Risk Map Explorer'); + + // Inline SVG external-link icon + link.innerHTML = ` + + `; + + label.appendChild(link); + } + + + function addFieldHelpTooltip(label) { + if (label.querySelector('.field-help-icon')) { + return; + } + + const helpIcon = document.createElement('span'); + helpIcon.className = 'help-icon cc-help-icon field-help-icon'; + helpIcon.textContent = 'ⓘ'; + helpIcon.title = 'Click for more information'; + helpIcon.style.fontStyle = 'normal'; + helpIcon.style.marginLeft = '5px'; + helpIcon.style.cursor = 'pointer'; + + const tooltip = document.createElement('div'); + tooltip.className = 'help-tooltip cc-help-tooltip field-help-tooltip'; + tooltip.textContent = 'This indicator will not be counted for the score but is needed for the overall tracking and estimations.'; + tooltip.style.fontStyle = 'normal'; + + // Ensure label can position the absolute tooltip + label.style.position = 'relative'; + + label.appendChild(helpIcon); + label.appendChild(tooltip); + + helpIcon.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + const isVisible = tooltip.classList.contains('visible'); + + // Close all open tooltips first + document.querySelectorAll('.cc-help-tooltip.visible').forEach(function(openTooltip) { + openTooltip.classList.remove('visible'); + }); + + if (!isVisible) { + tooltip.classList.add('visible'); + } + }); + + document.addEventListener('click', function(e) { + if (!label.contains(e.target)) { + tooltip.classList.remove('visible'); + } + }); + } + + function italicizeFieldLabels(fieldNames) { + fieldNames.forEach(function(fieldName) { + const label = document.querySelector(`label[for="id_${fieldName}"]`); + if (!label) { + return; + } + + label.style.fontStyle = 'italic'; + addFieldHelpTooltip(label); + }); + } + + italicizeFieldLabels([ + 'pre_crisis_vulnerability_hazard_exposure_intermediate', + 'pre_crisis_vulnerability_vulnerability_intermediate', + 'pre_crisis_vulnerability_coping_mechanism_intermediate', + 'crisis_complexity_humanitarian_access_acaps', + 'scope_and_scale_number_of_affected_population', + 'scope_and_scale_total_population_of_the_affected_area', + 'scope_and_scale_percentage_affected_population', + 'scope_and_scale_impact_index_inform', + 'humanitarian_conditions_casualties_injrd_deaths_missing', + 'humanitarian_conditions_severity', + 'humanitarian_conditions_people_in_need', + 'capacity_and_response_ifrc_international_staff', + 'capacity_and_response_ifrc_national_staff', + 'capacity_and_response_ifrc_total_staff', + 'capacity_and_response_regional_office', + 'capacity_and_response_ops_capacity_ranking', + 'capacity_and_response_number_of_ns_staff', + 'capacity_and_response_ratio_staff_volunteer', + 'capacity_and_response_number_of_ns_volunteer', + 'capacity_and_response_number_of_dref_ea_last_3_years' + ]); + + // Add external URL icons after labels + addExternalLinkIcon('.field-pre_crisis_vulnerability_hazard_exposure', informMapExplorerUrl); + addExternalLinkIcon('.field-pre_crisis_vulnerability_vulnerability', informMapExplorerUrl); + addExternalLinkIcon('.field-pre_crisis_vulnerability_coping_mechanism', informMapExplorerUrl); + addExternalLinkIcon('.field-crisis_complexity_humanitarian_access_score', informMapExplorerUrl); + addExternalLinkIcon('.field-crisis_complexity_humanitarian_access_acaps', informMapExplorerUrl); + addExternalLinkIcon('.field-scope_and_scale_impact_index_score', informMapExplorerUrl); + addExternalLinkIcon('.field-humanitarian_conditions_severity_score', informMapExplorerUrl); + addExternalLinkIcon('.field-capacity_and_response_ifrc_capacity_score', informMapExplorerUrl); + addExternalLinkIcon('.field-capacity_and_response_ops_capacity_score', informMapExplorerUrl); + addExternalLinkIcon('.field-capacity_and_response_ns_staff_score', informMapExplorerUrl); + addExternalLinkIcon('.field-capacity_and_response_ratio_staff_to_volunteer_score', informMapExplorerUrl); + addExternalLinkIcon('.field-capacity_and_response_number_of_dref_score', informMapExplorerUrl); +}); diff --git a/main/settings.py b/main/settings.py index 6fede598b..d2e2daf1a 100644 --- a/main/settings.py +++ b/main/settings.py @@ -276,6 +276,8 @@ def parse_domain(*env_keys: str) -> str: "debug_toolbar", # GIS "django.contrib.gis", + # chained select + "smart_selects", ] REST_FRAMEWORK = { diff --git a/main/urls.py b/main/urls.py index 0733965ad..d9d6cc676 100644 --- a/main/urls.py +++ b/main/urls.py @@ -267,6 +267,7 @@ path("docs/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), path("api-docs/", SpectacularAPIView.as_view(), name="schema"), path("api-docs/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("chaining/", include("smart_selects.urls")), ] if settings.OIDC_ENABLE: diff --git a/pyproject.toml b/pyproject.toml index f617456d7..6cfafedcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "django-read-only==1.12.0", "django-reversion-compare==0.16.2", "django-reversion==5.0.12", + "django-smart-selects>=1.7.2", "django-storages[s3,azure]==1.14.5", "django-tinymce==4.1.0", "django-oauth-toolkit==3.0.1", @@ -92,7 +93,7 @@ dev = [ "pytest-ordering", "pytest-django", "snapshottest==0.6.0", - "django-debug-toolbar==4.1.0", + "django-debug-toolbar==6.2.0", "django-stubs", ] celery = [ diff --git a/uv.lock b/uv.lock index a5528acb0..47692fbfd 100644 --- a/uv.lock +++ b/uv.lock @@ -641,15 +641,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/83/19/9d53c3a330b121d04 [[package]] name = "django-debug-toolbar" -version = "4.1.0" +version = "6.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "sqlparse" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/e0/9a439dd58ac27ceaecce015fe57916bbd84dbbaf7ca3208c122b46c7d3ff/django_debug_toolbar-4.1.0.tar.gz", hash = "sha256:f57882e335593cb8e74c2bda9f1116bbb9ca8fc0d81b50a75ace0f83de5173c7", size = 116377, upload-time = "2023-05-16T06:46:00.074Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/4d/6acf660500d3d581bfc19460d9605cdf14c275640f35825da1329eaafafa/django_debug_toolbar-6.2.0.tar.gz", hash = "sha256:dc1c174d8fb0ea01435e02d9ceef735cf62daf37c1a6a5692d33b4127327679b", size = 313779, upload-time = "2026-01-20T12:38:25.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/11/22ae178ae870945970250bdad22530583384b3438be87e08c075016f3b07/django_debug_toolbar-4.1.0-py3-none-any.whl", hash = "sha256:a0b532ef5d52544fd745d1dcfc0557fa75f6f0d1962a8298bd568427ef2fa436", size = 222480, upload-time = "2023-05-16T06:46:23.879Z" }, + { url = "https://files.pythonhosted.org/packages/88/04/e24611299a5ee0d4edfacf935b09cfb7d5d9cb653bd7b7883c3b43a6f90d/django_debug_toolbar-6.2.0-py3-none-any.whl", hash = "sha256:1575461954e6befa720e999dec13fe4f1cc8baf40b6c3ac2aec5f340c0f9c85f", size = 271354, upload-time = "2026-01-20T12:38:23.608Z" }, ] [[package]] @@ -813,6 +813,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/b9/40963fd61f766d6570415a5e82a544824bc62f865ecbc935b41e986bb149/django_reversion_compare-0.16.2-py3-none-any.whl", hash = "sha256:5629f226fc73bd7b95de47b2e21e2eba2fa39f004ba0fee6d460e96676c0dc9b", size = 97508, upload-time = "2023-05-08T14:03:51.018Z" }, ] +[[package]] +name = "django-smart-selects" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/33/3b512f6b38e0581be812f470424e9b819688edba03b581931534d5fcfc11/django_smart_selects-1.7.2.tar.gz", hash = "sha256:c8116718ecf2d0d0fd4cd1e52c26d2dcc586e25319aa65af7af2d51a1012df3a", size = 27931, upload-time = "2024-11-17T19:06:57.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/83/4ae33d1518409ac65891cc5f739cface4d8f27117fbf1eda3a515e90e86c/django_smart_selects-1.7.2-py3-none-any.whl", hash = "sha256:08c774d96285e6d169043cd548860d74d085be8d349c42437232c42bc43cd79a", size = 31651, upload-time = "2024-11-17T19:06:55.279Z" }, +] + [[package]] name = "django-storages" version = "1.14.5" @@ -1043,6 +1055,7 @@ dependencies = [ { name = "django-redis" }, { name = "django-reversion" }, { name = "django-reversion-compare" }, + { name = "django-smart-selects" }, { name = "django-storages", extra = ["azure", "s3"] }, { name = "django-tinymce" }, { name = "djangorestframework" }, @@ -1136,6 +1149,7 @@ requires-dist = [ { name = "django-redis", specifier = "==5.0.0" }, { name = "django-reversion", specifier = "==5.0.12" }, { name = "django-reversion-compare", specifier = "==0.16.2" }, + { name = "django-smart-selects", specifier = ">=1.7.2" }, { name = "django-storages", extras = ["s3", "azure"], specifier = "==1.14.5" }, { name = "django-tinymce", specifier = "==4.1.0" }, { name = "djangorestframework", specifier = "==3.15.2" }, @@ -1188,7 +1202,7 @@ requires-dist = [ [package.metadata.requires-dev] celery = [{ name = "playwright", specifier = "==1.50.0" }] dev = [ - { name = "django-debug-toolbar", specifier = "==4.1.0" }, + { name = "django-debug-toolbar", specifier = "==6.2.0" }, { name = "django-stubs" }, { name = "pytest" }, { name = "pytest-django" },