diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py index ea73720..688217e 100644 --- a/loko/contracts/admin.py +++ b/loko/contracts/admin.py @@ -2,6 +2,8 @@ import io from django import forms from django.contrib import admin, messages +from django.contrib.gis import admin as gis_admin +from django.utils.translation import gettext_lazy as _ from django.http import HttpResponseRedirect from django.shortcuts import render from django.urls import path, reverse @@ -136,8 +138,8 @@ class CompanyAdmin(admin.ModelAdmin): list_display = ('name', 'contact_email', 'phone_number') @admin.register(Contract) -class ContractAdmin(admin.ModelAdmin): - list_display = ('contract_number', 'description', 'company', 'municipalities_list', 'is_blocked', 'is_active', 'start_date', 'end_date', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'thematics_list') +class ContractAdmin(gis_admin.GISModelAdmin): + list_display = ('contract_number', 'description', 'company', 'municipalities_list', 'has_geom', 'color', 'is_blocked', 'is_active', 'start_date', 'end_date', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'thematics_list') search_fields = ('contract_number', 'company__name', 'description', 'contact_phone') list_filter = ('is_blocked', 'is_active', 'municipalities', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'send_daily_summary') filter_horizontal = ('thematics', 'municipalities') @@ -155,6 +157,12 @@ class ContractAdmin(admin.ModelAdmin): (None, { 'fields': ('company', 'municipalities', 'contract_number', 'description', 'start_date', 'end_date', 'is_active', 'committed_amount', 'internal_link', 'executive_officer', 'contact_phone', 'signatory_name', 'signatory_role', 'thematics'), }), + ('Emprise géographique (Lot)', { + 'fields': ('geom', 'color'), + 'description': ( + "Emprise géographique du contrat / lot (Multi-polygone) et couleur d'affichage sur les cartes." + ), + }), ('Blocage budgétaire / Suspension', { 'fields': ('is_blocked', 'blocked_reason', 'blocked_at'), 'description': ( @@ -175,6 +183,11 @@ class ContractAdmin(admin.ModelAdmin): }), ) + def has_geom(self, obj): + return bool(obj.geom) + has_geom.boolean = True + has_geom.short_description = _("Zone définie") + class Media: css = { 'all': ('contracts/css/contract_admin.css',) @@ -285,8 +298,7 @@ class CompanyTeamMemberInline(admin.TabularInline): verbose_name_plural = "Membres" -from django.contrib.gis import admin as gis_admin -from django.utils.translation import gettext_lazy as _ + class CompanyTeamAdminForm(forms.ModelForm): diff --git a/loko/contracts/locale/en/LC_MESSAGES/django.mo b/loko/contracts/locale/en/LC_MESSAGES/django.mo index d464408..d61ce0c 100644 Binary files a/loko/contracts/locale/en/LC_MESSAGES/django.mo and b/loko/contracts/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/contracts/locale/en/LC_MESSAGES/django.po b/loko/contracts/locale/en/LC_MESSAGES/django.po index 32e5bae..bd89c9a 100644 --- a/loko/contracts/locale/en/LC_MESSAGES/django.po +++ b/loko/contracts/locale/en/LC_MESSAGES/django.po @@ -2383,3 +2383,19 @@ msgstr "If enabled, a control team is automatically assigned to interventions fo msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge." msgstr "Maintenance types handled by this team (corrective, preventive, ameliorative). If none is selected, all types are supported." +#: contracts/models.py:415 +msgid "Zone géographique / Emprise du lot" +msgstr "Geographic zone / Lot scope" + +#: contracts/models.py:416 +msgid "Emprise géographique multi-polygone pour le lot/contrat." +msgstr "Multi-polygon geographic footprint for the lot/contract." + +#: contracts/models.py:423 +msgid "Couleur cartographique" +msgstr "Map color" + +#: contracts/models.py:424 +msgid "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)." +msgstr "Display color of the lot on the map (hexadecimal code, e.g. #28a745)." + diff --git a/loko/contracts/locale/fr/LC_MESSAGES/django.mo b/loko/contracts/locale/fr/LC_MESSAGES/django.mo index 271a5ba..4c62d55 100644 Binary files a/loko/contracts/locale/fr/LC_MESSAGES/django.mo and b/loko/contracts/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/contracts/locale/fr/LC_MESSAGES/django.po b/loko/contracts/locale/fr/LC_MESSAGES/django.po index 7b1bad4..417a16d 100644 --- a/loko/contracts/locale/fr/LC_MESSAGES/django.po +++ b/loko/contracts/locale/fr/LC_MESSAGES/django.po @@ -2433,3 +2433,19 @@ msgstr "Si activé, une équipe de contrôle est automatiquement assignée aux i msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge." msgstr "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge." +#: contracts/models.py:415 +msgid "Zone géographique / Emprise du lot" +msgstr "Zone géographique / Emprise du lot" + +#: contracts/models.py:416 +msgid "Emprise géographique multi-polygone pour le lot/contrat." +msgstr "Emprise géographique multi-polygone pour le lot/contrat." + +#: contracts/models.py:423 +msgid "Couleur cartographique" +msgstr "Couleur cartographique" + +#: contracts/models.py:424 +msgid "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)." +msgstr "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)." + diff --git a/loko/contracts/locale/nl/LC_MESSAGES/django.mo b/loko/contracts/locale/nl/LC_MESSAGES/django.mo index 43dec45..c738f01 100644 Binary files a/loko/contracts/locale/nl/LC_MESSAGES/django.mo and b/loko/contracts/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/contracts/locale/nl/LC_MESSAGES/django.po b/loko/contracts/locale/nl/LC_MESSAGES/django.po index 64bdac2..1433fbe 100644 --- a/loko/contracts/locale/nl/LC_MESSAGES/django.po +++ b/loko/contracts/locale/nl/LC_MESSAGES/django.po @@ -2389,3 +2389,19 @@ msgstr "Indien geactiveerd wordt automatisch een controleteam toegewezen aan de msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge." msgstr "Onderhoudstypes ondersteund door dit team (correctief, preventief, verbeterend). Indien niets is geselecteerd, worden alle types ondersteund." +#: contracts/models.py:415 +msgid "Zone géographique / Emprise du lot" +msgstr "Geografische zone / Omvang van het perceel" + +#: contracts/models.py:416 +msgid "Emprise géographique multi-polygone pour le lot/contrat." +msgstr "Geografische multi-polygoondekking voor het perceel/contract." + +#: contracts/models.py:423 +msgid "Couleur cartographique" +msgstr "Kaartkleur" + +#: contracts/models.py:424 +msgid "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)." +msgstr "Weergavekleur van het perceel op de kaart (hexadecimale code, bijv. #28a745)." + diff --git a/loko/contracts/migrations/0014_contract_color_contract_geom.py b/loko/contracts/migrations/0014_contract_color_contract_geom.py new file mode 100644 index 0000000..a2256ee --- /dev/null +++ b/loko/contracts/migrations/0014_contract_color_contract_geom.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.7 on 2026-10-05 12:08 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contracts', '0013_companyteam_maintain_types_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='contract', + name='color', + field=models.CharField(blank=True, default='#28a745', help_text="Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745).", max_length=20, null=True, verbose_name='Couleur cartographique'), + ), + migrations.AddField( + model_name='contract', + name='geom', + field=django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, help_text='Emprise géographique multi-polygone pour le lot/contrat.', null=True, srid=3812, verbose_name='Zone géographique / Emprise du lot'), + ), + ] diff --git a/loko/contracts/models.py b/loko/contracts/models.py index c455b63..d949b2b 100644 --- a/loko/contracts/models.py +++ b/loko/contracts/models.py @@ -408,6 +408,33 @@ class Contract(models.Model): def municipality(self): """Retourne la première commune associée (compatibilité ascendante).""" return self.municipalities.first() + geom = gis_models.MultiPolygonField( + srid=3812, + null=True, + blank=True, + verbose_name=_("Zone géographique / Emprise du lot"), + help_text=_("Emprise géographique multi-polygone pour le lot/contrat.") + ) + color = models.CharField( + max_length=20, + default="#28a745", + blank=True, + null=True, + verbose_name=_("Couleur cartographique"), + help_text=_("Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745).") + ) + + @property + def geojson_geometry(self): + """Retourne la géométrie GeoJSON sous forme de dictionnaire ou None.""" + if self.geom: + import json + try: + return json.loads(self.geom.geojson) + except Exception: + return None + return None + is_active = models.BooleanField( default=True, verbose_name=_("Actif"), diff --git a/loko/contracts/tests.py b/loko/contracts/tests.py index 9d21ca2..575f3d3 100644 --- a/loko/contracts/tests.py +++ b/loko/contracts/tests.py @@ -4,7 +4,7 @@ from django.utils import timezone from django.urls import reverse import json -from contracts.models import Company, CompanyTeam, CompanyMember, CompanyMemberHistory +from contracts.models import Company, CompanyTeam, CompanyMember, CompanyMemberHistory, Contract from common.models import UserConfig, Role User = get_user_model() @@ -460,3 +460,61 @@ class ContractOrderDetailControllerDisplayTests(TestCase): self.assertNotContains(response, 'data-field="assigned_controller"') self.assertNotContains(response, 'Contrôleur assigné') + + +class ContractGeometryTests(TestCase): + def setUp(self): + self.company = Company.objects.create(name="ETA Environnement") + + def test_contract_with_donut_geometry_and_color(self): + """Test that Contract accepts a MultiPolygon with interior ring (donut hole) and color.""" + from django.contrib.gis.geos import Polygon, MultiPolygon, Point + from datetime import date + + outer_ring = ((0, 0), (100, 0), (100, 100), (0, 100), (0, 0)) + hole = ((20, 20), (40, 20), (40, 40), (20, 40), (20, 20)) + poly_with_hole = Polygon(outer_ring, hole) + multi_poly = MultiPolygon([poly_with_hole], srid=3812) + + contract = Contract.objects.create( + company=self.company, + contract_number="CTR-ETA-LOT1", + start_date=date(2026, 1, 1), + end_date=date(2028, 12, 31), + geom=multi_poly, + color="#28a745", + ) + + self.assertEqual(contract.color, "#28a745") + self.assertIsNotNone(contract.geom) + self.assertEqual(contract.geom.srid, 3812) + self.assertEqual(contract.geom.geom_type, "MultiPolygon") + + # GeoJSON property + geojson = contract.geojson_geometry + self.assertIsNotNone(geojson) + self.assertEqual(geojson["type"], "MultiPolygon") + self.assertEqual(len(geojson["coordinates"]), 1) + self.assertEqual(len(geojson["coordinates"][0]), 2) + + # Spatial queries with hole + pt_inside = Point(10, 10, srid=3812) + pt_in_hole = Point(30, 30, srid=3812) + + self.assertTrue(Contract.objects.filter(pk=contract.pk, geom__intersects=pt_inside).exists()) + self.assertFalse(Contract.objects.filter(pk=contract.pk, geom__intersects=pt_in_hole).exists()) + + def test_contract_admin_has_geom(self): + """Test ContractAdmin has_geom method.""" + from django.contrib.admin.sites import AdminSite + from contracts.admin import ContractAdmin + from datetime import date + + admin_instance = ContractAdmin(Contract, AdminSite()) + contract_without_geom = Contract.objects.create( + company=self.company, + contract_number="CTR-NOGEOM", + start_date=date(2026, 1, 1), + end_date=date(2028, 12, 31), + ) + self.assertFalse(admin_instance.has_geom(contract_without_geom))