feat: add geographic polygon and color fields to Contract model and admin
This commit is contained in:
parent
69f5e65495
commit
f81ca93590
10 changed files with 174 additions and 5 deletions
|
|
@ -2,6 +2,8 @@ import io
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import admin, messages
|
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.http import HttpResponseRedirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import path, reverse
|
from django.urls import path, reverse
|
||||||
|
|
@ -136,8 +138,8 @@ class CompanyAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'contact_email', 'phone_number')
|
list_display = ('name', 'contact_email', 'phone_number')
|
||||||
|
|
||||||
@admin.register(Contract)
|
@admin.register(Contract)
|
||||||
class ContractAdmin(admin.ModelAdmin):
|
class ContractAdmin(gis_admin.GISModelAdmin):
|
||||||
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')
|
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')
|
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')
|
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')
|
filter_horizontal = ('thematics', 'municipalities')
|
||||||
|
|
@ -155,6 +157,12 @@ class ContractAdmin(admin.ModelAdmin):
|
||||||
(None, {
|
(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'),
|
'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', {
|
('Blocage budgétaire / Suspension', {
|
||||||
'fields': ('is_blocked', 'blocked_reason', 'blocked_at'),
|
'fields': ('is_blocked', 'blocked_reason', 'blocked_at'),
|
||||||
'description': (
|
'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:
|
class Media:
|
||||||
css = {
|
css = {
|
||||||
'all': ('contracts/css/contract_admin.css',)
|
'all': ('contracts/css/contract_admin.css',)
|
||||||
|
|
@ -285,8 +298,7 @@ class CompanyTeamMemberInline(admin.TabularInline):
|
||||||
verbose_name_plural = "Membres"
|
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):
|
class CompanyTeamAdminForm(forms.ModelForm):
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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."
|
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."
|
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)."
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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."
|
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."
|
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)."
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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."
|
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."
|
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)."
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -408,6 +408,33 @@ class Contract(models.Model):
|
||||||
def municipality(self):
|
def municipality(self):
|
||||||
"""Retourne la première commune associée (compatibilité ascendante)."""
|
"""Retourne la première commune associée (compatibilité ascendante)."""
|
||||||
return self.municipalities.first()
|
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(
|
is_active = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
verbose_name=_("Actif"),
|
verbose_name=_("Actif"),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from django.utils import timezone
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
import json
|
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
|
from common.models import UserConfig, Role
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
@ -460,3 +460,61 @@ class ContractOrderDetailControllerDisplayTests(TestCase):
|
||||||
self.assertNotContains(response, 'data-field="assigned_controller"')
|
self.assertNotContains(response, 'data-field="assigned_controller"')
|
||||||
self.assertNotContains(response, 'Contrôleur assigné')
|
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))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue