feat: add monthly team planning scopes and operation types to company teams
This commit is contained in:
parent
4d0682efc1
commit
a0ffebc489
29 changed files with 1117 additions and 198 deletions
|
|
@ -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'
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
),
|
||||
]
|
||||
|
|
@ -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})"
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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" }} → {{ 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 %}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
18
loko/sign/migrations/0005_alter_signpole_support_type.py
Normal file
18
loko/sign/migrations/0005_alter_signpole_support_type.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
Loading…
Reference in a new issue