diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py index 9b04071..f91b7c2 100644 --- a/loko/contracts/admin.py +++ b/loko/contracts/admin.py @@ -264,18 +264,61 @@ class CompanyTeamMemberInline(admin.TabularInline): verbose_name_plural = "Membres" +from django.contrib.gis import admin as gis_admin +from django.utils.translation import gettext_lazy as _ + + +class CompanyTeamAdminForm(forms.ModelForm): + operation_types = forms.MultipleChoiceField( + choices=CompanyTeam.OPERATION_TYPES, + widget=forms.CheckboxSelectMultiple, + required=False, + label=_("Types d'opérations gérées"), + ) + + class Meta: + model = CompanyTeam + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + self.initial['operation_types'] = self.instance.operation_types or [] + + def clean_operation_types(self): + return list(self.cleaned_data.get('operation_types') or []) + + @admin.register(CompanyTeam) -class CompanyTeamAdmin(admin.ModelAdmin): - list_display = ('name', 'company', 'thematics_list', 'members_count', 'can_generate_daily_report', 'photos_required', 'restrict_today_interventions_visibility', 'visibility_start_hour', 'restrict_to_assigned_interventions') +class CompanyTeamAdmin(gis_admin.GISModelAdmin): + form = CompanyTeamAdminForm + list_display = ( + 'name', 'company', 'thematics_list', 'operation_types_list', 'has_geom', + 'members_count', 'can_generate_daily_report', 'photos_required', + 'restrict_today_interventions_visibility', 'visibility_start_hour', + 'restrict_to_assigned_interventions' + ) search_fields = ('name', 'company__name') - list_filter = ('company', 'can_generate_daily_report', 'photos_required', 'restrict_today_interventions_visibility', 'restrict_to_assigned_interventions') - filter_horizontal = ('thematics',) + list_filter = ( + 'company', 'can_generate_daily_report', 'photos_required', + 'restrict_today_interventions_visibility', 'restrict_to_assigned_interventions' + ) + filter_horizontal = ('thematics', 'asset_categories') inlines = [CompanyTeamMemberInline] def thematics_list(self, obj): return ", ".join([thematic.name for thematic in obj.thematics.all()]) thematics_list.short_description = 'Thématiques' + def operation_types_list(self, obj): + return obj.get_operation_types_display() or "—" + operation_types_list.short_description = _("Opérations gérées") + + def has_geom(self, obj): + return bool(obj.geom) + has_geom.boolean = True + has_geom.short_description = _("Zone définie") + def members_count(self, obj): return obj.members.count() members_count.short_description = 'Nb membres' diff --git a/loko/contracts/locale/en/LC_MESSAGES/django.mo b/loko/contracts/locale/en/LC_MESSAGES/django.mo index 015d601..c8314f5 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 0e183a1..2769536 100644 --- a/loko/contracts/locale/en/LC_MESSAGES/django.po +++ b/loko/contracts/locale/en/LC_MESSAGES/django.po @@ -2270,6 +2270,90 @@ msgstr "You do not have permission to generate documents for this order." msgid "Déclaration de créance créée et sauvegardée." msgstr "Debt declaration created and saved." +#: contracts/models.py:73 +msgid "Inspections" +msgstr "Inspections" + +#: contracts/models.py:75 +msgid "Contrôles" +msgstr "Controls" + +#: contracts/models.py:76 +msgid "Observations" +msgstr "Observations" + +#: contracts/models.py:93 +msgid "Zone géographique" +msgstr "Geographic zone" + +#: contracts/models.py:94 +msgid "Emprise géographique multi-polygone pour l'attribution automatique." +msgstr "Multi-polygon geographic footprint for automatic assignment." + +#: contracts/models.py:99 contracts/admin.py:274 +msgid "Types d'opérations gérées" +msgstr "Managed operation types" + +#: contracts/models.py:100 +msgid "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)." +msgstr "Operation types supported by this team (e.g. Inspections, Interventions, Controls, Observations)." + +#: contracts/admin.py:312 +msgid "Opérations gérées" +msgstr "Managed operations" + +#: contracts/admin.py:318 +msgid "Zone définie" +msgstr "Defined zone" + +#: contracts/templates/contracts/teams_index.html:679 +msgid "1 Mois" +msgstr "1 Month" + +#: contracts/templates/contracts/teams_index.html:684 +msgid "Portée :" +msgstr "Scope:" + +#: contracts/templates/contracts/teams_index.html:685 +msgid "Durée appliquée lors de l'assignation ou du retrait d'un membre" +msgstr "Duration applied when assigning or removing a member" + +#: contracts/templates/contracts/teams_index.html:686 +msgid "Jour" +msgstr "Day" + +#: contracts/templates/contracts/teams_index.html:687 +msgid "Semaine" +msgstr "Week" + +#: contracts/templates/contracts/teams_index.html:688 +msgid "Mois" +msgstr "Month" + +#: contracts/templates/contracts/teams_index.html:887 +msgid "Un mois complet" +msgstr "A full month" + +#: contracts/templates/contracts/teams_index.html:924 +msgid "Mois source" +msgstr "Source month" + +#: contracts/templates/contracts/teams_index.html:928 +msgid "Mois cible" +msgstr "Target month" + +#: contracts/views_teams.py:648 contracts/views_teams.py:664 +msgid "Impossible de modifier une période passée." +msgstr "Cannot modify a past period." + +#: contracts/views_teams.py:790 +msgid "Le mois cible ne peut pas être dans le passé." +msgstr "Target month cannot be in the past." + +#: contracts/views_teams.py:792 +msgid "La date cible ne peut pas être dans le passé." +msgstr "Target date cannot be in the past." + #~ msgid "Ce déplacement modifie l'équipe de" #~ msgstr "This move changes the team of" diff --git a/loko/contracts/locale/fr/LC_MESSAGES/django.mo b/loko/contracts/locale/fr/LC_MESSAGES/django.mo index f6449bc..fba0a22 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 d71651a..a8f0ad4 100644 --- a/loko/contracts/locale/fr/LC_MESSAGES/django.po +++ b/loko/contracts/locale/fr/LC_MESSAGES/django.po @@ -2320,6 +2320,90 @@ msgstr "" msgid "Déclaration de créance créée et sauvegardée." msgstr "Déclaration de créance créée et sauvegardée." +#: contracts/models.py:73 +msgid "Inspections" +msgstr "Inspections" + +#: contracts/models.py:75 +msgid "Contrôles" +msgstr "Contrôles" + +#: contracts/models.py:76 +msgid "Observations" +msgstr "Observations" + +#: contracts/models.py:93 +msgid "Zone géographique" +msgstr "Zone géographique" + +#: contracts/models.py:94 +msgid "Emprise géographique multi-polygone pour l'attribution automatique." +msgstr "Emprise géographique multi-polygone pour l'attribution automatique." + +#: contracts/models.py:99 contracts/admin.py:274 +msgid "Types d'opérations gérées" +msgstr "Types d'opérations gérées" + +#: contracts/models.py:100 +msgid "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)." +msgstr "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)." + +#: contracts/admin.py:312 +msgid "Opérations gérées" +msgstr "Opérations gérées" + +#: contracts/admin.py:318 +msgid "Zone définie" +msgstr "Zone définie" + +#: contracts/templates/contracts/teams_index.html:679 +msgid "1 Mois" +msgstr "1 Mois" + +#: contracts/templates/contracts/teams_index.html:684 +msgid "Portée :" +msgstr "Portée :" + +#: contracts/templates/contracts/teams_index.html:685 +msgid "Durée appliquée lors de l'assignation ou du retrait d'un membre" +msgstr "Durée appliquée lors de l'assignation ou du retrait d'un membre" + +#: contracts/templates/contracts/teams_index.html:686 +msgid "Jour" +msgstr "Jour" + +#: contracts/templates/contracts/teams_index.html:687 +msgid "Semaine" +msgstr "Semaine" + +#: contracts/templates/contracts/teams_index.html:688 +msgid "Mois" +msgstr "Mois" + +#: contracts/templates/contracts/teams_index.html:887 +msgid "Un mois complet" +msgstr "Un mois complet" + +#: contracts/templates/contracts/teams_index.html:924 +msgid "Mois source" +msgstr "Mois source" + +#: contracts/templates/contracts/teams_index.html:928 +msgid "Mois cible" +msgstr "Mois cible" + +#: contracts/views_teams.py:648 contracts/views_teams.py:664 +msgid "Impossible de modifier une période passée." +msgstr "Impossible de modifier une période passée." + +#: contracts/views_teams.py:790 +msgid "Le mois cible ne peut pas être dans le passé." +msgstr "Le mois cible ne peut pas être dans le passé." + +#: contracts/views_teams.py:792 +msgid "La date cible ne peut pas être dans le passé." +msgstr "La date cible ne peut pas être dans le passé." + #~ msgid "Ce déplacement modifie l'équipe de" #~ msgstr "Ce déplacement modifie l'équipe de" diff --git a/loko/contracts/locale/nl/LC_MESSAGES/django.mo b/loko/contracts/locale/nl/LC_MESSAGES/django.mo index 09b79ae..d84dfca 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 52bd590..c6d018e 100644 --- a/loko/contracts/locale/nl/LC_MESSAGES/django.po +++ b/loko/contracts/locale/nl/LC_MESSAGES/django.po @@ -2276,6 +2276,90 @@ msgstr "" msgid "Déclaration de créance créée et sauvegardée." msgstr "Schuldvordering aangemaakt en opgeslagen." +#: contracts/models.py:73 +msgid "Inspections" +msgstr "Inspecties" + +#: contracts/models.py:75 +msgid "Contrôles" +msgstr "Controles" + +#: contracts/models.py:76 +msgid "Observations" +msgstr "Observaties" + +#: contracts/models.py:93 +msgid "Zone géographique" +msgstr "Geografische zone" + +#: contracts/models.py:94 +msgid "Emprise géographique multi-polygone pour l'attribution automatique." +msgstr "Geografische multi-polygoondekking voor automatische toewijzing." + +#: contracts/models.py:99 contracts/admin.py:274 +msgid "Types d'opérations gérées" +msgstr "Beheerde bewerkingstypes" + +#: contracts/models.py:100 +msgid "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)." +msgstr "Bewerkingstypes die door dit team worden ondersteund (bijv. Inspecties, Interventies, Controles, Observaties)." + +#: contracts/admin.py:312 +msgid "Opérations gérées" +msgstr "Beheerde bewerkingen" + +#: contracts/admin.py:318 +msgid "Zone définie" +msgstr "Gedefinieerde zone" + +#: contracts/templates/contracts/teams_index.html:679 +msgid "1 Mois" +msgstr "1 Maand" + +#: contracts/templates/contracts/teams_index.html:684 +msgid "Portée :" +msgstr "Bereik:" + +#: contracts/templates/contracts/teams_index.html:685 +msgid "Durée appliquée lors de l'assignation ou du retrait d'un membre" +msgstr "Duur toegepast bij de toewijzing of verwijdering van een lid" + +#: contracts/templates/contracts/teams_index.html:686 +msgid "Jour" +msgstr "Dag" + +#: contracts/templates/contracts/teams_index.html:687 +msgid "Semaine" +msgstr "Week" + +#: contracts/templates/contracts/teams_index.html:688 +msgid "Mois" +msgstr "Maand" + +#: contracts/templates/contracts/teams_index.html:887 +msgid "Un mois complet" +msgstr "Een volledige maand" + +#: contracts/templates/contracts/teams_index.html:924 +msgid "Mois source" +msgstr "Bronmaand" + +#: contracts/templates/contracts/teams_index.html:928 +msgid "Mois cible" +msgstr "Doelmaand" + +#: contracts/views_teams.py:648 contracts/views_teams.py:664 +msgid "Impossible de modifier une période passée." +msgstr "Kan een verstreken periode niet wijzigen." + +#: contracts/views_teams.py:790 +msgid "Le mois cible ne peut pas être dans le passé." +msgstr "De doelmaand kan niet in het verleden liggen." + +#: contracts/views_teams.py:792 +msgid "La date cible ne peut pas être dans le passé." +msgstr "De doeldatum kan niet in het verleden liggen." + #~ msgid "Ce déplacement modifie l'équipe de" #~ msgstr "Deze verplaatsing wijzigt het team van" diff --git a/loko/contracts/migrations/0008_companyteam_geom_companyteam_operation_types.py b/loko/contracts/migrations/0008_companyteam_geom_companyteam_operation_types.py new file mode 100644 index 0000000..7edc6b9 --- /dev/null +++ b/loko/contracts/migrations/0008_companyteam_geom_companyteam_operation_types.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.7 on 2026-09-19 08:32 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contracts', '0007_contract_approval_except_urgent'), + ] + + operations = [ + migrations.AddField( + model_name='companyteam', + name='geom', + field=django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, help_text="Emprise géographique multi-polygone pour l'attribution automatique.", null=True, srid=3812, verbose_name='Zone géographique'), + ), + migrations.AddField( + model_name='companyteam', + name='operation_types', + field=models.JSONField(blank=True, default=list, help_text="Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations).", verbose_name="Types d'opérations gérées"), + ), + ] diff --git a/loko/contracts/models.py b/loko/contracts/models.py index d763d89..c22c95f 100644 --- a/loko/contracts/models.py +++ b/loko/contracts/models.py @@ -6,6 +6,8 @@ from django.utils.translation import gettext_lazy as _ from django.utils.translation import get_language from django.utils import timezone +from django.contrib.gis.db import models as gis_models + from common.models import Thematic import os @@ -53,13 +55,7 @@ class MaintainType(models.Model): if lang and lang.startswith('nl'): return self.name_nl or self.name_fr return self.name_fr - - def get_name(self): - lang = get_language() - if lang and lang.startswith('nl'): - return self.name_nl or self.name_fr - return self.name_fr - + class Meta: verbose_name = _("Type de maintenance") verbose_name_plural = _("Types de maintenance") @@ -68,6 +64,18 @@ class MaintainType(models.Model): class CompanyTeam(models.Model): """Modèle représentant une équipe au sein d'une société ou d'un organisme""" + + OPERATION_INSPECTIONS = 'inspections' + OPERATION_INTERVENTIONS = 'interventions' + OPERATION_CONTROLS = 'controls' + OPERATION_OBSERVATIONS = 'observations' + OPERATION_TYPES = [ + (OPERATION_INSPECTIONS, _('Inspections')), + (OPERATION_INTERVENTIONS, _('Interventions')), + (OPERATION_CONTROLS, _('Contrôles')), + (OPERATION_OBSERVATIONS, _('Observations')), + ] + name = models.CharField(max_length=255, unique=True) company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name="teams") thematics = models.ManyToManyField('common.Thematic', blank=True, related_name="company_teams") @@ -78,6 +86,19 @@ class CompanyTeam(models.Model): verbose_name=_("Catégories d'assets gérées"), help_text=_("Catégories d'assets dont cette équipe est responsable."), ) + geom = gis_models.MultiPolygonField( + srid=3812, + null=True, + blank=True, + verbose_name=_("Zone géographique"), + help_text=_("Emprise géographique multi-polygone pour l'attribution automatique.") + ) + operation_types = models.JSONField( + default=list, + blank=True, + verbose_name=_("Types d'opérations gérées"), + help_text=_("Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations).") + ) is_assets_manager = models.BooleanField( default=False, verbose_name=_("Gestionnaire d'assets"), @@ -115,6 +136,17 @@ class CompanyTeam(models.Model): help_text=_("Si activé, les membres de cette équipe ne voient que les interventions assignées à l'équipe ou directement à un membre."), ) + def handles_operation(self, op_type: str) -> bool: + """Indique si l'équipe prend en charge le type d'opération donné.""" + if not self.operation_types: + return False + return op_type in self.operation_types + + def get_operation_types_display(self) -> str: + """Retourne la liste lisible des types d'opérations gérées.""" + mapping = dict(self.OPERATION_TYPES) + return ", ".join(str(mapping.get(op, op)) for op in (self.operation_types or [])) + def __str__(self): return f"{self.name} ({self.company.name})" diff --git a/loko/contracts/services_teams.py b/loko/contracts/services_teams.py index b443ba1..1159efe 100644 --- a/loko/contracts/services_teams.py +++ b/loko/contracts/services_teams.py @@ -2,6 +2,7 @@ Service de gestion temporelle et de planification des équipes. Gère les appartenances continues, l'historique d'équipe, les absences journalières et la duplication. """ +import calendar from collections import defaultdict from datetime import timedelta, date from typing import Optional, List, Dict, Set, Any @@ -182,6 +183,51 @@ def set_member_team_schedule( return new_history +@transaction.atomic +def set_member_team_period_schedule( + member: CompanyMember, + team_id: Optional[int], + start_date: date, + end_date: date, + action: str = 'move', + unassign_team_id: Optional[int] = None, + changed_by=None, + weekdays_only: bool = False, +) -> int: + """ + Affecte ou désaffecte un membre sur une plage de dates continue [start_date, end_date]. + Permet de définir des semaines complètes ou des mois entiers en une seule opération. + Ne modifie pas les jours passés (< aujourd'hui). + Retourne le nombre de jours mis à jour. + """ + today = timezone.now().date() + num_days = (end_date - start_date).days + 1 + updated_count = 0 + + for i in range(num_days): + d = start_date + timedelta(days=i) + if d < today: + continue + if weekdays_only and d.weekday() >= 5: + continue + + set_member_team_schedule( + member=member, + team_id=team_id, + target_date=d, + changed_by=changed_by, + sync_current=False, + action=action, + unassign_team_id=unassign_team_id, + ) + updated_count += 1 + + if start_date <= today <= end_date: + sync_member_current_teams(member, today) + + return updated_count + + def _sync_teams_if_needed(member: CompanyMember, target_date: date, today: date, sync_current: bool): """Synchronise member.teams si la date modifiée impacte la date d'aujourd'hui.""" if sync_current and target_date <= today: @@ -486,3 +532,40 @@ def duplicate_week_schedule( total_absences += res['absences_count'] return {'assigned_count': total_assigned, 'absences_count': total_absences} + + +@transaction.atomic +def duplicate_month_schedule( + source_year: int, + source_month: int, + target_year: int, + target_month: int, + allowed_team_ids: Optional[Set[int]] = None, + copy_absences: bool = False, + changed_by=None, +) -> Dict[str, int]: + """ + Duplique la configuration des équipes d'un mois source vers un mois cible (jour par jour). + Gère automatiquement les longueurs de mois différentes (28 à 31 jours). + """ + _, src_days = calendar.monthrange(source_year, source_month) + _, tgt_days = calendar.monthrange(target_year, target_month) + days_to_copy = min(src_days, tgt_days) + + total_assigned = 0 + total_absences = 0 + + for day_num in range(1, days_to_copy + 1): + s_day = date(source_year, source_month, day_num) + t_day = date(target_year, target_month, day_num) + res = duplicate_day_schedule( + source_date=s_day, + target_date=t_day, + allowed_team_ids=allowed_team_ids, + copy_absences=copy_absences, + changed_by=changed_by, + ) + total_assigned += res['assigned_count'] + total_absences += res['absences_count'] + + return {'assigned_count': total_assigned, 'absences_count': total_absences} diff --git a/loko/contracts/templates/contracts/teams_index.html b/loko/contracts/templates/contracts/teams_index.html index 8b36a02..245b6f5 100644 --- a/loko/contracts/templates/contracts/teams_index.html +++ b/loko/contracts/templates/contracts/teams_index.html @@ -676,6 +676,17 @@ + + + + +
+ {% trans "Portée :" %} +
+ + + +
@@ -862,7 +873,7 @@
-
+
@@ -871,6 +882,10 @@
+
+ + +
@@ -879,11 +894,11 @@
- +
- +
@@ -893,11 +908,25 @@
- +
- + +
+
+ + + +
+
+
+ + +
+
+ +
@@ -1572,6 +1601,7 @@ const initialViewMode = (function() { let _planningState = { anchorDate: initialDate, viewMode: initialViewMode, + assignScope: getStoredSetting('assign_scope', 'day'), companyId: getStoredSetting('company_id', ''), data: null, members: [], @@ -1584,6 +1614,18 @@ document.addEventListener('DOMContentLoaded', function() { $('#planning-viewmode-group button').removeClass('btn-primary').addClass('btn-outline-primary'); $(`#planning-viewmode-group button[data-mode="${_planningState.viewMode}"]`).removeClass('btn-outline-primary').addClass('btn-primary'); + // Restaurer le sélecteur global de portée d'assignation + $('#planning-assign-scope-group button').removeClass('btn-primary').addClass('btn-outline-primary'); + $(`#planning-assign-scope-group button[data-scope="${_planningState.assignScope}"]`).removeClass('btn-outline-primary').addClass('btn-primary'); + + // Gestion du sélecteur de portée d'assignation + $('#planning-assign-scope-group button').on('click', function() { + $('#planning-assign-scope-group button').removeClass('btn-primary').addClass('btn-outline-primary'); + $(this).removeClass('btn-outline-primary').addClass('btn-primary'); + _planningState.assignScope = $(this).data('scope'); + setStoredSetting('assign_scope', _planningState.assignScope); + }); + // Restaurer les filtres de la sidebar const savedSearch = getStoredSetting('search', ''); if (savedSearch) $('#planning-member-search').val(savedSearch); @@ -1621,7 +1663,7 @@ document.addEventListener('DOMContentLoaded', function() { loadPlanningData(); }); - // Changement de mode de vue (5j / 7j / 14j) + // Changement de mode de vue (5j / 7j / 14j / mois) $('#planning-viewmode-group button').on('click', function() { $('#planning-viewmode-group button').removeClass('btn-primary').addClass('btn-outline-primary'); $(this).removeClass('btn-outline-primary').addClass('btn-primary'); @@ -1677,13 +1719,10 @@ document.addEventListener('DOMContentLoaded', function() { // Mode duplication modal switch $('input[name="dupMode"]').on('change', function() { - if ($(this).val() === 'week') { - $('#dupSectionDay').addClass('d-none'); - $('#dupSectionWeek').removeClass('d-none'); - } else { - $('#dupSectionDay').removeClass('d-none'); - $('#dupSectionWeek').addClass('d-none'); - } + const val = $(this).val(); + $('#dupSectionDay').toggleClass('d-none', val !== 'day'); + $('#dupSectionWeek').toggleClass('d-none', val !== 'week'); + $('#dupSectionMonth').toggleClass('d-none', val !== 'month'); }); $('#dupSourceDate').on('change', function() { @@ -1721,8 +1760,13 @@ document.addEventListener('DOMContentLoaded', function() { function navigatePlanningDate(direction) { const d = new Date(_planningState.anchorDate); - const step = _planningState.viewMode === 'two_weeks' ? 14 : 7; - d.setDate(d.getDate() + (direction * step)); + if (_planningState.viewMode === 'month') { + d.setDate(1); + d.setMonth(d.getMonth() + direction); + } else { + const step = _planningState.viewMode === 'two_weeks' ? 14 : 7; + d.setDate(d.getDate() + (direction * step)); + } _planningState.anchorDate = d.toISOString().split('T')[0]; setStoredSetting('anchor_date', _planningState.anchorDate); _planningState.flatpickrInstance.setDate(_planningState.anchorDate); @@ -1982,6 +2026,7 @@ function executePlanningAssign(memberId, targetTeamId, targetDate, action) { team_id: parseInt(targetTeamId), date: targetDate, action: action || 'move', + scope: _planningState.assignScope || 'day', }, function(resp) { loadPlanningData(); @@ -2171,6 +2216,7 @@ function unassignMemberFromDate(memberId, dateStr, teamId) { team_id: null, date: dateStr, unassign_team_id: teamId ? parseInt(teamId) : null, + scope: _planningState.assignScope || 'day', }, function(resp) { loadPlanningData(); @@ -2273,6 +2319,15 @@ function openDuplicateModal() { const nextWeekDay = String(dWeek.getDate()).padStart(2, '0'); $('#dupTargetWeek').val(`${nextWeekYear}-${nextWeekMonth}-${nextWeekDay}`); + // Initialiser les mois source et cible + const srcMonthVal = `${parts[0]}-${parts[1]}`; + $('#dupSourceMonth').val(srcMonthVal); + const dMonth = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, 1); + dMonth.setMonth(dMonth.getMonth() + 1); + const nextMonthYear = dMonth.getFullYear(); + const nextMonthVal = `${nextMonthYear}-${String(dMonth.getMonth() + 1).padStart(2, '0')}`; + $('#dupTargetMonth').val(nextMonthVal); + $('#dupModalAlert').addClass('d-none'); new bootstrap.Modal(document.getElementById('duplicatePlanningModal')).show(); } @@ -2307,8 +2362,17 @@ function quickDuplicateToNextDay(sourceDateStr) { function submitDuplicatePlanning() { const mode = $('input[name="dupMode"]:checked').val(); - const sourceDate = mode === 'week' ? $('#dupSourceWeek').val() : $('#dupSourceDate').val(); - const targetDate = mode === 'week' ? $('#dupTargetWeek').val() : $('#dupTargetDate').val(); + let sourceDate, targetDate; + if (mode === 'month') { + sourceDate = $('#dupSourceMonth').val(); + targetDate = $('#dupTargetMonth').val(); + } else if (mode === 'week') { + sourceDate = $('#dupSourceWeek').val(); + targetDate = $('#dupTargetWeek').val(); + } else { + sourceDate = $('#dupSourceDate').val(); + targetDate = $('#dupTargetDate').val(); + } const copyAbsences = $('#dupCopyAbsences').is(':checked'); if (!sourceDate || !targetDate) { diff --git a/loko/contracts/test_planning.py b/loko/contracts/test_planning.py index 80bc724..958526c 100644 --- a/loko/contracts/test_planning.py +++ b/loko/contracts/test_planning.py @@ -15,6 +15,8 @@ from contracts.services_teams import ( toggle_member_absence, duplicate_day_schedule, duplicate_week_schedule, + duplicate_month_schedule, + set_member_team_period_schedule, sync_member_current_teams, ) @@ -176,6 +178,41 @@ class TeamPlanningServicesTests(TestCase): self.assertIn(self.team_b, teams_day2) + def test_set_member_team_period_schedule(self): + """Tester l'affectation sur une période continue.""" + start_d = date(2026, 10, 5) # Lundi + end_d = date(2026, 10, 11) # Dimanche + count = set_member_team_period_schedule( + member=self.member_alice, + team_id=self.team_a.id, + start_date=start_d, + end_date=end_d, + ) + self.assertEqual(count, 7) + for day_offset in range(7): + d = start_d + timedelta(days=day_offset) + self.assertEqual(get_member_team_for_date(self.member_alice, d), self.team_a) + + def test_duplicate_month_schedule(self): + """Tester la duplication d'un mois entier vers un autre mois.""" + # Configurer Alice dans Team A tous les jours d'octobre 2026 (31 jours) + for day in range(1, 32): + d = date(2026, 10, day) + set_member_team_schedule(self.member_alice, self.team_a.id, d) + + # Dupliquer vers novembre 2026 (30 jours) + res = duplicate_month_schedule( + source_year=2026, + source_month=10, + target_year=2026, + target_month=11, + ) + self.assertEqual(res['assigned_count'], 30) + + # Vérifier que le 15 novembre Alice est bien dans Team A + self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 11, 15)), self.team_a) + + class TeamPlanningAjaxViewsTests(TestCase): def setUp(self): from common.models import UserConfig, Role @@ -255,8 +292,12 @@ class TeamPlanningAjaxViewsTests(TestCase): def test_planning_duplicate_friday_to_monday_ajax(self): """Vérifie la duplication d'un vendredi vers le lundi suivant via l'endpoint AJAX.""" - friday = date(2026, 9, 11) - monday = date(2026, 9, 14) + today = timezone.now().date() + days_ahead_to_friday = (4 - today.weekday()) % 7 + if days_ahead_to_friday == 0: + days_ahead_to_friday = 7 + friday = today + timedelta(days=days_ahead_to_friday) + monday = friday + timedelta(days=3) set_member_team_schedule(self.member_alice, self.team_b.id, friday) toggle_member_absence(self.member_alice, friday, is_absent=True, reason='conge', comment='Weekend prolonge') @@ -346,7 +387,8 @@ class TeamPlanningAjaxViewsTests(TestCase): 'target_date': past_date, }), content_type='application/json') self.assertEqual(resp_dup.status_code, 400) - self.assertIn('passé', resp_dup.json().get('error', '').lower()) + error_msg = resp_dup.json().get('error', '').lower() + self.assertTrue('passé' in error_msg or 'past' in error_msg) def test_reassign_future_member_to_same_team_as_previous_day(self): """ @@ -559,5 +601,58 @@ class TeamPlanningAjaxViewsTests(TestCase): modal_member_ids = [m['id'] for m in resp_modal.json()['current_members']] self.assertIn(member_olivier.id, modal_member_ids) + def test_planning_data_ajax_month_view(self): + """Tester le retour du mode 'month' dans team_planning_data_ajax.""" + url = reverse('contracts:team_planning_data_ajax') + response = self.client.get(url, {'date': '2026-09-15', 'view_mode': 'month'}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data['days']), 30) # Septembre compte 30 jours + self.assertEqual(data['days'][0]['date'], '2026-09-01') + self.assertEqual(data['days'][-1]['date'], '2026-09-30') + + def test_planning_assign_member_ajax_week_scope(self): + """Tester l'assignation avec portée 'week' via l'API AJAX.""" + # Prendre un lundi dans le futur + today = timezone.now().date() + next_monday = today + timedelta(days=(7 - today.weekday())) + url = reverse('contracts:team_planning_assign_member_ajax') + payload = { + 'member_id': self.member_alice.id, + 'team_id': self.team_a.id, + 'date': next_monday.isoformat(), + 'scope': 'week', + } + response = self.client.post(url, data=json.dumps(payload), content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()['success']) + self.assertEqual(response.json()['scope'], 'week') + self.assertEqual(response.json()['updated_count'], 7) + + # Vérifier que le mercredi et le dimanche de cette semaine ont bien Alice + self.assertEqual(get_member_team_for_date(self.member_alice, next_monday + timedelta(days=2)), self.team_a) + self.assertEqual(get_member_team_for_date(self.member_alice, next_monday + timedelta(days=6)), self.team_a) + + def test_planning_duplicate_ajax_month_mode(self): + """Tester la duplication de mois via l'API AJAX.""" + # Créer des affectations pour octobre 2026 + oct_1 = date(2026, 10, 1) + set_member_team_schedule(self.member_alice, self.team_b.id, oct_1) + + url = reverse('contracts:team_planning_duplicate_ajax') + payload = { + 'mode': 'month', + 'source_date': '2026-10', + 'target_date': '2026-11', + 'copy_absences': False, + } + response = self.client.post(url, data=json.dumps(payload), content_type='application/json') + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + self.assertEqual(data['mode'], 'month') + self.assertGreaterEqual(data['assigned_count'], 1) + self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 11, 1)), self.team_b) + diff --git a/loko/contracts/views_teams.py b/loko/contracts/views_teams.py index 14349cb..78866f5 100644 --- a/loko/contracts/views_teams.py +++ b/loko/contracts/views_teams.py @@ -1,3 +1,4 @@ +import calendar import json from datetime import date, timedelta, datetime @@ -21,12 +22,14 @@ from contracts.permissions import ( ) from contracts.services_teams import ( set_member_team_schedule, + set_member_team_period_schedule, get_member_team_for_date, get_teams_composition_for_period, get_member_absences_for_period, toggle_member_absence, duplicate_day_schedule, duplicate_week_schedule, + duplicate_month_schedule, sync_member_current_teams, sync_all_members_current_teams, ) @@ -456,6 +459,11 @@ def team_planning_data_ajax(request): num_days = 14 start_date = monday end_date = monday + timedelta(days=13) + elif view_mode == 'month': + _, days_in_month = calendar.monthrange(anchor_date.year, anchor_date.month) + num_days = days_in_month + start_date = date(anchor_date.year, anchor_date.month, 1) + end_date = date(anchor_date.year, anchor_date.month, days_in_month) else: # 'week' (default) num_days = 7 start_date = monday @@ -602,6 +610,7 @@ def team_planning_assign_member_ajax(request): member_id = data.get("member_id") raw_team_id = data.get("team_id") date_str = data.get("date") + scope = data.get("scope", "day") # 'day', 'week', 'work_week', 'month' if not member_id or not date_str: return JsonResponse({"error": "Paramètres member_id et date requis."}, status=400) @@ -611,7 +620,7 @@ def team_planning_assign_member_ajax(request): return JsonResponse({"error": "Date invalide."}, status=400) today = timezone.now().date() - if target_date < today: + if scope == "day" and target_date < today: return JsonResponse({"error": "Impossible de modifier la planification d'un jour passé."}, status=400) member = get_object_or_404(CompanyMember, id=member_id) @@ -631,16 +640,48 @@ def team_planning_assign_member_ajax(request): raw_unassign_team_id = data.get("unassign_team_id") unassign_team_id = int(raw_unassign_team_id) if raw_unassign_team_id is not None else None - # Effectuer la planification - set_member_team_schedule( - member=member, - team_id=team_id, - target_date=target_date, - changed_by=request.user, - sync_current=True, - action=action, - unassign_team_id=unassign_team_id, - ) + # Effectuer la planification selon la portée choisie + if scope in ("week", "work_week"): + monday = target_date - timedelta(days=target_date.weekday()) + week_end = monday + timedelta(days=4 if scope == "work_week" else 6) + if week_end < today: + return JsonResponse({"error": _("Impossible de modifier une période passée.")}, status=400) + updated_count = set_member_team_period_schedule( + member=member, + team_id=team_id, + start_date=monday, + end_date=week_end, + action=action, + unassign_team_id=unassign_team_id, + changed_by=request.user, + weekdays_only=(scope == "work_week"), + ) + elif scope == "month": + _, days_in_month = calendar.monthrange(target_date.year, target_date.month) + month_start = date(target_date.year, target_date.month, 1) + month_end = date(target_date.year, target_date.month, days_in_month) + if month_end < today: + return JsonResponse({"error": _("Impossible de modifier une période passée.")}, status=400) + updated_count = set_member_team_period_schedule( + member=member, + team_id=team_id, + start_date=month_start, + end_date=month_end, + action=action, + unassign_team_id=unassign_team_id, + changed_by=request.user, + ) + else: + set_member_team_schedule( + member=member, + team_id=team_id, + target_date=target_date, + changed_by=request.user, + sync_current=True, + action=action, + unassign_team_id=unassign_team_id, + ) + updated_count = 1 return JsonResponse({ "success": True, @@ -648,6 +689,8 @@ def team_planning_assign_member_ajax(request): "member_name": member.name, "team_id": team_id, "date": target_date.isoformat(), + "scope": scope, + "updated_count": updated_count, }) @@ -728,6 +771,13 @@ def team_planning_duplicate_ajax(request): if not source_date_str or not target_date_str: return JsonResponse({"error": "Dates source et cible requises."}, status=400) + # Si mode == 'month' et dates au format 'YYYY-MM', normaliser avec le 1er du mois + if mode == "month": + if len(source_date_str) == 7: + source_date_str = f"{source_date_str}-01" + if len(target_date_str) == 7: + target_date_str = f"{target_date_str}-01" + source_date = parse_date(source_date_str) target_date = parse_date(target_date_str) @@ -735,8 +785,11 @@ def team_planning_duplicate_ajax(request): return JsonResponse({"error": "Format de date invalide."}, status=400) today = timezone.now().date() - if target_date < today: - return JsonResponse({"error": "La date cible ne peut pas être dans le passé."}, status=400) + if mode == "month": + if (target_date.year, target_date.month) < (today.year, today.month): + return JsonResponse({"error": _("Le mois cible ne peut pas être dans le passé.")}, status=400) + elif target_date < today: + return JsonResponse({"error": _("La date cible ne peut pas être dans le passé.")}, status=400) # Récupérer les équipes que l'utilisateur a le droit d'éditer viewable = get_viewable_teams_for_user(request.user) @@ -752,7 +805,17 @@ def team_planning_duplicate_ajax(request): else: allowed_team_ids = set(viewable.values_list('id', flat=True)) - if mode == "week": + if mode == "month": + res = duplicate_month_schedule( + source_year=source_date.year, + source_month=source_date.month, + target_year=target_date.year, + target_month=target_date.month, + allowed_team_ids=allowed_team_ids, + copy_absences=copy_absences, + changed_by=request.user, + ) + elif mode == "week": # S'assurer de pointer sur les lundis source_monday = source_date - timedelta(days=source_date.weekday()) target_monday = target_date - timedelta(days=target_date.weekday()) diff --git a/loko/inspections/admin.py b/loko/inspections/admin.py index ecad04a..c26aad5 100644 --- a/loko/inspections/admin.py +++ b/loko/inspections/admin.py @@ -1,14 +1,5 @@ from django.contrib import admin -from django.contrib.gis import admin as gis_admin -from .models import Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, HotBoxInspection, InspectionDocument - - -@admin.register(InspectionZone) -class InspectionZoneAdmin(gis_admin.GISModelAdmin): - list_display = ("code", "name_fr", "thematic", "is_active", "created_at") - list_filter = ("thematic", "is_active") - search_fields = ("code", "name_fr", "name_nl") - filter_horizontal = ("municipalities", "inspectors") +from .models import Inspection, ParkingSpotInspection, GiepInspection, HotBoxInspection, InspectionDocument class InspectionDocumentInline(admin.TabularInline): @@ -35,21 +26,22 @@ def make_compliant(modeladmin, request, queryset): @admin.register(Inspection) class InspectionAdmin(admin.ModelAdmin): list_display = ( - "id", "asset", "status", "zone", "scheduled_start_date", "scheduled_due_date", + "id", "asset", "status", "assigned_team", "assigned_member", + "scheduled_start_date", "scheduled_due_date", "inspector", "inspection_date", "result_status", "mission_intervention", "checked_intervention", "apply_changes_to_asset" ) list_filter = ( - "status", "result_status", "nature", "zone", "inspector_role", + "status", "result_status", "nature", "assigned_team", "inspector_role", "apply_changes_to_asset", "inspection_date" ) search_fields = ( "notes", "inspector__username", "inspector__first_name", - "inspector__last_name", "mission_intervention__code", - "checked_intervention__code" + "inspector__last_name", "assigned_team__name", "assigned_member__name", + "mission_intervention__code", "checked_intervention__code" ) - raw_id_fields = ("inspector", "mission_intervention", "checked_intervention", "zone") + raw_id_fields = ("inspector", "mission_intervention", "checked_intervention", "assigned_team", "assigned_member") inlines = [InspectionDocumentInline] actions = [make_cancelled, make_compliant] @@ -95,7 +87,7 @@ class HotBoxInspectionAdmin(admin.ModelAdmin): "cracking", "water_drainage", "inspection_date" ) search_fields = ("notes", "inspector__username", "inspector__first_name", "inspector__last_name", "checked_intervention__code") - raw_id_fields = ("inspector", "mission_intervention", "checked_intervention", "zone") + raw_id_fields = ("inspector", "mission_intervention", "checked_intervention", "assigned_team", "assigned_member") inlines = [InspectionDocumentInline] actions = [make_cancelled, make_compliant] diff --git a/loko/inspections/locale/en/LC_MESSAGES/django.mo b/loko/inspections/locale/en/LC_MESSAGES/django.mo index 02ac4aa..8c770f6 100644 Binary files a/loko/inspections/locale/en/LC_MESSAGES/django.mo and b/loko/inspections/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/inspections/locale/en/LC_MESSAGES/django.po b/loko/inspections/locale/en/LC_MESSAGES/django.po index 2d0ebb8..d847cac 100644 --- a/loko/inspections/locale/en/LC_MESSAGES/django.po +++ b/loko/inspections/locale/en/LC_MESSAGES/django.po @@ -1970,6 +1970,19 @@ msgid "" "d'évaluation)." msgstr "Visual checks not yet filled in (scheduled or pending evaluation)." +#: inspections/models.py:163 +#: inspections/templates/inspections/inspections_list_mobile.html:309 +msgid "Équipe assignée" +msgstr "Assigned team" + +#: inspections/models.py:171 +msgid "Membre assigné" +msgstr "Assigned member" + +#: inspections/templates/inspections/components/edit_inspection_modal.html:675 +msgid "Équipe" +msgstr "Team" + #~ msgid "Renforcement de l'entretien préventif" #~ msgstr "Reinforcement of preventive maintenance" diff --git a/loko/inspections/locale/fr/LC_MESSAGES/django.mo b/loko/inspections/locale/fr/LC_MESSAGES/django.mo index 9c33eb7..9cf3ed3 100644 Binary files a/loko/inspections/locale/fr/LC_MESSAGES/django.mo and b/loko/inspections/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/inspections/locale/fr/LC_MESSAGES/django.po b/loko/inspections/locale/fr/LC_MESSAGES/django.po index 3b1c422..f6df44f 100644 --- a/loko/inspections/locale/fr/LC_MESSAGES/django.po +++ b/loko/inspections/locale/fr/LC_MESSAGES/django.po @@ -1980,6 +1980,19 @@ msgstr "" "Contrôles visuels non encore renseignés (inspection planifiée ou en attente " "d'évaluation)." +#: inspections/models.py:163 +#: inspections/templates/inspections/inspections_list_mobile.html:309 +msgid "Équipe assignée" +msgstr "Équipe assignée" + +#: inspections/models.py:171 +msgid "Membre assigné" +msgstr "Membre assigné" + +#: inspections/templates/inspections/components/edit_inspection_modal.html:675 +msgid "Équipe" +msgstr "Équipe" + #~ msgid "Renforcement de l'entretien préventif" #~ msgstr "Renforcement de l’entretien préventif" diff --git a/loko/inspections/locale/nl/LC_MESSAGES/django.mo b/loko/inspections/locale/nl/LC_MESSAGES/django.mo index febaab2..09e44b3 100644 Binary files a/loko/inspections/locale/nl/LC_MESSAGES/django.mo and b/loko/inspections/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/inspections/locale/nl/LC_MESSAGES/django.po b/loko/inspections/locale/nl/LC_MESSAGES/django.po index 1f3d363..58ccec2 100644 --- a/loko/inspections/locale/nl/LC_MESSAGES/django.po +++ b/loko/inspections/locale/nl/LC_MESSAGES/django.po @@ -1971,6 +1971,19 @@ msgstr "" "Visuele controles nog niet ingevuld (geplande inspectie of in afwachting van" " evaluatie)." +#: inspections/models.py:163 +#: inspections/templates/inspections/inspections_list_mobile.html:309 +msgid "Équipe assignée" +msgstr "Toegewezen team" + +#: inspections/models.py:171 +msgid "Membre assigné" +msgstr "Toegewezen lid" + +#: inspections/templates/inspections/components/edit_inspection_modal.html:675 +msgid "Équipe" +msgstr "Team" + #~ msgid "Renforcement de l'entretien préventif" #~ msgstr "Versterking van preventief onderhoud" diff --git a/loko/inspections/migrations/0014_remove_inspection_zone_inspection_assigned_member_and_more.py b/loko/inspections/migrations/0014_remove_inspection_zone_inspection_assigned_member_and_more.py new file mode 100644 index 0000000..6d696b7 --- /dev/null +++ b/loko/inspections/migrations/0014_remove_inspection_zone_inspection_assigned_member_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.7 on 2026-09-19 08:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contracts', '0008_companyteam_geom_companyteam_operation_types'), + ('inspections', '0013_promote_existing_hotbox_inspections'), + ] + + operations = [ + migrations.RemoveField( + model_name='inspection', + name='zone', + ), + migrations.AddField( + model_name='inspection', + name='assigned_member', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_inspections', to='contracts.companymember', verbose_name='Membre assigné'), + ), + migrations.AddField( + model_name='inspection', + name='assigned_team', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_inspections', to='contracts.companyteam', verbose_name='Équipe assignée'), + ), + migrations.DeleteModel( + name='InspectionZone', + ), + ] diff --git a/loko/inspections/models.py b/loko/inspections/models.py index c085779..0dba912 100644 --- a/loko/inspections/models.py +++ b/loko/inspections/models.py @@ -77,53 +77,6 @@ INSPECTOR_ROLE_CHOICES = ( ) -class InspectionZone(models.Model): - """ - Zone / District géographique d'inspection pour une thématique donnée. - Permet d'affecter automatiquement les inspections de durabilité et de filtrer les tournées des inspecteurs. - """ - code = models.CharField(max_length=50, unique=True, verbose_name=_("Code du secteur / zone")) - name_fr = models.CharField(max_length=200, verbose_name=_("Nom (FR)")) - name_nl = models.CharField(max_length=200, blank=True, verbose_name=_("Nom (NL)")) - thematic = models.ForeignKey( - 'common.Thematic', - on_delete=models.PROTECT, - related_name='inspection_zones', - verbose_name=_("Thématique") - ) - geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise géographique")) - municipalities = models.ManyToManyField( - 'common.Municipality', - blank=True, - related_name='inspection_zones', - verbose_name=_("Communes associées") - ) - inspectors = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - related_name='assigned_inspection_zones', - verbose_name=_("Inspecteurs assignés") - ) - is_active = models.BooleanField(default=True, verbose_name=_("Actif")) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - verbose_name = _("Zone / District d'inspection") - verbose_name_plural = _("Zones / Districts d'inspection") - ordering = ['thematic', 'name_fr'] - - def __str__(self): - return f"{self.name_fr} ({self.thematic.name})" - - def get_name(self): - from django.utils.translation import get_language - lang = get_language() - if lang == 'nl' and self.name_nl: - return self.name_nl - return self.name_fr - - class Inspection(models.Model): """ Modèle d'enregistrement générique d'une vérification/inspection sur le terrain d'une cible (Asset, Localisation, Intervention). @@ -201,13 +154,21 @@ class Inspection(models.Model): blank=True, verbose_name=_("Échéance limite d'inspection") ) - zone = models.ForeignKey( - InspectionZone, + assigned_team = models.ForeignKey( + 'contracts.CompanyTeam', on_delete=models.SET_NULL, null=True, blank=True, - related_name='inspections', - verbose_name=_("Zone / District d'inspection") + related_name='assigned_inspections', + verbose_name=_("Équipe assignée") + ) + assigned_member = models.ForeignKey( + 'contracts.CompanyMember', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='assigned_inspections', + verbose_name=_("Membre assigné") ) non_compliance_notified = models.BooleanField( default=False, diff --git a/loko/inspections/permissions.py b/loko/inspections/permissions.py index b296218..f61d0d8 100644 --- a/loko/inspections/permissions.py +++ b/loko/inspections/permissions.py @@ -48,8 +48,8 @@ def can_edit_inspection(user, inspection): # Pour une inspection planifiée / à réaliser : if inspection and getattr(inspection, 'status', None) == 'scheduled': - # Inspecteur faisant partie de la zone d'inspection - if inspection.zone_id and inspection.zone.inspectors.filter(id=user.id).exists(): + # Membre faisant partie de l'équipe assignée à l'inspection + if inspection.assigned_team_id and inspection.assigned_team.members.filter(user=user).exists(): return True # Inspecteur ayant la permission can_inspect sur la thématique insp_thematic = get_inspection_thematic(inspection) @@ -136,6 +136,10 @@ def can_view_inspection(user, inspection): insp_thematic = get_inspection_thematic(inspection) return bool(insp_thematic and insp_thematic.id in viewable_thematic_ids) + # 0. Équipe assignée + if inspection.assigned_team_id and inspection.assigned_team.members.filter(user=user).exists(): + return True + # 1. Auteur / Inspecteur de l'inspection if inspection.inspector_id == user.id: return True @@ -329,9 +333,9 @@ def filter_viewable_inspections_for_user(user, qs=None): conditions = [] - # 1. Auteur / Inspecteur de l'inspection ou inspecteur de la zone + # 1. Auteur / Inspecteur de l'inspection ou membre de l'équipe assignée conditions.append(Q(inspector=user)) - conditions.append(Q(zone__inspectors=user)) + conditions.append(Q(assigned_team__members__user=user)) # 2. Interventions liées visibles (missions d'intervention, contrôles post-travaux, ou cible intervention) from interventions.permissions import filter_viewable_interventions_for_user diff --git a/loko/inspections/services.py b/loko/inspections/services.py index 4438525..481c424 100644 --- a/loko/inspections/services.py +++ b/loko/inspections/services.py @@ -130,12 +130,130 @@ def _resolve_inspection_target(intervention, sub_type): return ContentType.objects.get_for_model(intervention), intervention.pk +def resolve_team_and_member_for_inspection(target_geom=None, point=None, scheduled_date=None, thematic=None, municipalities=None): + """ + Détermine automatiquement l'équipe et le membre assigné pour une inspection : + 1. Filtre les équipes CompanyTeam actives gérant l'opération 'inspections'. + 2. Filtre par thématique si l'équipe a des thématiques restreintes. + 3. Résolution spatiale si une géométrie est définie (sur la cible et sur les équipes) : + - Tente de faire correspondre géographiquement (intersects target_geom ou contains point). + - Si des équipes correspondent spatialement, la sélection s'effectue parmi elles. + 4. Fallback sur le calendrier : + - Si aucune géométrie n'est définie (ni sur la cible, ni sur les équipes), + on évalue l'ensemble des équipes candidates gérant les inspections. + 5. Prise en compte du calendrier d'assignation à la date scheduled_date : + - Évalue le nombre d'agents planifiés et disponibles (non absents) à scheduled_date. + - Si aucune équipe n'a d'agent disponible ou si aucune date ne correspond, AUCUNE équipe n'est assignée (None, None). + - Sinon, choisit l'équipe ayant le plus d'agents disponibles (Option A). + - Pré-assigne le membre disponible de cette équipe si applicable. + """ + from contracts.models import CompanyTeam, CompanyMember + from contracts.services_teams import get_teams_composition_for_period, get_member_absences_for_period + + # 1. Équipes candidates gérant les inspections + teams_qs = CompanyTeam.objects.all().select_related('company') + candidate_teams = [t for t in teams_qs if t.handles_operation(CompanyTeam.OPERATION_INSPECTIONS)] + + if not candidate_teams: + return None, None + + # 2. Filtrage thématique si applicable + 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 + + # 3. Résolution spatiale si géométrie définie + has_target_geom = bool(target_geom or point or municipalities) + 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 municipalities and team.geom: + for muni in municipalities: + muni_geom = getattr(muni, 'geom', None) + if muni_geom: + try: + if team.geom.intersects(muni_geom): + matched = True + break + except Exception: + pass + + if matched: + spatial_matches.append(team) + + eligible_teams = spatial_matches + else: + # Aucune géométrie définie (sur la cible ou sur les équipes) -> fallback sur toutes les équipes candidates + eligible_teams = candidate_teams + + if not eligible_teams: + return None, None + + # 4. Calendrier d'assignation + # Si aucune date n'est fournie, alors aucune date ne correspond -> ne pas assigner d'équipe + 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)) + + # Trier par nb de membres disponibles décroissant, puis nom de l'équipe + scored_teams.sort(key=lambda item: (-item[0], item[1].name)) + best_len, best_team, best_members = scored_teams[0] + + # Si aucune date ne correspond (aucun agent planifié/disponible à cette date), ne pas assigner d'équipe + if best_len == 0: + return None, None + + assigned_member = None + if best_members: + assigned_member = CompanyMember.objects.filter(id=best_members[0]['id']).first() + + return best_team, assigned_member + + def schedule_post_intervention_inspection(intervention): """ Planifie automatiquement une inspection de durabilité pour une intervention lorsque son sous-type requiert une inspection post-travaux (ex: Hot Box). """ - from .models import Inspection, InspectionZone + from .models import Inspection sub_type = getattr(intervention, 'sub_type', None) if not sub_type or not getattr(sub_type, 'requires_post_inspection', False): @@ -164,47 +282,41 @@ def schedule_post_intervention_inspection(intervention): scheduled_start_date = ref_date.date() + timedelta(days=min_days) scheduled_due_date = ref_date.date() + timedelta(days=max_days) - # Détermination de la zone d'inspection - thematic = intervention.thematic - zones = InspectionZone.objects.filter(thematic=thematic, is_active=True) if thematic else InspectionZone.objects.none() - zone = None + # Point pour résolution spatiale + point = None + if intervention.lon and intervention.lat: + try: + from django.contrib.gis.geos import Point + 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}") - # 1. Résolution spatiale par intersection / inclusion - if zones.exists(): - if intervention.geom: - zone = zones.filter(geom__intersects=intervention.geom).first() + # Récupérer les communes associées + munis = [] + from common.models import Municipality + for loc in intervention.locations.all(): + loc_obj = loc.location + muni_obj = loc_obj if isinstance(loc_obj, Municipality) else getattr(loc_obj, 'municipality', None) + if muni_obj: + munis.append(muni_obj) - if not zone and intervention.lon and intervention.lat: - try: - from django.contrib.gis.geos import Point - p = Point(float(intervention.lon), float(intervention.lat), srid=4326) - p.transform(3812) - zone = zones.filter(geom__contains=p).first() - except Exception as e: - logger.debug(f"Error resolving inspection zone via coordinates: {e}") + # Résolution automatique de l'équipe et du membre + assigned_team, assigned_member = resolve_team_and_member_for_inspection( + target_geom=intervention.geom, + point=point, + scheduled_date=scheduled_start_date, + thematic=intervention.thematic, + municipalities=munis, + ) - # 2. Résolution administrative par commune associée - if not zone: - from common.models import Municipality - for loc in intervention.locations.all(): - loc_obj = loc.location - muni_to_check = loc_obj if isinstance(loc_obj, Municipality) else getattr(loc_obj, 'municipality', None) - if muni_to_check: - zone = zones.filter(municipalities=muni_to_check).first() - if zone: - break + assigned_inspector = assigned_member.user if (assigned_member and assigned_member.user) else None # Détermination de la cible (Asset, Localisation ou Intervention elle-même) target_ct, target_id = _resolve_inspection_target(intervention, sub_type) - # Pré-assignation si un seul inspecteur est assigné à la zone - assigned_inspector = None - if zone: - zone_inspectors = list(zone.inspectors.all()[:2]) - if len(zone_inspectors) == 1: - assigned_inspector = zone_inspectors[0] - - from .models import Inspection, InspectionZone, HotBoxInspection + from .models import Inspection, HotBoxInspection is_hotbox = any(k in (sub_type.code or '').lower() or k in (sub_type.name_fr or '').lower() for k in ('hotbox', 'hot_box', 'hot box')) inspection_cls = HotBoxInspection if is_hotbox else Inspection @@ -220,9 +332,10 @@ def schedule_post_intervention_inspection(intervention): result_status='pending', scheduled_start_date=scheduled_start_date, scheduled_due_date=scheduled_due_date, - zone=zone, + assigned_team=assigned_team, + assigned_member=assigned_member, inspector=assigned_inspector, notes=notes, ) - logger.info(f"Scheduled post-intervention inspection {inspection.pk} created for intervention {intervention.code}") + logger.info(f"Scheduled post-intervention inspection {inspection.pk} created for intervention {intervention.code} (Team: {assigned_team})") return inspection diff --git a/loko/inspections/templates/inspections/components/edit_inspection_modal.html b/loko/inspections/templates/inspections/components/edit_inspection_modal.html index fd4e48b..34bbd83 100644 --- a/loko/inspections/templates/inspections/components/edit_inspection_modal.html +++ b/loko/inspections/templates/inspections/components/edit_inspection_modal.html @@ -672,8 +672,8 @@ document.addEventListener('DOMContentLoaded', function() { titleModal.innerHTML = `{% translate "Réaliser l'inspection" %}`; } let schedInfo = `{% translate "Fenêtre" %} : ${data.scheduled_start_date} → ${data.scheduled_due_date}`; - if (data.zone_name) { - schedInfo += ` | {% translate "Zone" %} : ${data.zone_name}`; + if (data.assigned_team_name || data.zone_name) { + schedInfo += ` | {% translate "Équipe" %} : ${data.assigned_team_name || data.zone_name}`; } if (data.checked_intervention_code) { schedInfo += ` | ${data.checked_intervention_code}`; diff --git a/loko/inspections/templates/inspections/inspections_list_mobile.html b/loko/inspections/templates/inspections/inspections_list_mobile.html index 2f9fe10..8cff4be 100644 --- a/loko/inspections/templates/inspections/inspections_list_mobile.html +++ b/loko/inspections/templates/inspections/inspections_list_mobile.html @@ -158,9 +158,9 @@ {{ insp.asset_content_type.name }} - {% if insp.zone %} + {% if insp.assigned_team %} - {{ insp.zone.name_fr }} + {{ insp.assigned_team.name }} {% endif %} @@ -304,10 +304,10 @@ {% translate "Fenêtre d'inspection" %} {{ insp.scheduled_start_date|date:"d/m/Y" }} → {{ insp.scheduled_due_date|date:"d/m/Y" }} - {% if insp.zone %} + {% if insp.assigned_team %}
  • - {% translate "District / Zone" %} - {{ insp.zone.name_fr }} + {% translate "Équipe assignée" %} + {{ insp.assigned_team.name }}
  • {% endif %} {% if insp.checked_intervention %} diff --git a/loko/inspections/tests.py b/loko/inspections/tests.py index 78004db..bdd163b 100644 --- a/loko/inspections/tests.py +++ b/loko/inspections/tests.py @@ -1288,7 +1288,7 @@ class ViewerInspectionPermissionsAndGeojsonTestCase(TestCase): # Number of queries should be bounded and independent of inspection count # (O(1) queries instead of O(N), unaffected by inspection count) - self.assertLessEqual(len(ctx), 28) + self.assertLessEqual(len(ctx), 32) class GiepInspectionStatusCalculationTestCase(TestCase): @@ -1574,41 +1574,118 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): self.assertEqual(insp.timeliness, 'future') def test_zone_and_single_inspector_auto_assignment(self): - from common.models import Municipality - from interventions.models import InterventionLocation - from .models import InspectionZone + from datetime import timedelta + from contracts.models import Company, CompanyTeam, CompanyMember + from contracts.services_teams import set_member_team_schedule + from django.contrib.gis.geos import Polygon, MultiPolygon - zone = InspectionZone.objects.create( - code='DIST_NORD_TEST', - name_fr='District Nord Test', - thematic=self.roads_thematic + company = Company.objects.create(name="Voirie Nord Test") + poly = MultiPolygon(Polygon(((100000, 100000), (100000, 200000), (200000, 200000), (200000, 100000), (100000, 100000)))) + team = CompanyTeam.objects.create( + name="Inspection Zone 1 Test", + company=company, + geom=poly, + operation_types=['inspections'], ) - zone.inspectors.add(self.user) - muni = Municipality.objects.create(name_fr='Schaerbeek Test', name_nl='Schaarbeek Test', niscode='21099', fid='21099') - zone.municipalities.add(muni) + team.thematics.add(self.roads_thematic) + member = CompanyMember.objects.create(name="Inspecteur Alice", user=self.user) + + target_date = (timezone.now() + timedelta(days=30)).date() + set_member_team_schedule(member, team.id, target_date) intervention = Intervention.objects.create( thematic=self.roads_thematic, type='repair', sub_type=self.hotbox_subtype, status='in_progress', + geom=poly, end_time=timezone.now() ) - InterventionLocation.objects.create( - intervention=intervention, - content_type=ContentType.objects.get_for_model(muni), - object_id=muni.pk - ) intervention.status = 'finished' intervention.save() insp = Inspection.objects.get(checked_intervention=intervention) - self.assertEqual(insp.zone, zone) - # Inspecteur unique pré-assigné + self.assertEqual(insp.assigned_team, team) + self.assertEqual(insp.assigned_member, member) + # Inspecteur unique pré-assigné via le membre self.assertEqual(insp.inspector, self.user) self.assertTrue(insp.can_edit(self.user)) + def test_auto_assignment_fallback_calendar_when_no_geometry(self): + """Si aucune géométrie n'est définie, fallback sur le calendrier d'assignation.""" + from datetime import timedelta + from contracts.models import Company, CompanyTeam, CompanyMember + from contracts.services_teams import set_member_team_schedule + + company = Company.objects.create(name="Voirie Fallback Test") + team = CompanyTeam.objects.create( + name="Équipe Sans Zone", + company=company, + geom=None, + operation_types=['inspections'], + ) + team.thematics.add(self.roads_thematic) + member = CompanyMember.objects.create(name="Inspecteur Bob", user=self.user) + + target_date = (timezone.now() + timedelta(days=30)).date() + set_member_team_schedule(member, team.id, target_date) + + # Intervention SANS géométrie + intervention = Intervention.objects.create( + thematic=self.roads_thematic, + type='repair', + sub_type=self.hotbox_subtype, + status='in_progress', + geom=None, + lon=None, + lat=None, + end_time=timezone.now() + ) + intervention.status = 'finished' + intervention.save() + + insp = Inspection.objects.get(checked_intervention=intervention) + self.assertEqual(insp.assigned_team, team) + self.assertEqual(insp.assigned_member, member) + self.assertEqual(insp.inspector, self.user) + + def test_auto_assignment_no_team_assigned_when_no_date_matches(self): + """Si aucune date ne correspond (aucun agent planifié ce jour-là), ne pas assigner d'équipe.""" + from datetime import timedelta + from contracts.models import Company, CompanyTeam, CompanyMember + from contracts.services_teams import set_member_team_schedule + + company = Company.objects.create(name="Voirie No Date Test") + team = CompanyTeam.objects.create( + name="Équipe Dispo Uniquement Demain", + company=company, + geom=None, + operation_types=['inspections'], + ) + team.thematics.add(self.roads_thematic) + member = CompanyMember.objects.create(name="Inspecteur Charlie", user=self.user) + + # Planifié demain, mais l'inspection a lieu dans 30 jours + tomorrow = (timezone.now() + timedelta(days=1)).date() + set_member_team_schedule(member, team.id, tomorrow) + + intervention = Intervention.objects.create( + thematic=self.roads_thematic, + type='repair', + sub_type=self.hotbox_subtype, + status='in_progress', + geom=None, + end_time=timezone.now() + ) + intervention.status = 'finished' + intervention.save() + + insp = Inspection.objects.get(checked_intervention=intervention) + # Aucune date ne correspond dans 30 jours -> pas d'équipe assignée + self.assertIsNone(insp.assigned_team) + self.assertIsNone(insp.assigned_member) + def test_non_compliance_triggers_team_notification(self): from notifications.models import NotificationRecipient intervention = Intervention.objects.create( diff --git a/loko/inspections/views.py b/loko/inspections/views.py index c023b4e..85c25e3 100644 --- a/loko/inspections/views.py +++ b/loko/inspections/views.py @@ -11,8 +11,9 @@ import json from common.models import Thematic from common.utils import get_short_name from assets.models import AssetCategory +from contracts.models import CompanyTeam from .models import ( - Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, SignPanelInspection, HotBoxInspection, InspectionDocument, + Inspection, ParkingSpotInspection, GiepInspection, SignPanelInspection, HotBoxInspection, InspectionDocument, INSPECTION_RESULT_CHOICES, INSPECTION_NATURE_CHOICES, INSPECTOR_ROLE_CHOICES ) @@ -45,7 +46,8 @@ def _filter_inspections_queryset(request): qs = filter_viewable_inspections_for_user(request.user) qs = qs.select_related( 'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention', - 'parkingspotinspection', 'giepinspection', 'signpanelinspection' + 'parkingspotinspection', 'giepinspection', 'signpanelinspection', + 'assigned_team', 'assigned_member' ).prefetch_related('documents') thematics = request.GET.getlist('thematic') @@ -159,16 +161,16 @@ def _filter_inspections_queryset(request): if natures: qs = qs.filter(nature__in=natures) - zone_id = request.GET.get('zone') or request.GET.get('zone_id') - if zone_id: - qs = qs.filter(zone_id=zone_id) + team_filter = request.GET.get('team') or request.GET.get('team_id') or request.GET.get('zone') or request.GET.get('zone_id') + if team_filter: + qs = qs.filter(assigned_team_id=team_filter) show_mine_only = request.GET.get('mine') == '1' or request.GET.get('tab') == 'my' if show_mine_only: qs = qs.filter(inspector=request.user) - if request.GET.get('mine_zones') == '1': - qs = qs.filter(Q(zone__inspectors=request.user) | Q(inspector=request.user)) + if request.GET.get('mine_zones') == '1' or request.GET.get('mine_teams') == '1': + qs = qs.filter(Q(assigned_team__members__user=request.user) | Q(inspector=request.user)) timeliness = request.GET.get('timeliness') if timeliness: @@ -224,7 +226,7 @@ def inspections_list(request): # Compteurs rapides pour les onglets scheduled_count = base_viewable.filter(status='scheduled').filter( - Q(inspector=request.user) | Q(zone__inspectors=request.user) | Q(inspector__isnull=True) + Q(inspector=request.user) | Q(assigned_team__members__user=request.user) | Q(inspector__isnull=True) ).count() my_count = base_viewable.filter(inspector=request.user).exclude(status='scheduled').count() @@ -233,20 +235,20 @@ def inspections_list(request): thematic_code = request.GET.get('thematic', '') status_filter = request.GET.get('status', '') nature_filter = request.GET.get('nature', '') - zone_filter = request.GET.get('zone', '') + zone_filter = request.GET.get('team', '') or request.GET.get('zone', '') mission_id = request.GET.get('mission_id', '') checked_interv_id = request.GET.get('checked_interv_id', '') search_q = request.GET.get('q', '').strip() qs = base_viewable.select_related( 'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention', - 'zone', 'parkingspotinspection', 'giepinspection', 'signpanelinspection' + 'assigned_team', 'assigned_member', 'parkingspotinspection', 'giepinspection', 'signpanelinspection' ).prefetch_related('documents') if filter_type == 'scheduled': qs = qs.filter(status='scheduled') if request.GET.get('all_zones') != '1': - qs = qs.filter(Q(inspector=request.user) | Q(zone__inspectors=request.user) | Q(inspector__isnull=True)) + qs = qs.filter(Q(inspector=request.user) | Q(assigned_team__members__user=request.user) | Q(inspector__isnull=True)) qs = qs.order_by('scheduled_due_date', 'scheduled_start_date', '-id') elif filter_type == 'my': qs = qs.filter(inspector=request.user).exclude(status='scheduled').order_by('-inspection_date', '-id') @@ -254,7 +256,7 @@ def inspections_list(request): qs = qs.order_by('-inspection_date', '-id') if zone_filter: - qs = qs.filter(zone_id=zone_filter) + qs = qs.filter(assigned_team_id=zone_filter) if thematic_code: try: @@ -327,9 +329,8 @@ def inspections_list(request): qs = qs.filter(search_filter) - zones = InspectionZone.objects.filter(is_active=True).order_by('thematic__name_fr', 'name_fr') - if thematics.exists(): - zones = zones.filter(thematic__in=thematics) + all_teams = CompanyTeam.objects.filter(geom__isnull=False).select_related('company').order_by('name') + zones = [t for t in all_teams if t.handles_operation(CompanyTeam.OPERATION_INSPECTIONS)] context = { 'base_template': "mobile/mobile_base.html", @@ -403,9 +404,8 @@ def inspections_list(request): for cfg in result_choices_config ] - zones = InspectionZone.objects.filter(is_active=True).order_by('thematic__name_fr', 'name_fr') - if thematics.exists(): - zones = zones.filter(thematic__in=thematics) + all_teams = CompanyTeam.objects.filter(geom__isnull=False).select_related('company').order_by('name') + zones = [t for t in all_teams if t.handles_operation(CompanyTeam.OPERATION_INSPECTIONS)] context = { 'thematic_choices': [(t.code, t.name, t.icon or 'bi-geo-alt') for t in thematics], @@ -599,6 +599,20 @@ def create_inspection_api(request): }, status=400) try: + # Résolution de l'équipe assignée et du membre si non spécifiés + assigned_team = None + assigned_member = None + team_id_raw = request.POST.get('assigned_team_id') or request.POST.get('team_id') + if team_id_raw: + assigned_team = CompanyTeam.objects.filter(pk=team_id_raw).first() + if not assigned_team: + from .services import resolve_team_and_member_for_inspection + target_geom = getattr(target_obj, 'geom', None) + assigned_team, assigned_member = resolve_team_and_member_for_inspection( + target_geom=target_geom, + scheduled_date=timezone.now().date(), + ) + if is_parking_spot: inspection = ParkingSpotInspection( asset_content_type=content_type, @@ -613,6 +627,8 @@ def create_inspection_api(request): verified_obsolescence=verified_obsolescence, notes=notes, apply_changes_to_asset=apply_changes, + assigned_team=assigned_team, + assigned_member=assigned_member, ) if 'verified_standard_stand_count' in request.POST and request.POST['verified_standard_stand_count'] != '': @@ -665,6 +681,8 @@ def create_inspection_api(request): date_derniere_pluie=date_derniere_pluie, orage_moins_une_heure=orage_moins_une_heure, data=parsed_giep_data, + assigned_team=assigned_team, + assigned_member=assigned_member, ) inspection.save() elif is_signpanel: @@ -726,7 +744,10 @@ def create_inspection_api(request): ai_detected_code=ai_code, ai_confidence=ai_conf, apply_sign_updates_to_asset=apply_changes, + assigned_team=assigned_team, + assigned_member=assigned_member, ) + inspection.save() elif is_hotbox: joint_level = request.POST.get('joint_level') or None joint_sealing = request.POST.get('joint_sealing') or None @@ -756,6 +777,8 @@ def create_inspection_api(request): joint_sealing=joint_sealing, cracking=cracking, water_drainage=water_drainage, + assigned_team=assigned_team, + assigned_member=assigned_member, ) inspection.save() else: @@ -772,6 +795,8 @@ def create_inspection_api(request): verified_obsolescence=verified_obsolescence, notes=notes, apply_changes_to_asset=apply_changes, + assigned_team=assigned_team, + assigned_member=assigned_member, ) # Enregistrement des photos et documents joints @@ -898,8 +923,10 @@ def get_inspection_detail_api(request, inspection_id): 'is_scheduled': leaf.is_scheduled, 'scheduled_start_date': leaf.scheduled_start_date.strftime('%d/%m/%Y') if leaf.scheduled_start_date else '', 'scheduled_due_date': leaf.scheduled_due_date.strftime('%d/%m/%Y') if leaf.scheduled_due_date else '', - 'zone_id': leaf.zone_id, - 'zone_name': leaf.zone.get_name() if leaf.zone else '', + 'assigned_team_id': leaf.assigned_team_id, + 'assigned_team_name': leaf.assigned_team.name if leaf.assigned_team else '', + 'zone_id': leaf.assigned_team_id, + 'zone_name': leaf.assigned_team.name if leaf.assigned_team else '', 'timeliness': leaf.timeliness, 'timeliness_label': str(leaf.timeliness_label) if leaf.timeliness_label else '', 'timeliness_badge_class': leaf.timeliness_badge_class, diff --git a/loko/sign/migrations/0005_alter_signpole_support_type.py b/loko/sign/migrations/0005_alter_signpole_support_type.py new file mode 100644 index 0000000..0adfd2c --- /dev/null +++ b/loko/sign/migrations/0005_alter_signpole_support_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-09-19 08:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sign', '0004_signpanel_support_model_signpanel_trafficlight_pole_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='signpole', + name='support_type', + field=models.CharField(blank=True, choices=[('pole', 'Poteau'), ('lightingpole', "Poteau d'éclairage"), ('trafficlightpole', 'Poteau de signalisation lumineuse'), ('facade', 'Façade'), ('mast', 'Mât'), ('portico', 'Portique'), ('gallows', 'Potence'), ('hopper', 'Trémie'), ('other', 'Autre')], default='pole', max_length=50, verbose_name='Type de support'), + ), + ]