diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py
index f91b7c2..a0c641f 100644
--- a/loko/contracts/admin.py
+++ b/loko/contracts/admin.py
@@ -126,9 +126,9 @@ class CompanyAdmin(admin.ModelAdmin):
@admin.register(Contract)
class ContractAdmin(admin.ModelAdmin):
- list_display = ('contract_number', 'description', 'company', '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', 'thematics_list')
+ list_display = ('contract_number', 'description', 'company', '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', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'send_daily_summary')
+ list_filter = ('is_blocked', 'is_active', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'send_daily_summary')
filter_horizontal = ('thematics',)
def get_inlines(self, request, obj=None):
@@ -154,7 +154,7 @@ class ContractAdmin(admin.ModelAdmin):
),
}),
('Options de workflow', {
- 'fields': ('needs_approval', 'approval_except_urgent', 'needs_checking', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'warn_if_no_prestation_on_processed', 'allow_team_members_edit'),
+ 'fields': ('needs_approval', 'approval_except_urgent', 'needs_checking', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'warn_if_no_prestation_on_processed', 'allow_team_members_edit', 'auto_assign_control_team'),
}),
)
diff --git a/loko/contracts/locale/en/LC_MESSAGES/django.mo b/loko/contracts/locale/en/LC_MESSAGES/django.mo
index c8314f5..bf73ab1 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 2769536..29d547d 100644
--- a/loko/contracts/locale/en/LC_MESSAGES/django.po
+++ b/loko/contracts/locale/en/LC_MESSAGES/django.po
@@ -2362,3 +2362,12 @@ msgstr "Target date cannot be in the past."
#~ msgid "Confirmer le changement immédiat"
#~ msgstr "Confirm immediate change"
+
+#: contracts/models.py:435
+msgid "Assigner automatiquement une équipe de contrôle"
+msgstr "Automatically assign a control team"
+
+#: contracts/models.py:436
+msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."
+msgstr "If enabled, a control team is automatically assigned to corrective interventions for this contract."
+
diff --git a/loko/contracts/locale/fr/LC_MESSAGES/django.mo b/loko/contracts/locale/fr/LC_MESSAGES/django.mo
index fba0a22..0d49398 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 a8f0ad4..ac0c7a3 100644
--- a/loko/contracts/locale/fr/LC_MESSAGES/django.po
+++ b/loko/contracts/locale/fr/LC_MESSAGES/django.po
@@ -2412,3 +2412,12 @@ msgstr "La date cible ne peut pas être dans le passé."
#~ msgid "Confirmer le changement immédiat"
#~ msgstr "Confirmer le changement immédiat"
+
+#: contracts/models.py:435
+msgid "Assigner automatiquement une équipe de contrôle"
+msgstr "Assigner automatiquement une équipe de contrôle"
+
+#: contracts/models.py:436
+msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."
+msgstr "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."
+
diff --git a/loko/contracts/locale/nl/LC_MESSAGES/django.mo b/loko/contracts/locale/nl/LC_MESSAGES/django.mo
index d84dfca..13b871d 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 c6d018e..d97110e 100644
--- a/loko/contracts/locale/nl/LC_MESSAGES/django.po
+++ b/loko/contracts/locale/nl/LC_MESSAGES/django.po
@@ -2368,3 +2368,12 @@ msgstr "De doeldatum kan niet in het verleden liggen."
#~ msgid "Confirmer le changement immédiat"
#~ msgstr "Onmiddellijke wijziging bevestigen"
+
+#: contracts/models.py:435
+msgid "Assigner automatiquement une équipe de contrôle"
+msgstr "Automatisch een controleteam toewijzen"
+
+#: contracts/models.py:436
+msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."
+msgstr "Indien geactiveerd wordt automatisch een controleteam toegewezen aan de correctieve interventies van dit contract."
+
diff --git a/loko/contracts/migrations/0009_contract_auto_assign_control_team.py b/loko/contracts/migrations/0009_contract_auto_assign_control_team.py
new file mode 100644
index 0000000..c2f2a32
--- /dev/null
+++ b/loko/contracts/migrations/0009_contract_auto_assign_control_team.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.7 on 2026-09-19 11:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contracts', '0008_companyteam_geom_companyteam_operation_types'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='contract',
+ name='auto_assign_control_team',
+ field=models.BooleanField(default=False, help_text='Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat.', verbose_name='Assigner automatiquement une équipe de contrôle'),
+ ),
+ ]
diff --git a/loko/contracts/models.py b/loko/contracts/models.py
index c22c95f..ec5aa11 100644
--- a/loko/contracts/models.py
+++ b/loko/contracts/models.py
@@ -432,6 +432,11 @@ class Contract(models.Model):
verbose_name=_("Modification inter-membres d'équipe autorisée"),
help_text=_("Si activé, les membres d'une même équipe peuvent modifier les interventions assignées à d'autres membres de leur équipe, y compris à partir du statut 'Pris en charge' ou au-delà."),
)
+ auto_assign_control_team = models.BooleanField(
+ default=False,
+ verbose_name=_("Assigner automatiquement une équipe de contrôle"),
+ help_text=_("Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."),
+ )
is_blocked = models.BooleanField(
default=False,
verbose_name=_("Marché bloqué / gelé"),
diff --git a/loko/home/locale/en/LC_MESSAGES/django.mo b/loko/home/locale/en/LC_MESSAGES/django.mo
index d9b7964..54a2c1d 100644
Binary files a/loko/home/locale/en/LC_MESSAGES/django.mo and b/loko/home/locale/en/LC_MESSAGES/django.mo differ
diff --git a/loko/home/locale/en/LC_MESSAGES/django.po b/loko/home/locale/en/LC_MESSAGES/django.po
index d46984f..a8bd5ca 100644
--- a/loko/home/locale/en/LC_MESSAGES/django.po
+++ b/loko/home/locale/en/LC_MESSAGES/django.po
@@ -656,10 +656,11 @@ msgstr ""
msgid "En pause"
msgstr ""
+#: home/templates/home/widgets/controller_shortcuts.html:36
#: home/templates/home/widgets/external_interventions.html:84
#: home/templates/home/widgets/external_interventions.html:85
msgid "À corriger"
-msgstr ""
+msgstr "To be corrected"
#: home/templates/home/widgets/external_interventions.html:94
msgid "10 dernières interventions"
@@ -1111,3 +1112,28 @@ msgstr "Loaned items"
#: home/templates/home/widgets/stock_summary.html:235
msgid "Aucun article en prêt en cours"
msgstr "No active loaned items"
+
+#: home/models.py:38
+#: home/templates/home/widgets/controller_shortcuts.html:11
+msgid "Raccourcis contrôleur"
+msgstr "Controller shortcuts"
+
+#: home/templates/home/widgets/controller_shortcuts.html:15
+msgid "Interventions à contrôler"
+msgstr "Interventions to control"
+
+#: home/templates/home/widgets/controller_shortcuts.html:18
+msgid "en attente de contrôle"
+msgstr "pending control"
+
+#: home/templates/home/widgets/controller_shortcuts.html:43
+msgid "Validées"
+msgstr "Validated"
+
+#: home/templates/home/widgets/controller_shortcuts.html:64
+msgid "Aucune intervention à contrôler"
+msgstr "No interventions to control"
+
+#: home/templates/home/widgets/controller_shortcuts.html:72
+msgid "Voir toutes les interventions à contrôler"
+msgstr "View all interventions to control"
diff --git a/loko/home/locale/fr/LC_MESSAGES/django.mo b/loko/home/locale/fr/LC_MESSAGES/django.mo
index 015eabc..5944087 100644
Binary files a/loko/home/locale/fr/LC_MESSAGES/django.mo and b/loko/home/locale/fr/LC_MESSAGES/django.mo differ
diff --git a/loko/home/locale/fr/LC_MESSAGES/django.po b/loko/home/locale/fr/LC_MESSAGES/django.po
index 4e171e3..62d8407 100644
--- a/loko/home/locale/fr/LC_MESSAGES/django.po
+++ b/loko/home/locale/fr/LC_MESSAGES/django.po
@@ -665,10 +665,11 @@ msgstr ""
msgid "En pause"
msgstr ""
+#: home/templates/home/widgets/controller_shortcuts.html:36
#: home/templates/home/widgets/external_interventions.html:84
#: home/templates/home/widgets/external_interventions.html:85
msgid "À corriger"
-msgstr ""
+msgstr "À corriger"
#: home/templates/home/widgets/external_interventions.html:94
msgid "10 dernières interventions"
@@ -1120,3 +1121,28 @@ msgstr "Articles en prêt"
#: home/templates/home/widgets/stock_summary.html:235
msgid "Aucun article en prêt en cours"
msgstr "Aucun article en prêt en cours"
+
+#: home/models.py:38
+#: home/templates/home/widgets/controller_shortcuts.html:11
+msgid "Raccourcis contrôleur"
+msgstr "Raccourcis contrôleur"
+
+#: home/templates/home/widgets/controller_shortcuts.html:15
+msgid "Interventions à contrôler"
+msgstr "Interventions à contrôler"
+
+#: home/templates/home/widgets/controller_shortcuts.html:18
+msgid "en attente de contrôle"
+msgstr "en attente de contrôle"
+
+#: home/templates/home/widgets/controller_shortcuts.html:43
+msgid "Validées"
+msgstr "Validées"
+
+#: home/templates/home/widgets/controller_shortcuts.html:64
+msgid "Aucune intervention à contrôler"
+msgstr "Aucune intervention à contrôler"
+
+#: home/templates/home/widgets/controller_shortcuts.html:72
+msgid "Voir toutes les interventions à contrôler"
+msgstr "Voir toutes les interventions à contrôler"
diff --git a/loko/home/locale/nl/LC_MESSAGES/django.mo b/loko/home/locale/nl/LC_MESSAGES/django.mo
index 4489a08..24b7e4e 100644
Binary files a/loko/home/locale/nl/LC_MESSAGES/django.mo and b/loko/home/locale/nl/LC_MESSAGES/django.mo differ
diff --git a/loko/home/locale/nl/LC_MESSAGES/django.po b/loko/home/locale/nl/LC_MESSAGES/django.po
index 69e8302..f19cec0 100644
--- a/loko/home/locale/nl/LC_MESSAGES/django.po
+++ b/loko/home/locale/nl/LC_MESSAGES/django.po
@@ -662,10 +662,11 @@ msgstr ""
msgid "En pause"
msgstr ""
+#: home/templates/home/widgets/controller_shortcuts.html:36
#: home/templates/home/widgets/external_interventions.html:84
#: home/templates/home/widgets/external_interventions.html:85
msgid "À corriger"
-msgstr ""
+msgstr "Te corrigeren"
#: home/templates/home/widgets/external_interventions.html:94
msgid "10 dernières interventions"
@@ -1117,3 +1118,28 @@ msgstr "Uitgeleende artikelen"
#: home/templates/home/widgets/stock_summary.html:235
msgid "Aucun article en prêt en cours"
msgstr "Geen actieve uitgeleende artikelen"
+
+#: home/models.py:38
+#: home/templates/home/widgets/controller_shortcuts.html:11
+msgid "Raccourcis contrôleur"
+msgstr "Snelkoppelingen controleur"
+
+#: home/templates/home/widgets/controller_shortcuts.html:15
+msgid "Interventions à contrôler"
+msgstr "Te controleren interventies"
+
+#: home/templates/home/widgets/controller_shortcuts.html:18
+msgid "en attente de contrôle"
+msgstr "in afwachting van controle"
+
+#: home/templates/home/widgets/controller_shortcuts.html:43
+msgid "Validées"
+msgstr "Gevalideerd"
+
+#: home/templates/home/widgets/controller_shortcuts.html:64
+msgid "Aucune intervention à contrôler"
+msgstr "Geen te controleren interventies"
+
+#: home/templates/home/widgets/controller_shortcuts.html:72
+msgid "Voir toutes les interventions à contrôler"
+msgstr "Alle te controleren interventies bekijken"
diff --git a/loko/home/migrations/0007_alter_homewidget_widget_code.py b/loko/home/migrations/0007_alter_homewidget_widget_code.py
new file mode 100644
index 0000000..e4e6e40
--- /dev/null
+++ b/loko/home/migrations/0007_alter_homewidget_widget_code.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.7 on 2026-09-19 11:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('home', '0006_create_exceptional_transport_widget'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='homewidget',
+ name='widget_code',
+ field=models.CharField(choices=[('intervention_stats', 'Statistiques interventions'), ('contracts_summary', 'Résumé contrats'), ('map_today', 'Carte du jour'), ('operator_shortcuts', 'Raccourcis opérateur'), ('assets_links', 'Liens assets'), ('stock_summary', 'Résumé stock'), ('observer_shortcuts', 'Raccourcis observateur'), ('incident_counter', 'Compteur incidents à traiter'), ('intervention_to_verify', 'Compteur interventions à vérifier'), ('intervention_to_plan', 'Compteur interventions à planifier'), ('intervention_to_approve', 'Compteur interventions à approuver'), ('intervention_checked_to_approve', 'Compteur interventions vérifiées à approuver'), ('intervention_finished', 'Compteur interventions terminées'), ('intervention_on_pause', 'Compteur interventions en pause'), ('external_interventions', 'Interventions externes'), ('stock_actions', 'Actions stock'), ('exceptional_transport', 'Transport exceptionnel'), ('controller_shortcuts', 'Raccourcis contrôleur')], max_length=50, unique=True, verbose_name='Code du widget'),
+ ),
+ ]
diff --git a/loko/home/migrations/0008_create_controller_shortcuts_widget.py b/loko/home/migrations/0008_create_controller_shortcuts_widget.py
new file mode 100644
index 0000000..8cf8daf
--- /dev/null
+++ b/loko/home/migrations/0008_create_controller_shortcuts_widget.py
@@ -0,0 +1,49 @@
+from django.db import migrations
+
+
+def create_controller_shortcuts_widget(apps, schema_editor):
+ HomeWidget = apps.get_model('home', 'HomeWidget')
+ HomeWidgetConditionGroup = apps.get_model('home', 'HomeWidgetConditionGroup')
+ HomeWidgetCondition = apps.get_model('home', 'HomeWidgetCondition')
+ Role = apps.get_model('common', 'Role')
+
+ widget, created = HomeWidget.objects.get_or_create(
+ widget_code='controller_shortcuts',
+ defaults={'is_active': True}
+ )
+
+ if not widget.condition_groups.exists():
+ controller_role = Role.objects.filter(name='controller').first()
+ if controller_role:
+ group = HomeWidgetConditionGroup.objects.create(
+ widget=widget,
+ label="Contrôleurs",
+ order=15,
+ col_width=4,
+ priority=0,
+ is_active=True
+ )
+ HomeWidgetCondition.objects.create(
+ group=group,
+ condition_type='role',
+ role=controller_role,
+ is_active=True,
+ is_negated=False
+ )
+
+
+def remove_controller_shortcuts_widget(apps, schema_editor):
+ HomeWidget = apps.get_model('home', 'HomeWidget')
+ HomeWidget.objects.filter(widget_code='controller_shortcuts').delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('home', '0007_alter_homewidget_widget_code'),
+ ('common', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RunPython(create_controller_shortcuts_widget, remove_controller_shortcuts_widget),
+ ]
diff --git a/loko/home/models.py b/loko/home/models.py
index 6099ace..b0f3a3a 100644
--- a/loko/home/models.py
+++ b/loko/home/models.py
@@ -21,6 +21,7 @@ WIDGET_CODE_CHOICES = [
('external_interventions', _('Interventions externes')),
('stock_actions', _('Actions stock')),
('exceptional_transport', _('Transport exceptionnel')),
+ ('controller_shortcuts', _('Raccourcis contrôleur')),
]
# Condition type choices
diff --git a/loko/home/templates/home/widgets/controller_shortcuts.html b/loko/home/templates/home/widgets/controller_shortcuts.html
new file mode 100644
index 0000000..974a5e3
--- /dev/null
+++ b/loko/home/templates/home/widgets/controller_shortcuts.html
@@ -0,0 +1,72 @@
+{% load i18n %}
+
+{# ========== Widget raccourcis pour les contrôleurs ========== #}
+
+
+
+ {# --- Compteur principal et sous-raccourcis --- #}
+
+
+ {# --- Liste des 5 premières interventions --- #}
+
+
+
+
diff --git a/loko/home/tests.py b/loko/home/tests.py
index d95cc61..d859414 100644
--- a/loko/home/tests.py
+++ b/loko/home/tests.py
@@ -3,7 +3,7 @@ from django.test import TestCase
from datetime import date
from common.models import Role, Thematic, UserConfig, UserThematics
-from contracts.models import Company, CompanyTeam, Contract
+from contracts.models import Company, CompanyTeam, Contract, CompanyMember
from home.models import HomeWidget, HomeWidgetConditionGroup, HomeWidgetCondition
@@ -370,5 +370,75 @@ class HomeWidgetAdminTests(TestCase):
self.assertIn('Group 1', html)
+class ControllerShortcutsWidgetTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user('ctrl_user', password='pwd')
+ self.user_config = UserConfig.objects.create(
+ user=self.user,
+ is_intern=True,
+ limit_interventions_to_contracts=False
+ )
+ self.role_ctrl, _ = Role.objects.get_or_create(name='controller')
+ self.user_config.roles.add(self.role_ctrl)
+
+ self.thematic, _ = Thematic.objects.get_or_create(code='structures', defaults={'name_fr': 'Structures'})
+ self.company = Company.objects.create(name='Société Contrôle')
+ self.team = CompanyTeam.objects.create(
+ name='Équipe Contrôle Test',
+ company=self.company,
+ operation_types=[CompanyTeam.OPERATION_CONTROLS]
+ )
+ self.member = CompanyMember.objects.create(user=self.user, name='Membre Contrôleur')
+ self.member.teams.add(self.team)
+
+ from django.urls import reverse
+ self.reverse = reverse
+
+ def test_controller_shortcuts_widget_context_and_rendering(self):
+ from interventions.models import Intervention
+ itv_processed = Intervention.objects.create(
+ code='ITV-CTRL-001',
+ maintain_type='corrective',
+ thematic=self.thematic,
+ status='processed',
+ assigned_control_team=self.team,
+ assigned_controller=self.member,
+ )
+ itv_corrected = Intervention.objects.create(
+ code='ITV-CTRL-002',
+ maintain_type='corrective',
+ thematic=self.thematic,
+ status='corrected',
+ assigned_control_team=self.team,
+ assigned_controller=self.member,
+ )
+ itv_in_progress = Intervention.objects.create(
+ code='ITV-CTRL-003',
+ maintain_type='corrective',
+ thematic=self.thematic,
+ status='in_progress',
+ assigned_control_team=self.team,
+ assigned_controller=self.member,
+ )
+
+ self.client.force_login(self.user)
+ response = self.client.get(self.reverse('home:index'), HTTP_ACCEPT_LANGUAGE='fr')
+ self.assertEqual(response.status_code, 200)
+
+ # Context counts
+ self.assertEqual(response.context['controller_interventions_count'], 2)
+ ctrl_codes = [itv.code for itv in response.context['controller_interventions_list']]
+ self.assertIn('ITV-CTRL-001', ctrl_codes)
+ self.assertIn('ITV-CTRL-002', ctrl_codes)
+ self.assertNotIn('ITV-CTRL-003', ctrl_codes)
+
+ # Content rendered in HTML
+ self.assertContains(response, 'Interventions à contrôler')
+ self.assertContains(response, 'ITV-CTRL-001')
+ self.assertContains(response, 'ITV-CTRL-002')
+
+
+
diff --git a/loko/home/views.py b/loko/home/views.py
index fd1b5ba..2a6a4c3 100644
--- a/loko/home/views.py
+++ b/loko/home/views.py
@@ -654,6 +654,42 @@ def index(request):
total_finished_count = interventions.filter(status='finished').count()
total_on_pause_count = interventions.filter(status='on_pause').count()
+ # ========== Données widget controller_shortcuts ==========
+ user_role_names = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
+ controller_filter = Q(status__in=['processed', 'corrected'])
+ if user_teams and user_teams.exists():
+ control_team_ids = list(user_teams.values_list('id', flat=True))
+ controller_q = (
+ Q(assigned_control_team_id__in=control_team_ids) |
+ Q(assigned_controller__user=request.user)
+ )
+ if not (user_role_names & {'admin', 'top_manager', 'manager'}):
+ controller_filter &= controller_q
+
+ controller_interventions_qs = interventions.filter(controller_filter)
+ controller_interventions_count = controller_interventions_qs.count()
+ controller_interventions_list = list(
+ controller_interventions_qs
+ .select_related('thematic', 'contract', 'assigned_control_team', 'assigned_team')
+ .order_by('-code')[:5]
+ )
+
+ controller_to_correct_qs = interventions.filter(status='to_be_corrected')
+ if user_teams and user_teams.exists() and not (user_role_names & {'admin', 'top_manager', 'manager'}):
+ controller_to_correct_qs = controller_to_correct_qs.filter(
+ Q(assigned_control_team_id__in=control_team_ids) |
+ Q(assigned_controller__user=request.user)
+ )
+ controller_to_correct_count = controller_to_correct_qs.count()
+
+ controller_validated_qs = interventions.filter(status='validated')
+ if user_teams and user_teams.exists() and not (user_role_names & {'admin', 'top_manager', 'manager'}):
+ controller_validated_qs = controller_validated_qs.filter(
+ Q(assigned_control_team_id__in=control_team_ids) |
+ Q(assigned_controller__user=request.user)
+ )
+ controller_validated_count = controller_validated_qs.count()
+
# ========== Données widget external_interventions ==========
user_company_ids = set()
if user_config.company_id:
@@ -801,6 +837,10 @@ def index(request):
'external_interventions_unfinished_count': external_interventions_unfinished_count,
'external_status_counts': external_status_counts,
'external_interventions_list': external_interventions_list,
+ 'controller_interventions_count': controller_interventions_count,
+ 'controller_interventions_list': controller_interventions_list,
+ 'controller_to_correct_count': controller_to_correct_count,
+ 'controller_validated_count': controller_validated_count,
}
# ── Logging de performance (désactivé) ────────────────────────────────
diff --git a/loko/interventions/admin.py b/loko/interventions/admin.py
index 234932b..1d4401b 100644
--- a/loko/interventions/admin.py
+++ b/loko/interventions/admin.py
@@ -152,8 +152,8 @@ class InterventionSubTypeAdmin(admin.ModelAdmin):
@admin.register(Intervention)
class InterventionAdmin(admin.ModelAdmin):
search_fields = ("code", "title")
- list_filter = ("thematic", "type", "sub_type", "status", "maintain_type")
- raw_id_fields = ("intervention_manager", "sub_type")
+ list_filter = ("thematic", "type", "sub_type", "status", "maintain_type", "assigned_control_team")
+ raw_id_fields = ("intervention_manager", "sub_type", "assigned_team", "assigned_member", "assigned_control_team", "assigned_controller")
change_list_template = "admin/interventions/intervention_changelist.html"
diff --git a/loko/interventions/locale/en/LC_MESSAGES/django.mo b/loko/interventions/locale/en/LC_MESSAGES/django.mo
index 61d8a78..e14ba9a 100644
Binary files a/loko/interventions/locale/en/LC_MESSAGES/django.mo and b/loko/interventions/locale/en/LC_MESSAGES/django.mo differ
diff --git a/loko/interventions/locale/en/LC_MESSAGES/django.po b/loko/interventions/locale/en/LC_MESSAGES/django.po
index 4cc5a4d..75297c6 100644
--- a/loko/interventions/locale/en/LC_MESSAGES/django.po
+++ b/loko/interventions/locale/en/LC_MESSAGES/django.po
@@ -8112,3 +8112,28 @@ msgstr "You cannot modify this intervention."
#~ msgid "Interventions suivies uniquement"
#~ msgstr "Followed interventions only"
+
+#: interventions/models.py:522
+msgid "Équipe de contrôle assignée"
+msgstr "Assigned control team"
+
+#: interventions/models.py:523
+msgid "Équipe chargée du contrôle et de la validation des postes de l'intervention."
+msgstr "Team in charge of checking and validating the intervention posts."
+
+#: interventions/models.py:531
+msgid "Contrôleur assigné"
+msgstr "Assigned controller"
+
+#: interventions/models.py:532
+msgid "Membre de l'équipe de contrôle en charge de la vérification."
+msgstr "Control team member in charge of verification."
+
+#: interventions/templates/interventions/detail/card_info.html:952
+msgid "Contrôle :"
+msgstr "Control:"
+
+#: interventions/views/general.py:1007
+msgid "Équipe de contrôle"
+msgstr "Control team"
+
diff --git a/loko/interventions/locale/fr/LC_MESSAGES/django.mo b/loko/interventions/locale/fr/LC_MESSAGES/django.mo
index 3901836..8ece14c 100644
Binary files a/loko/interventions/locale/fr/LC_MESSAGES/django.mo and b/loko/interventions/locale/fr/LC_MESSAGES/django.mo differ
diff --git a/loko/interventions/locale/fr/LC_MESSAGES/django.po b/loko/interventions/locale/fr/LC_MESSAGES/django.po
index f4b9333..365e51d 100644
--- a/loko/interventions/locale/fr/LC_MESSAGES/django.po
+++ b/loko/interventions/locale/fr/LC_MESSAGES/django.po
@@ -8219,3 +8219,28 @@ msgstr "Vous suivez maintenant cette intervention."
#~ msgid "Interventions suivies uniquement"
#~ msgstr "Interventions suivies uniquement"
+
+#: interventions/models.py:522
+msgid "Équipe de contrôle assignée"
+msgstr "Équipe de contrôle assignée"
+
+#: interventions/models.py:523
+msgid "Équipe chargée du contrôle et de la validation des postes de l'intervention."
+msgstr "Équipe chargée du contrôle et de la validation des postes de l'intervention."
+
+#: interventions/models.py:531
+msgid "Contrôleur assigné"
+msgstr "Contrôleur assigné"
+
+#: interventions/models.py:532
+msgid "Membre de l'équipe de contrôle en charge de la vérification."
+msgstr "Membre de l'équipe de contrôle en charge de la vérification."
+
+#: interventions/templates/interventions/detail/card_info.html:952
+msgid "Contrôle :"
+msgstr "Contrôle :"
+
+#: interventions/views/general.py:1007
+msgid "Équipe de contrôle"
+msgstr "Équipe de contrôle"
+
diff --git a/loko/interventions/locale/nl/LC_MESSAGES/django.mo b/loko/interventions/locale/nl/LC_MESSAGES/django.mo
index 72f4f90..7a7f1a9 100644
Binary files a/loko/interventions/locale/nl/LC_MESSAGES/django.mo and b/loko/interventions/locale/nl/LC_MESSAGES/django.mo differ
diff --git a/loko/interventions/locale/nl/LC_MESSAGES/django.po b/loko/interventions/locale/nl/LC_MESSAGES/django.po
index 1a7ac81..9e26ef7 100644
--- a/loko/interventions/locale/nl/LC_MESSAGES/django.po
+++ b/loko/interventions/locale/nl/LC_MESSAGES/django.po
@@ -8153,3 +8153,28 @@ msgstr "U hebt geen toestemming om deze interventie te wijzigen."
#~ msgid "Interventions suivies uniquement"
#~ msgstr "Enkel gevolgde interventies"
+
+#: interventions/models.py:522
+msgid "Équipe de contrôle assignée"
+msgstr "Toegewezen controleteam"
+
+#: interventions/models.py:523
+msgid "Équipe chargée du contrôle et de la validation des postes de l'intervention."
+msgstr "Team belast met de controle en validatie van de posten van de interventie."
+
+#: interventions/models.py:531
+msgid "Contrôleur assigné"
+msgstr "Toegewezen controleur"
+
+#: interventions/models.py:532
+msgid "Membre de l'équipe de contrôle en charge de la vérification."
+msgstr "Lid van het controleteam belast met de controle."
+
+#: interventions/templates/interventions/detail/card_info.html:952
+msgid "Contrôle :"
+msgstr "Controle:"
+
+#: interventions/views/general.py:1007
+msgid "Équipe de contrôle"
+msgstr "Controleteam"
+
diff --git a/loko/interventions/migrations/0013_intervention_assigned_control_team_and_more.py b/loko/interventions/migrations/0013_intervention_assigned_control_team_and_more.py
new file mode 100644
index 0000000..a1d6753
--- /dev/null
+++ b/loko/interventions/migrations/0013_intervention_assigned_control_team_and_more.py
@@ -0,0 +1,25 @@
+# Generated by Django 6.0.7 on 2026-09-19 11:56
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contracts', '0009_contract_auto_assign_control_team'),
+ ('interventions', '0012_interventionsubtype_concerned_categories_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='intervention',
+ name='assigned_control_team',
+ field=models.ForeignKey(blank=True, help_text="Équipe chargée du contrôle et de la validation des postes de l'intervention.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='controlled_interventions', to='contracts.companyteam', verbose_name='Équipe de contrôle assignée'),
+ ),
+ migrations.AddField(
+ model_name='intervention',
+ name='assigned_controller',
+ field=models.ForeignKey(blank=True, help_text="Membre de l'équipe de contrôle en charge de la vérification.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='controlled_interventions', to='contracts.companymember', verbose_name='Contrôleur assigné'),
+ ),
+ ]
diff --git a/loko/interventions/models.py b/loko/interventions/models.py
index 7e21850..e4bd436 100644
--- a/loko/interventions/models.py
+++ b/loko/interventions/models.py
@@ -514,6 +514,24 @@ class Intervention(models.Model):
assigned_provider = models.ForeignKey(Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_interventions")
assigned_team = models.ForeignKey(CompanyTeam, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_interventions")
assigned_member = models.ForeignKey(CompanyMember, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_interventions")
+ assigned_control_team = models.ForeignKey(
+ CompanyTeam,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="controlled_interventions",
+ verbose_name=_("Équipe de contrôle assignée"),
+ help_text=_("Équipe chargée du contrôle et de la validation des postes de l'intervention.")
+ )
+ assigned_controller = models.ForeignKey(
+ CompanyMember,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="controlled_interventions",
+ verbose_name=_("Contrôleur assigné"),
+ help_text=_("Membre de l'équipe de contrôle en charge de la vérification.")
+ )
osiris = models.IntegerField(null=True, blank=True)
linked_folder = models.CharField(max_length=1024, null=True, blank=True)
intervention_sheet = models.FileField(upload_to=intervention_sheet_upload_path, storage=private_storage, null=True, blank=True)
@@ -634,6 +652,26 @@ class Intervention(models.Model):
user_config = UserConfig.objects.filter(user_id=self.created_by_id).first()
if user_config and not user_config.is_intern:
self.created_by_provider = True
+
+ # Attribution automatique de l'équipe de contrôle pour les interventions correctives Structures
+ if (
+ self.maintain_type == 'corrective'
+ and self.thematic and getattr(self.thematic, 'code', None) == 'structures'
+ and self.contract and getattr(self.contract, 'auto_assign_control_team', False)
+ and not self.assigned_control_team_id
+ ):
+ try:
+ from interventions.services_control_assignment import resolve_control_team_and_member_for_intervention
+ ctrl_team, ctrl_member = resolve_control_team_and_member_for_intervention(self)
+ if ctrl_team:
+ self.assigned_control_team = ctrl_team
+ self.assigned_controller = ctrl_member
+ except Exception:
+ import logging
+ logging.getLogger(__name__).exception(
+ f"Error auto-assigning control team for intervention {self.code}"
+ )
+
super().save(*args, **kwargs)
# Sync daily assignments history
diff --git a/loko/interventions/permissions.py b/loko/interventions/permissions.py
index 2036e30..2ca7b03 100644
--- a/loko/interventions/permissions.py
+++ b/loko/interventions/permissions.py
@@ -312,8 +312,13 @@ def can_view_intervention(user, intervention):
and intervention.assigned_member
and intervention.assigned_member.teams.filter(id__in=company_member.teams.all()).exists()
)
+ is_assigned_to_control_team = bool(
+ intervention.assigned_control_team
+ and intervention.assigned_control_team in company_member.teams.all()
+ )
+ is_assigned_controller = (intervention.assigned_controller == company_member)
- if not (is_assigned_to_user or is_assigned_to_user_team or is_unrestricted_company or is_team_member_edit_allowed):
+ if not (is_assigned_to_user or is_assigned_to_user_team or is_unrestricted_company or is_team_member_edit_allowed or is_assigned_to_control_team or is_assigned_controller):
return False
contract = intervention.contract
@@ -352,10 +357,16 @@ def can_view_intervention(user, intervention):
can_view=True,
).exists()
+ # -- Accès contrôleur assigné / équipe de contrôle assignée --
+ if company_member and (
+ intervention.assigned_controller == company_member
+ or bool(intervention.assigned_control_team and intervention.assigned_control_team in company_member.teams.all())
+ ):
+ return True
+
# -- Fallback prestataire assigné --
assigned_provider = intervention.assigned_provider
if assigned_provider:
- company_member = CompanyMember.objects.filter(user=user).prefetch_related('teams__company').first()
if company_member:
return company_member.teams.filter(company=assigned_provider).exists()
@@ -405,7 +416,8 @@ def intervention_permission_required(view_func):
def can_control_intervention(user, intervention):
"""
Vérifie si l'utilisateur peut contrôler (valider/corriger) l'intervention.
- Réservé aux utilisateurs internes ayant le droit d'édition sur le statut courant.
+ Réservé aux utilisateurs internes ayant le droit d'édition sur le statut courant
+ ou membres de l'équipe de contrôle assignée / contrôleur assigné.
"""
if intervention.status not in ('processed', 'corrected'):
return False
@@ -418,6 +430,16 @@ def can_control_intervention(user, intervention):
if not user_config.is_intern:
return False
+ # Si l'utilisateur est le contrôleur assigné ou fait partie de l'équipe de contrôle assignée
+ from contracts.models import CompanyMember
+ company_member = CompanyMember.objects.filter(user=user).prefetch_related('teams').first()
+ if company_member:
+ if (
+ intervention.assigned_controller == company_member
+ or (intervention.assigned_control_team and intervention.assigned_control_team in company_member.teams.all())
+ ):
+ return True
+
return can_edit_intervention(user, intervention, user_config=user_config)
@@ -716,13 +738,19 @@ def filter_viewable_interventions_for_user(user, limit_to_default_thematic=False
company_q = (
Q(assigned_member=company_member) |
Q(assigned_team__in=list(company_member.teams.all())) |
- Q(contract__allow_team_members_edit=True, assigned_member__teams__in=list(company_member.teams.all()))
+ Q(contract__allow_team_members_edit=True, assigned_member__teams__in=list(company_member.teams.all())) |
+ Q(assigned_control_team__in=list(company_member.teams.all())) |
+ Q(assigned_controller=company_member)
)
if unrestricted_companies:
company_q |= Q(assigned_member__isnull=True, assigned_team__isnull=True, assigned_provider_id__in=unrestricted_companies)
else:
user_companies = company_member.teams.values_list('company_id', flat=True).distinct()
- company_q = Q(assigned_provider_id__in=user_companies)
+ company_q = (
+ Q(assigned_provider_id__in=user_companies) |
+ Q(assigned_control_team__in=list(company_member.teams.all())) |
+ Q(assigned_controller=company_member)
+ )
if user_config.limit_interventions_to_contracts:
# Limiter aux contrats configurés pour cet utilisateur afin d'exclure
@@ -761,7 +789,9 @@ def filter_viewable_interventions_for_user(user, limit_to_default_thematic=False
assign_filter = (
Q(assigned_member=company_member) |
Q(assigned_team__in=list(company_member.teams.all())) |
- Q(contract__allow_team_members_edit=True, assigned_member__teams__in=list(company_member.teams.all()))
+ Q(contract__allow_team_members_edit=True, assigned_member__teams__in=list(company_member.teams.all())) |
+ Q(assigned_control_team__in=list(company_member.teams.all())) |
+ Q(assigned_controller=company_member)
)
if unrestricted_companies:
assign_filter |= Q(assigned_provider_id__in=unrestricted_companies)
diff --git a/loko/interventions/services_control_assignment.py b/loko/interventions/services_control_assignment.py
new file mode 100644
index 0000000..e81392f
--- /dev/null
+++ b/loko/interventions/services_control_assignment.py
@@ -0,0 +1,154 @@
+import logging
+from django.utils import timezone
+from django.contrib.gis.geos import Point
+
+logger = logging.getLogger(__name__)
+
+
+def resolve_control_team_and_member(
+ target_geom=None,
+ point=None,
+ scheduled_date=None,
+ thematic=None,
+ locations=None,
+):
+ """
+ Détermine l'équipe de contrôle (CompanyTeam) et le contrôleur (CompanyMember) :
+ 1. Équipes candidates gérant l'opération 'controls' (OPERATION_CONTROLS).
+ 2. Filtrage thématique si l'équipe restreint ses thématiques.
+ 3. Résolution spatiale si une géométrie est définie (cible et équipes) :
+ - team.geom.intersects(target_geom) ou team.geom.contains(point).
+ - Fallback sur toutes les équipes candidates si aucune géométrie n'est définie.
+ 4. Calendrier d'assignation à la date scheduled_date :
+ - Évalue le nombre d'agents planifiés et non absents.
+ - Si aucun agent n'est disponible ou aucune date : retourne (None, None).
+ - Sélectionne l'équipe ayant le plus d'agents disponibles.
+ - Sélectionne le premier membre disponible de cette équipe.
+ """
+ from contracts.models import CompanyTeam, CompanyMember
+ from contracts.services_teams import get_teams_composition_for_period, get_member_absences_for_period
+
+ teams_qs = CompanyTeam.objects.all().select_related('company')
+ candidate_teams = [t for t in teams_qs if t.handles_operation(CompanyTeam.OPERATION_CONTROLS)]
+
+ if not candidate_teams:
+ return None, None
+
+ # 1. Filtrage thématique
+ if thematic:
+ filtered_thematic = [
+ t for t in candidate_teams
+ if not t.thematics.exists() or t.thematics.filter(id=thematic.id).exists()
+ ]
+ if filtered_thematic:
+ candidate_teams = filtered_thematic
+
+ # 2. Résolution spatiale si géométrie définie
+ has_target_geom = bool(target_geom or point or locations)
+ teams_with_geom = [t for t in candidate_teams if t.geom is not None]
+
+ if has_target_geom and teams_with_geom:
+ spatial_matches = []
+ for team in teams_with_geom:
+ matched = False
+ if target_geom and team.geom:
+ try:
+ matched = team.geom.intersects(target_geom)
+ except Exception as e:
+ logger.debug(f"Error checking team {team.id} geom intersects: {e}")
+
+ if not matched and point and team.geom:
+ try:
+ matched = team.geom.contains(point)
+ except Exception as e:
+ logger.debug(f"Error checking team {team.id} geom contains point: {e}")
+
+ if not matched and locations and team.geom:
+ for loc in locations:
+ loc_geom = getattr(loc, 'geom', None)
+ if loc_geom:
+ try:
+ if team.geom.intersects(loc_geom):
+ matched = True
+ break
+ except Exception:
+ pass
+
+ if matched:
+ spatial_matches.append(team)
+
+ eligible_teams = spatial_matches
+ else:
+ # Fallback sans géométrie : toutes les équipes candidates
+ eligible_teams = candidate_teams
+
+ if not eligible_teams:
+ return None, None
+
+ # 3. Calendrier d'assignation
+ if not scheduled_date:
+ return None, None
+
+ if hasattr(scheduled_date, 'date'):
+ target_d = scheduled_date.date()
+ else:
+ target_d = scheduled_date
+
+ team_ids = {t.id for t in eligible_teams}
+ compositions = get_teams_composition_for_period(team_ids, target_d, target_d)
+
+ all_member_ids = {m['id'] for m_list in compositions.values() for m in m_list}
+ absences = get_member_absences_for_period(all_member_ids, target_d, target_d) if all_member_ids else {}
+
+ scored_teams = []
+ for team in eligible_teams:
+ day_members = compositions.get((team.id, target_d), [])
+ available_members = [
+ m for m in day_members
+ if (m['id'], target_d) not in absences
+ ]
+ scored_teams.append((len(available_members), team, available_members))
+
+ scored_teams.sort(key=lambda item: (-item[0], item[1].name))
+ best_len, best_team, best_members = scored_teams[0]
+
+ # Règle stricte : si aucun agent disponible ce jour-là, ne pas assigner
+ if best_len == 0 or not best_members:
+ return None, None
+
+ best_member_id = best_members[0]['id']
+ best_member = CompanyMember.objects.filter(id=best_member_id).first()
+
+ return best_team, best_member
+
+
+def resolve_control_team_and_member_for_intervention(intervention):
+ """
+ Extrait la géométrie, la localisation et la date de l'intervention
+ puis appelle resolve_control_team_and_member.
+ """
+ point = None
+ if intervention.lon and intervention.lat:
+ try:
+ p = Point(float(intervention.lon), float(intervention.lat), srid=4326)
+ p.transform(3812)
+ point = p
+ except Exception as e:
+ logger.debug(f"Error transforming intervention coordinates to 3812: {e}")
+
+ target_date = (
+ intervention.planned_begin_time
+ or intervention.begin_time
+ or intervention.creation_time
+ or timezone.now().date()
+ )
+
+ locs = list(intervention.locations.all()) if (intervention.pk and hasattr(intervention, 'locations')) else []
+
+ return resolve_control_team_and_member(
+ target_geom=intervention.geom,
+ point=point,
+ scheduled_date=target_date,
+ thematic=intervention.thematic,
+ locations=locs,
+ )
diff --git a/loko/interventions/templates/interventions/detail/card_info.html b/loko/interventions/templates/interventions/detail/card_info.html
index 85a009e..b589146 100644
--- a/loko/interventions/templates/interventions/detail/card_info.html
+++ b/loko/interventions/templates/interventions/detail/card_info.html
@@ -946,6 +946,20 @@
✓
+ {% if intervention.assigned_control_team or intervention.assigned_controller %}
+
+
+ {% translate "Contrôle :" %}
+
+ {% if intervention.assigned_controller %}
+ {{ intervention.assigned_controller.name }}
+ {% if intervention.assigned_control_team %}({{ intervention.assigned_control_team.name }}){% endif %}
+ {% elif intervention.assigned_control_team %}
+ {{ intervention.assigned_control_team.name }}
+ {% endif %}
+
+
+ {% endif %}
diff --git a/loko/interventions/tests/test_control_assignment.py b/loko/interventions/tests/test_control_assignment.py
new file mode 100644
index 0000000..6ccab79
--- /dev/null
+++ b/loko/interventions/tests/test_control_assignment.py
@@ -0,0 +1,208 @@
+from datetime import date, timedelta
+from django.test import TestCase, RequestFactory
+from django.contrib.auth import get_user_model
+from django.utils import timezone
+from django.contrib.gis.geos import MultiPolygon, Polygon, Point
+
+from common.models import Role, UserConfig, Thematic, UserContractAccess
+from contracts.models import (
+ Company, CompanyTeam, CompanyMember, CompanyMemberHistory, CompanyMemberAbsence, Contract
+)
+from interventions.models import Intervention, InterventionContractPost, ContractPost
+from interventions.permissions import can_view_intervention, can_control_intervention
+from interventions.services_control_assignment import (
+ resolve_control_team_and_member,
+ resolve_control_team_and_member_for_intervention,
+)
+
+
+class ControlAssignmentTests(TestCase):
+ def setUp(self):
+ self.User = get_user_model()
+ self.role_controller, _ = Role.objects.get_or_create(name='controller')
+ self.role_tech, _ = Role.objects.get_or_create(name='technician')
+
+ # Thématiques
+ self.thematic_structures, _ = Thematic.objects.get_or_create(
+ code='structures', defaults={'name_fr': 'Ouvrages d\'art', 'name_nl': 'Kunstwerken'}
+ )
+ self.thematic_roads, _ = Thematic.objects.get_or_create(
+ code='roads', defaults={'name_fr': 'Voirie', 'name_nl': 'Wegen'}
+ )
+
+ # Société & Prestataire
+ self.company = Company.objects.create(name='Prestataire Structures')
+ self.org_intern = Company.objects.create(name='Organisme Interne Contrôle')
+
+ # Équipes opérationnelles terrain
+ self.team_ops = CompanyTeam.objects.create(
+ name='Équipe Opérationnelle',
+ company=self.company,
+ operation_types=[CompanyTeam.OPERATION_INTERVENTIONS]
+ )
+
+ # Équipe de contrôle avec géométrie (SRID 3812)
+ # Bbox approximative autour de Bruxelles en Lambert 72
+ poly = Polygon(((140000, 160000), (160000, 160000), (160000, 180000), (140000, 180000), (140000, 160000)))
+ self.ctrl_geom = MultiPolygon([poly], srid=3812)
+ self.team_ctrl = CompanyTeam.objects.create(
+ name='Équipe Contrôle Structures',
+ company=self.org_intern,
+ operation_types=[CompanyTeam.OPERATION_CONTROLS],
+ geom=self.ctrl_geom
+ )
+
+ # Membre contrôleur
+ self.user_ctrl = self.User.objects.create_user(username='ctrl_agent', password='pwd')
+ self.config_ctrl = UserConfig.objects.create(user=self.user_ctrl, is_intern=True)
+ self.config_ctrl.roles.add(self.role_controller)
+ self.member_ctrl = CompanyMember.objects.create(user=self.user_ctrl, name='Agent Contrôle 1')
+ self.member_ctrl.teams.add(self.team_ctrl)
+
+ # Planification du contrôleur dans l'historique
+ self.today = timezone.now().date()
+ CompanyMemberHistory.objects.create(
+ member=self.member_ctrl,
+ team=self.team_ctrl,
+ join_date=self.today - timedelta(days=30),
+ leave_date=None
+ )
+
+ # Contrat avec attribution automatique du contrôle
+ self.contract_auto = Contract.objects.create(
+ company=self.company,
+ contract_number='C-STRUCT-AUTO',
+ start_date=self.today - timedelta(days=100),
+ end_date=self.today + timedelta(days=100),
+ is_active=True,
+ auto_assign_control_team=True,
+ )
+ self.contract_auto.thematics.add(self.thematic_structures)
+
+ # Contrat SANS attribution automatique
+ self.contract_no_auto = Contract.objects.create(
+ company=self.company,
+ contract_number='C-STRUCT-NOAUTO',
+ start_date=self.today - timedelta(days=100),
+ end_date=self.today + timedelta(days=100),
+ is_active=True,
+ auto_assign_control_team=False,
+ )
+ self.contract_no_auto.thematics.add(self.thematic_structures)
+
+ def test_auto_assignment_structures_corrective(self):
+ """Vérifie l'attribution automatique pour une intervention corrective Structures avec contrat activé."""
+ # Coordonnées au centre de la zone (Bruxelles ~ 4.35, 50.85 -> en Lambert 72 ~ 150000, 170000)
+ p_poly = Polygon(((149000, 169000), (151000, 169000), (151000, 171000), (149000, 171000), (149000, 169000)))
+ geom = MultiPolygon([p_poly], srid=3812)
+
+ itv = Intervention.objects.create(
+ maintain_type='corrective',
+ thematic=self.thematic_structures,
+ contract=self.contract_auto,
+ geom=geom,
+ planned_begin_time=timezone.now(),
+ )
+
+ self.assertEqual(itv.assigned_control_team, self.team_ctrl)
+ self.assertEqual(itv.assigned_controller, self.member_ctrl)
+
+ def test_no_auto_assignment_if_not_corrective(self):
+ """Une intervention préventive ne doit pas recevoir d'attribution automatique de contrôle."""
+ itv = Intervention.objects.create(
+ maintain_type='preventive',
+ thematic=self.thematic_structures,
+ contract=self.contract_auto,
+ planned_begin_time=timezone.now(),
+ )
+ self.assertIsNone(itv.assigned_control_team)
+ self.assertIsNone(itv.assigned_controller)
+
+ def test_no_auto_assignment_if_not_structures(self):
+ """Une intervention d'une autre thématique ne doit pas recevoir d'attribution automatique de contrôle."""
+ itv = Intervention.objects.create(
+ maintain_type='corrective',
+ thematic=self.thematic_roads,
+ contract=self.contract_auto,
+ planned_begin_time=timezone.now(),
+ )
+ self.assertIsNone(itv.assigned_control_team)
+ self.assertIsNone(itv.assigned_controller)
+
+ def test_no_auto_assignment_if_contract_not_enabled(self):
+ """Un contrat sans auto_assign_control_team ne doit pas déclencher l'attribution."""
+ itv = Intervention.objects.create(
+ maintain_type='corrective',
+ thematic=self.thematic_structures,
+ contract=self.contract_no_auto,
+ planned_begin_time=timezone.now(),
+ )
+ self.assertIsNone(itv.assigned_control_team)
+ self.assertIsNone(itv.assigned_controller)
+
+ def test_fallback_calendar_without_geometry(self):
+ """Si aucune géométrie n'est définie, le fallback calendrier doit sélectionner l'équipe candidate."""
+ itv = Intervention.objects.create(
+ maintain_type='corrective',
+ thematic=self.thematic_structures,
+ contract=self.contract_auto,
+ geom=None,
+ planned_begin_time=timezone.now(),
+ )
+ self.assertEqual(itv.assigned_control_team, self.team_ctrl)
+ self.assertEqual(itv.assigned_controller, self.member_ctrl)
+
+ def test_strict_fallback_when_no_member_available(self):
+ """Si le contrôleur est absent à la date cible et aucun autre agent disponible, attribue (None, None)."""
+ target_date = self.today + timedelta(days=5)
+ # Enregistrer une absence
+ CompanyMemberAbsence.objects.create(
+ member=self.member_ctrl,
+ date=target_date,
+ reason='conge'
+ )
+
+ team, member = resolve_control_team_and_member(
+ target_geom=self.ctrl_geom,
+ scheduled_date=target_date,
+ thematic=self.thematic_structures
+ )
+ self.assertIsNone(team)
+ self.assertIsNone(member)
+
+ def test_does_not_overwrite_manual_control_assignment(self):
+ """Ne doit pas écraser une équipe de contrôle déjà assignée manuellement."""
+ team_other = CompanyTeam.objects.create(
+ name='Autre Contrôle',
+ company=self.org_intern,
+ operation_types=[CompanyTeam.OPERATION_CONTROLS]
+ )
+ itv = Intervention.objects.create(
+ maintain_type='corrective',
+ thematic=self.thematic_structures,
+ contract=self.contract_auto,
+ assigned_control_team=team_other,
+ planned_begin_time=timezone.now(),
+ )
+ self.assertEqual(itv.assigned_control_team, team_other)
+
+ def test_controller_permissions_and_control(self):
+ """Le contrôleur assigné doit pouvoir visualiser et contrôler l'intervention au statut processed."""
+ itv = Intervention.objects.create(
+ maintain_type='corrective',
+ thematic=self.thematic_structures,
+ contract=self.contract_auto,
+ status='processed',
+ assigned_control_team=self.team_ctrl,
+ assigned_controller=self.member_ctrl,
+ )
+
+ # Permissions de vue
+ self.assertTrue(can_view_intervention(self.user_ctrl, itv))
+ # Permissions de contrôle (statut processed)
+ self.assertTrue(can_control_intervention(self.user_ctrl, itv))
+
+ # Au statut in_progress, le contrôle n'est pas encore ouvert
+ itv.status = 'in_progress'
+ itv.save(update_fields=['status'])
+ self.assertFalse(can_control_intervention(self.user_ctrl, itv))
diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py
index 6af23cb..214dc35 100644
--- a/loko/interventions/views/detail.py
+++ b/loko/interventions/views/detail.py
@@ -2480,7 +2480,14 @@ def intervention_posts_bulk_update(request, intervention_id):
# Vérifier les permissions selon le type de modification
can_update_posts = user_config.roles.filter(name__in=['admin', 'external_manager']).exists()
- can_control_posts = user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists()
+ is_in_control_team = False
+ company_member = CompanyMember.objects.filter(user=request.user).first()
+ if company_member:
+ is_in_control_team = (
+ intervention.assigned_controller == company_member
+ or bool(intervention.assigned_control_team and intervention.assigned_control_team in company_member.teams.all())
+ )
+ can_control_posts = user_config.roles.filter(name__in=['admin', 'manager', 'controller']).exists() or is_in_control_team
updated_count = 0
errors = []
diff --git a/loko/interventions/views/general.py b/loko/interventions/views/general.py
index c214d3d..b10f5f8 100644
--- a/loko/interventions/views/general.py
+++ b/loko/interventions/views/general.py
@@ -414,6 +414,8 @@ def index(request):
'selected_contracts': request.GET.getlist('contract'),
'selected_teams': request.GET.getlist('assigned_team'),
'selected_members': request.GET.getlist('assigned_member'),
+ 'selected_control_teams': request.GET.getlist('assigned_control_team'),
+ 'selected_controllers': request.GET.getlist('assigned_controller'),
'selected_pause_reasons': pause_reasons,
'selected_preparation_states': preparation_states,
'begin_date_min': begin_date_min or '',
@@ -465,6 +467,8 @@ def interventions_geojson(request):
codes = _expand_csv_params(request.GET.getlist('code') + request.GET.getlist('codes'))
assigned_teams = _expand_csv_params(request.GET.getlist('assigned_team'))
assigned_members = _expand_csv_params(request.GET.getlist('assigned_member'))
+ assigned_control_teams = _expand_csv_params(request.GET.getlist('assigned_control_team'))
+ assigned_controllers = _expand_csv_params(request.GET.getlist('assigned_controller'))
pause_reasons = _expand_csv_params(request.GET.getlist('pause_reason'))
intervention_managers = _expand_csv_params(request.GET.getlist('intervention_manager'))
preparation_states = _expand_csv_params(request.GET.getlist('preparation_state'))
@@ -545,6 +549,10 @@ def interventions_geojson(request):
qs = qs.filter(assigned_member_id__in=assigned_members)
if assigned_teams:
qs = qs.filter(Q(assigned_team_id__in=assigned_teams) | Q(assigned_member__teams__in=assigned_teams)).distinct()
+ if assigned_controllers:
+ qs = qs.filter(assigned_controller_id__in=assigned_controllers)
+ if assigned_control_teams:
+ qs = qs.filter(Q(assigned_control_team_id__in=assigned_control_teams) | Q(assigned_controller__teams__in=assigned_control_teams)).distinct()
if intervention_managers:
qs = qs.filter(intervention_manager_id__in=intervention_managers)
if followed_only == '1' and request.user.is_authenticated:
@@ -1007,6 +1015,8 @@ def export_interventions(request):
(_("Fournisseur assigné"), lambda i: (i.assigned_provider.name if i.assigned_provider else '')),
(_("Equipe assignée"), lambda i: (i.assigned_team.name if i.assigned_team else '')),
(_("Membre assigné"), lambda i: (i.assigned_member.name if i.assigned_member else '')),
+ (_("Équipe de contrôle"), lambda i: (i.assigned_control_team.name if i.assigned_control_team else '')),
+ (_("Contrôleur assigné"), lambda i: (i.assigned_controller.name if i.assigned_controller else '')),
(_("Contrat"), lambda i: (i.contract.contract_number if i.contract else '')),
(_("Commande"), lambda i: (i.order.order_code if i.order else '')),
(_("Début prévu"), lambda i: dj_format_date(i.expected_begin_time, 'Y-m-d H:i') if i.expected_begin_time else ''),