Compare commits

..

2 commits

30 changed files with 1119 additions and 198 deletions

View file

@ -88,6 +88,8 @@ COPY loko/mobile/ /app/mobile/
COPY loko/observations/ /app/observations/
COPY loko/controls/ /app/controls/
COPY loko/interventions/ /app/interventions/
COPY loko/inspections/ /app/inspections/
COPY loko/exports/ /app/exports/
COPY loko/assets/ /app/assets/
COPY loko/panoramax/ /app/panoramax/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -676,6 +676,17 @@
<button type="button" class="btn btn-outline-primary" data-mode="work_week">{% trans "5j (Ouvré)" %}</button>
<button type="button" class="btn btn-primary" data-mode="week">{% trans "1 Semaine (7j)" %}</button>
<button type="button" class="btn btn-outline-primary" data-mode="two_weeks">{% trans "2 Semaines" %}</button>
<button type="button" class="btn btn-outline-primary" data-mode="month">{% trans "1 Mois" %}</button>
</div>
<!-- Sélecteur global de portée d'assignation -->
<div class="d-flex align-items-center gap-1 ms-1">
<span class="text-muted small text-nowrap"><i class="bi bi-clock-history me-1"></i>{% trans "Portée :" %}</span>
<div class="btn-group btn-group-sm" role="group" id="planning-assign-scope-group" title="{% trans "Durée appliquée lors de l'assignation ou du retrait d'un membre" %}">
<button type="button" class="btn btn-primary" data-scope="day">{% trans "Jour" %}</button>
<button type="button" class="btn btn-outline-primary" data-scope="week">{% trans "Semaine" %}</button>
<button type="button" class="btn btn-outline-primary" data-scope="month">{% trans "Mois" %}</button>
</div>
</div>
</div>
@ -862,7 +873,7 @@
<!-- Mode de duplication -->
<div class="mb-3">
<label class="form-label fw-semibold">{% trans "Que souhaitez-vous dupliquer ?" %}</label>
<div class="d-flex gap-3">
<div class="d-flex gap-3 flex-wrap">
<div class="form-check">
<input class="form-check-input" type="radio" name="dupMode" id="dupModeDay" value="day" checked>
<label class="form-check-label" for="dupModeDay">{% trans "Une journée" %}</label>
@ -871,6 +882,10 @@
<input class="form-check-input" type="radio" name="dupMode" id="dupModeWeek" value="week">
<label class="form-check-label" for="dupModeWeek">{% trans "Une semaine complète" %}</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="dupMode" id="dupModeMonth" value="month">
<label class="form-check-label" for="dupModeMonth">{% trans "Un mois complet" %}</label>
</div>
</div>
</div>
@ -879,11 +894,11 @@
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Jour source" %}</label>
<input type="text" class="form-control form-control-sm" id="dupSourceDate">
<input type="date" class="form-control form-control-sm" id="dupSourceDate">
</div>
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Jour cible" %}</label>
<input type="text" class="form-control form-control-sm" id="dupTargetDate">
<input type="date" class="form-control form-control-sm" id="dupTargetDate">
</div>
</div>
</div>
@ -893,11 +908,25 @@
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Semaine source (Lundi)" %}</label>
<input type="text" class="form-control form-control-sm" id="dupSourceWeek">
<input type="date" class="form-control form-control-sm" id="dupSourceWeek">
</div>
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Semaine cible (Lundi)" %}</label>
<input type="text" class="form-control form-control-sm" id="dupTargetWeek">
<input type="date" class="form-control form-control-sm" id="dupTargetWeek">
</div>
</div>
</div>
<!-- Section Mois -->
<div id="dupSectionMonth" class="d-none">
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Mois source" %}</label>
<input type="month" class="form-control form-control-sm" id="dupSourceMonth">
</div>
<div class="col-6">
<label class="form-label small fw-semibold">{% trans "Mois cible" %}</label>
<input type="month" class="form-control form-control-sm" id="dupTargetMonth">
</div>
</div>
</div>
@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -672,8 +672,8 @@ document.addEventListener('DOMContentLoaded', function() {
titleModal.innerHTML = `<i class="bi bi-clipboard-check text-success me-2"></i>{% 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}`;

View file

@ -158,9 +158,9 @@
<i class="bi {{ insp.thematic_icon }} me-1 text-primary"></i>
{{ insp.asset_content_type.name }}
</span>
{% if insp.zone %}
{% if insp.assigned_team %}
<span class="badge bg-light text-secondary border">
<i class="bi bi-geo me-1"></i>{{ insp.zone.name_fr }}
<i class="bi bi-people me-1"></i>{{ insp.assigned_team.name }}
</span>
{% endif %}
</div>
@ -304,10 +304,10 @@
<span class="text-muted"><i class="bi bi-calendar-range me-1"></i>{% translate "Fenêtre d'inspection" %}</span>
<span class="fw-semibold">{{ insp.scheduled_start_date|date:"d/m/Y" }} &rarr; {{ insp.scheduled_due_date|date:"d/m/Y" }}</span>
</li>
{% if insp.zone %}
{% if insp.assigned_team %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-geo me-1"></i>{% translate "District / Zone" %}</span>
<span class="fw-semibold">{{ insp.zone.name_fr }}</span>
<span class="text-muted"><i class="bi bi-people me-1"></i>{% translate "Équipe assignée" %}</span>
<span class="fw-semibold">{{ insp.assigned_team.name }}</span>
</li>
{% endif %}
{% if insp.checked_intervention %}

View file

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

View file

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

View file

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