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 ========== #} +
+
+ + + {% translate "Interventions à contrôler" %} + + + {{ controller_interventions_count }} + +
+ + {# --- Compteur principal et sous-raccourcis --- #} +
+ + {{ controller_interventions_count }} +
{% translate "en attente de contrôle" %}
+
+ +
+ + {# --- 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 %}