feat: add intervention control team assignment, contract auto-assignment, and controller shortcuts dashboard widget

This commit is contained in:
kdeterme 2026-09-19 14:25:52 +02:00
parent 1ab3c71754
commit 74c1bf2447
36 changed files with 955 additions and 16 deletions

View file

@ -126,9 +126,9 @@ class CompanyAdmin(admin.ModelAdmin):
@admin.register(Contract) @admin.register(Contract)
class ContractAdmin(admin.ModelAdmin): 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') 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',) filter_horizontal = ('thematics',)
def get_inlines(self, request, obj=None): def get_inlines(self, request, obj=None):
@ -154,7 +154,7 @@ class ContractAdmin(admin.ModelAdmin):
), ),
}), }),
('Options de workflow', { ('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'),
}), }),
) )

View file

@ -2362,3 +2362,12 @@ msgstr "Target date cannot be in the past."
#~ msgid "Confirmer le changement immédiat" #~ msgid "Confirmer le changement immédiat"
#~ msgstr "Confirm immediate change" #~ 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."

View file

@ -2412,3 +2412,12 @@ msgstr "La date cible ne peut pas être dans le passé."
#~ msgid "Confirmer le changement immédiat" #~ msgid "Confirmer le changement immédiat"
#~ msgstr "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."

View file

@ -2368,3 +2368,12 @@ msgstr "De doeldatum kan niet in het verleden liggen."
#~ msgid "Confirmer le changement immédiat" #~ msgid "Confirmer le changement immédiat"
#~ msgstr "Onmiddellijke wijziging bevestigen" #~ 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."

View file

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

View file

@ -432,6 +432,11 @@ class Contract(models.Model):
verbose_name=_("Modification inter-membres d'équipe autorisée"), 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à."), 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( is_blocked = models.BooleanField(
default=False, default=False,
verbose_name=_("Marché bloqué / gelé"), verbose_name=_("Marché bloqué / gelé"),

View file

@ -656,10 +656,11 @@ msgstr ""
msgid "En pause" msgid "En pause"
msgstr "" msgstr ""
#: home/templates/home/widgets/controller_shortcuts.html:36
#: home/templates/home/widgets/external_interventions.html:84 #: home/templates/home/widgets/external_interventions.html:84
#: home/templates/home/widgets/external_interventions.html:85 #: home/templates/home/widgets/external_interventions.html:85
msgid "À corriger" msgid "À corriger"
msgstr "" msgstr "To be corrected"
#: home/templates/home/widgets/external_interventions.html:94 #: home/templates/home/widgets/external_interventions.html:94
msgid "10 dernières interventions" msgid "10 dernières interventions"
@ -1111,3 +1112,28 @@ msgstr "Loaned items"
#: home/templates/home/widgets/stock_summary.html:235 #: home/templates/home/widgets/stock_summary.html:235
msgid "Aucun article en prêt en cours" msgid "Aucun article en prêt en cours"
msgstr "No active loaned items" 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"

View file

@ -665,10 +665,11 @@ msgstr ""
msgid "En pause" msgid "En pause"
msgstr "" msgstr ""
#: home/templates/home/widgets/controller_shortcuts.html:36
#: home/templates/home/widgets/external_interventions.html:84 #: home/templates/home/widgets/external_interventions.html:84
#: home/templates/home/widgets/external_interventions.html:85 #: home/templates/home/widgets/external_interventions.html:85
msgid "À corriger" msgid "À corriger"
msgstr "" msgstr "À corriger"
#: home/templates/home/widgets/external_interventions.html:94 #: home/templates/home/widgets/external_interventions.html:94
msgid "10 dernières interventions" msgid "10 dernières interventions"
@ -1120,3 +1121,28 @@ msgstr "Articles en prêt"
#: home/templates/home/widgets/stock_summary.html:235 #: home/templates/home/widgets/stock_summary.html:235
msgid "Aucun article en prêt en cours" msgid "Aucun article en prêt en cours"
msgstr "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"

View file

@ -662,10 +662,11 @@ msgstr ""
msgid "En pause" msgid "En pause"
msgstr "" msgstr ""
#: home/templates/home/widgets/controller_shortcuts.html:36
#: home/templates/home/widgets/external_interventions.html:84 #: home/templates/home/widgets/external_interventions.html:84
#: home/templates/home/widgets/external_interventions.html:85 #: home/templates/home/widgets/external_interventions.html:85
msgid "À corriger" msgid "À corriger"
msgstr "" msgstr "Te corrigeren"
#: home/templates/home/widgets/external_interventions.html:94 #: home/templates/home/widgets/external_interventions.html:94
msgid "10 dernières interventions" msgid "10 dernières interventions"
@ -1117,3 +1118,28 @@ msgstr "Uitgeleende artikelen"
#: home/templates/home/widgets/stock_summary.html:235 #: home/templates/home/widgets/stock_summary.html:235
msgid "Aucun article en prêt en cours" msgid "Aucun article en prêt en cours"
msgstr "Geen actieve uitgeleende artikelen" 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"

