feat: add geographic polygon and color fields to Contract model and admin

This commit is contained in:
kdeterme 2026-10-05 14:19:07 +02:00
parent 69f5e65495
commit f81ca93590
10 changed files with 174 additions and 5 deletions

View file

@ -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):

View file

@ -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)."

View file

@ -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)."

View file

@ -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)."

View file

@ -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'),
),
]

View file

@ -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"),

View file

@ -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))