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