View file

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

View file

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

View file

@ -21,6 +21,7 @@ WIDGET_CODE_CHOICES = [
('external_interventions', _('Interventions externes')), ('external_interventions', _('Interventions externes')),
('stock_actions', _('Actions stock')), ('stock_actions', _('Actions stock')),
('exceptional_transport', _('Transport exceptionnel')), ('exceptional_transport', _('Transport exceptionnel')),
('controller_shortcuts', _('Raccourcis contrôleur')),
] ]
# Condition type choices # Condition type choices

View file

@ -0,0 +1,72 @@
{% load i18n %}
{# ========== Widget raccourcis pour les contrôleurs ========== #}
<div class="card shadow-sm h-100" style="border-color: #00897b !important;">
<div class="card-header d-flex align-items-center justify-content-between text-white" style="background-color: #00897b !important;">
<a href="{% url 'interventions:interventions_index' %}?status=processed&status=corrected"
class="text-decoration-none fw-bold text-uppercase text-white d-flex align-items-center" style="letter-spacing:.05em;">
<i class="bi bi-patch-check-fill me-2 fs-5"></i>
<span>{% translate "Interventions à contrôler" %}</span>
</a>
<span class="badge bg-white text-dark rounded-pill px-2 py-1 small fw-semibold">
{{ controller_interventions_count }}
</span>
</div>
{# --- Compteur principal et sous-raccourcis --- #}
<div class="text-center py-2 border-bottom bg-light bg-opacity-25">
<a href="{% url 'interventions:interventions_index' %}?status=processed&status=corrected"
class="text-decoration-none d-block">
<span class="display-5 fw-bold" style="color: #00897b !important;">{{ controller_interventions_count }}</span>
<div class="small text-muted fw-semibold">{% translate "en attente de contrôle" %}</div>
</a>
<div class="d-flex justify-content-center gap-2 mt-2">
<a href="{% url 'interventions:interventions_index' %}?status=to_be_corrected"
class="badge text-decoration-none bg-warning-subtle text-warning-emphasis border border-warning-subtle">
<i class="bi bi-arrow-repeat me-1"></i>{% translate "À corriger" %} ({{ controller_to_correct_count|default:0 }})
</a>
<a href="{% url 'interventions:interventions_index' %}?status=validated"
class="badge text-decoration-none bg-success-subtle text-success-emphasis border border-success-subtle">
<i class="bi bi-check-all me-1"></i>{% translate "Validées" %} ({{ controller_validated_count|default:0 }})
</a>
</div>
</div>
{# --- Liste des 5 premières interventions --- #}
<ul class="list-group list-group-flush flex-grow-1">
{% for itv in controller_interventions_list %}
<li class="list-group-item px-2 py-1 small">
<a href="{% url 'interventions:intervention_detail' itv.id %}" class="text-decoration-none d-block">
<div class="d-flex justify-content-between align-items-start">
<span class="fw-semibold text-truncate me-2" style="max-width:70%; color: #00897b !important;">
{{ itv.code }}
</span>
<span class="text-muted text-nowrap" style="font-size:.75rem;">
{{ itv.last_modified_time|date:"d/m H:i"|default:itv.creation_time|date:"d/m H:i" }}
</span>
</div>
<div class="d-flex justify-content-between align-items-center mt-1">
<span class="text-muted text-truncate" style="font-size:.75rem; max-width: 65%;">
{% if itv.title %}{{ itv.title|truncatechars:45 }}{% endif %}
{% if itv.thematic %} &middot; <span class="badge bg-secondary-subtle text-secondary py-0 px-1">{{ itv.thematic.code }}</span>{% endif %}
</span>
<span class="badge {% if itv.status == 'corrected' %}bg-info-subtle text-info-emphasis border border-info-subtle{% else %}bg-primary-subtle text-primary-emphasis border border-primary-subtle{% endif %} py-0 px-1" style="font-size:.7rem;">
{{ itv.get_status_display }}
</span>
</div>
</a>
</li>
{% empty %}
<li class="list-group-item text-center text-muted py-3 small">
<i class="bi bi-check-circle text-success me-1"></i>{% translate "Aucune intervention à contrôler" %}
</li>
{% endfor %}
</ul>
<div class="card-footer text-center text-muted small bg-light">
<a href="{% url 'interventions:interventions_index' %}?status=processed&status=corrected"
class="text-decoration-none" style="color: #00897b !important; font-weight: 500;">
<i class="bi bi-arrow-right-circle me-1"></i>{% translate "Voir toutes les interventions à contrôler" %}
</a>
</div>
</div>

View file

@ -3,7 +3,7 @@ from django.test import TestCase
from datetime import date from datetime import date
from common.models import Role, Thematic, UserConfig, UserThematics 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 from home.models import HomeWidget, HomeWidgetConditionGroup, HomeWidgetCondition
@ -370,5 +370,75 @@ class HomeWidgetAdminTests(TestCase):
self.assertIn('Group 1', html) 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')

View file

@ -654,6 +654,42 @@ def index(request):
total_finished_count = interventions.filter(status='finished').count() total_finished_count = interventions.filter(status='finished').count()
total_on_pause_count = interventions.filter(status='on_pause').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 ========== # ========== Données widget external_interventions ==========
user_company_ids = set() user_company_ids = set()
if user_config.company_id: if user_config.company_id:
@ -801,6 +837,10 @@ def index(request):
'external_interventions_unfinished_count': external_interventions_unfinished_count, 'external_interventions_unfinished_count': external_interventions_unfinished_count,
'external_status_counts': external_status_counts, 'external_status_counts': external_status_counts,
'external_interventions_list': external_interventions_list, '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é) ──────────────────────────────── # ── Logging de performance (désactivé) ────────────────────────────────

View file

@ -152,8 +152,8 @@ class InterventionSubTypeAdmin(admin.ModelAdmin):
@admin.register(Intervention) @admin.register(Intervention)
class InterventionAdmin(admin.ModelAdmin): class InterventionAdmin(admin.ModelAdmin):
search_fields = ("code", "title") search_fields = ("code", "title")
list_filter = ("thematic", "type", "sub_type", "status", "maintain_type") list_filter = ("thematic", "type", "sub_type", "status", "maintain_type", "assigned_control_team")
raw_id_fields = ("intervention_manager", "sub_type") raw_id_fields = ("intervention_manager", "sub_type", "assigned_team", "assigned_member", "assigned_control_team", "assigned_controller")
change_list_template = "admin/interventions/intervention_changelist.html" change_list_template = "admin/interventions/intervention_changelist.html"

View file

@ -8112,3 +8112,28 @@ msgstr "You cannot modify this intervention."
#~ msgid "Interventions suivies uniquement" #~ msgid "Interventions suivies uniquement"
#~ msgstr "Followed interventions only" #~ 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"

View file

@ -8219,3 +8219,28 @@ msgstr "Vous suivez maintenant cette intervention."
#~ msgid "Interventions suivies uniquement" #~ msgid "Interventions suivies uniquement"
#~ msgstr "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"

View file

@ -8153,3 +8153,28 @@ msgstr "U hebt geen toestemming om deze interventie te wijzigen."
#~ msgid "Interventions suivies uniquement" #~ msgid "Interventions suivies uniquement"
#~ msgstr "Enkel gevolgde interventies" #~ 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"

View file

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

View file

@ -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_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_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_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) osiris = models.IntegerField(null=True, blank=True)
linked_folder = models.CharField(max_length=1024, 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) 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() user_config = UserConfig.objects.filter(user_id=self.created_by_id).first()
if user_config and not user_config.is_intern: if user_config and not user_config.is_intern:
self.created_by_provider = True 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) super().save(*args, **kwargs)
# Sync daily assignments history # Sync daily assignments history

View file

@ -312,8 +312,13 @@ def can_view_intervention(user, intervention):
and intervention.assigned_member and intervention.assigned_member
and intervention.assigned_member.teams.filter(id__in=company_member.teams.all()).exists() 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 return False
contract = intervention.contract contract = intervention.contract
@ -352,10 +357,16 @@ def can_view_intervention(user, intervention):
can_view=True, can_view=True,
).exists() ).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é -- # -- Fallback prestataire assigné --
assigned_provider = intervention.assigned_provider assigned_provider = intervention.assigned_provider
if assigned_provider: if assigned_provider:
company_member = CompanyMember.objects.filter(user=user).prefetch_related('teams__company').first()
if company_member: if company_member:
return company_member.teams.filter(company=assigned_provider).exists() 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): def can_control_intervention(user, intervention):
""" """
Vérifie si l'utilisateur peut contrôler (valider/corriger) l'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'): if intervention.status not in ('processed', 'corrected'):
return False return False
@ -418,6 +430,16 @@ def can_control_intervention(user, intervention):
if not user_config.is_intern: if not user_config.is_intern:
return False 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) 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 = ( company_q = (
Q(assigned_member=company_member) | Q(assigned_member=company_member) |
Q(assigned_team__in=list(company_member.teams.all())) | 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: if unrestricted_companies:
company_q |= Q(assigned_member__isnull=True, assigned_team__isnull=True, assigned_provider_id__in=unrestricted_companies) company_q |= Q(assigned_member__isnull=True, assigned_team__isnull=True, assigned_provider_id__in=unrestricted_companies)
else: else:
user_companies = company_member.teams.values_list('company_id', flat=True).distinct() 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: if user_config.limit_interventions_to_contracts:
# Limiter aux contrats configurés pour cet utilisateur afin d'exclure # 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 = ( assign_filter = (
Q(assigned_member=company_member) | Q(assigned_member=company_member) |
Q(assigned_team__in=list(company_member.teams.all())) | 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: if unrestricted_companies:
assign_filter |= Q(assigned_provider_id__in=unrestricted_companies) assign_filter |= Q(assigned_provider_id__in=unrestricted_companies)

View file

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

View file

@ -946,6 +946,20 @@
</span> </span>
<span id="memberAssignmentSuccess" class="text-success ms-2 d-none">&#10003;</span> <span id="memberAssignmentSuccess" class="text-success ms-2 d-none">&#10003;</span>
</div> </div>
{% if intervention.assigned_control_team or intervention.assigned_controller %}
<div class="d-flex align-items-center mt-1">
<span class="btn btn-sm border-0 disabled text-muted"><i class="bi bi-patch-check"></i></span>
<span class="label mx-1">{% translate "Contrôle :" %}</span>
<span class="badge bg-secondary-subtle text-secondary border">
{% 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 %}
</span>
</div>
{% endif %}
<div class="modal fade" id="editAssignmentMemberModal" tabindex="-1" aria-labelledby="editAssignmentMemberModalLabel" aria-hidden="true"> <div class="modal fade" id="editAssignmentMemberModal" tabindex="-1" aria-labelledby="editAssignmentMemberModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl"> <div class="modal-dialog modal-xl">
<div class="modal-content"> <div class="modal-content">

View file

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

View file

@ -2480,7 +2480,14 @@ def intervention_posts_bulk_update(request, intervention_id):
# Vérifier les permissions selon le type de modification # Vérifier les permissions selon le type de modification
can_update_posts = user_config.roles.filter(name__in=['admin', 'external_manager']).exists() 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 updated_count = 0
errors = [] errors = []

View file

@ -414,6 +414,8 @@ def index(request):
'selected_contracts': request.GET.getlist('contract'), 'selected_contracts': request.GET.getlist('contract'),
'selected_teams': request.GET.getlist('assigned_team'), 'selected_teams': request.GET.getlist('assigned_team'),
'selected_members': request.GET.getlist('assigned_member'), '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_pause_reasons': pause_reasons,
'selected_preparation_states': preparation_states, 'selected_preparation_states': preparation_states,
'begin_date_min': begin_date_min or '', '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')) codes = _expand_csv_params(request.GET.getlist('code') + request.GET.getlist('codes'))
assigned_teams = _expand_csv_params(request.GET.getlist('assigned_team')) assigned_teams = _expand_csv_params(request.GET.getlist('assigned_team'))
assigned_members = _expand_csv_params(request.GET.getlist('assigned_member')) 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')) pause_reasons = _expand_csv_params(request.GET.getlist('pause_reason'))
intervention_managers = _expand_csv_params(request.GET.getlist('intervention_manager')) intervention_managers = _expand_csv_params(request.GET.getlist('intervention_manager'))
preparation_states = _expand_csv_params(request.GET.getlist('preparation_state')) 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) qs = qs.filter(assigned_member_id__in=assigned_members)
if assigned_teams: if assigned_teams:
qs = qs.filter(Q(assigned_team_id__in=assigned_teams) | Q(assigned_member__teams__in=assigned_teams)).distinct() 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: if intervention_managers:
qs = qs.filter(intervention_manager_id__in=intervention_managers) qs = qs.filter(intervention_manager_id__in=intervention_managers)
if followed_only == '1' and request.user.is_authenticated: 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 '')), (_("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 '')), (_("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 '')), (_("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 '')), (_("Contrat"), lambda i: (i.contract.contract_number if i.contract else '')),
(_("Commande"), lambda i: (i.order.order_code if i.order 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 ''), (_("Début prévu"), lambda i: dj_format_date(i.expected_begin_time, 'Y-m-d H:i') if i.expected_begin_time else ''),