loko/loko/interventions/permissions.py

1715 lines
No EOL
74 KiB
Python

from django.shortcuts import get_object_or_404
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.utils.translation import gettext as _
from functools import wraps
from datetime import timedelta
from django.utils import timezone
from django.shortcuts import get_object_or_404
from collections import defaultdict
from functools import reduce
from operator import or_
from common.models import (
UserConfig, UserContractAccess, UserThematics, Thematic,
UserThematicStatusPermission, UserContractStatusPermission,
)
from interventions.models import Intervention, InterventionTimeLine, STATUS_ORDERS, STATUS_CHOICES
from contracts.models import CompanyMember
# Ordered list of all status keys (used by get_allowed_statuses_for_user and admin UI)
ALL_STATUS_KEYS = list(STATUS_CHOICES.keys())
# Permissions for viewing intervention
VIEW_ROLE_STATUS_PERMISSIONS = {
'manager': ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed','to_be_corrected', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'],
'technician': ['in_preparation','to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished'],
'external_manager': ['in_preparation','to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed', 'to_be_corrected', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'],
'controller': ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed','to_be_corrected', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'],
'operator': ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed','to_be_corrected', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'],
'admin': ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed','to_be_corrected', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'],
'observer': ['to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed','to_be_corrected', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'],
'viewer': ['to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed','to_be_corrected', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'],
'editor': ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed','to_be_corrected', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'],
'top_manager': ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed','to_be_corrected', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'],
'inspector': ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished', 'processed','to_be_corrected', 'corrected', 'validated', 'invoiced', 'closed', 'canceled'],
}
ALLOWED_TRANSITIONS = {
'in_preparation': ['to_be_approved', 'to_be_planned', 'to_be_processed', 'canceled'],
'to_be_approved': ['to_be_planned', 'to_be_processed', 'canceled'],
'to_be_planned': ['to_be_processed', 'canceled'],
'to_be_processed': ['to_be_planned', 'assigned', 'in_progress', 'processed', 'closed', 'canceled'],
'assigned': ['to_be_processed', 'in_progress', 'canceled'],
'in_progress': ['on_pause', 'finished', 'canceled'],
'on_pause': ['to_be_planned', 'to_be_processed', 'in_progress', 'finished', 'canceled'],
'finished': ['to_be_planned', 'to_be_processed', 'on_pause', 'processed', 'canceled'],
'processed': ['to_be_corrected', 'validated', 'closed'],
'to_be_corrected': ['to_be_planned', 'corrected'],
'corrected': ['to_be_corrected','validated'],
'validated': ['invoiced', 'closed'],
'invoiced': ['closed'],
'closed': [],
'canceled': [],
}
# Droits par rôle = sous-ensemble du graph
ROLE_TRANSITIONS = {
"manager": [
("in_preparation","to_be_approved"),
("in_preparation","to_be_planned"),
("in_preparation","to_be_processed"),
("in_preparation","canceled"),
("to_be_approved","to_be_planned"),
("to_be_approved","to_be_processed"),
("to_be_approved","canceled"),
("to_be_planned", "to_be_processed"),
("to_be_planned", "canceled"),
('to_be_processed', 'canceled'),
("processed","to_be_corrected"),
("processed","validated"),
("corrected","validated"),
("invoiced","closed"),
],
"technician": [
("in_preparation","to_be_approved"),
("in_preparation","to_be_planned"),
("in_preparation","to_be_processed"),
("in_preparation","canceled"),
("to_be_processed","assigned"),
("assigned","in_progress"),
("assigned","to_be_processed"),
("to_be_processed","in_progress"),
("in_progress","on_pause"),
("in_progress","finished"),
("on_pause","in_progress"),
("on_pause","finished"),
],
"external_manager": [
("in_preparation","to_be_approved"),
("in_preparation","to_be_planned"),
("in_preparation","to_be_processed"),
("in_preparation","canceled"),
("to_be_approved","canceled"),
("to_be_planned", "to_be_processed"),
("to_be_planned","canceled"),
("to_be_processed","assigned"),
("to_be_processed","in_progress"),
("to_be_processed","to_be_planned"),
("to_be_processed","canceled"),
("assigned","in_progress"),
("assigned","to_be_processed"),
("assigned","canceled"),
("in_progress","on_pause"),
("in_progress","finished"),
("in_progress","canceled"),
("on_pause","in_progress"),
("on_pause","finished"),
("on_pause","to_be_planned"),
("on_pause","to_be_processed"),
("on_pause","canceled"),
("finished","processed"),
("finished","to_be_processed"),
("finished","on_pause"),
("finished","canceled"),
("to_be_corrected","corrected"),
],
"controller": [
("in_preparation","to_be_approved"),
("in_preparation","to_be_planned"),
("in_preparation","to_be_processed"),
("to_be_approved","to_be_planned"),
("to_be_approved","to_be_processed"),
("to_be_approved","canceled"),
("to_be_planned", "to_be_processed"),
("to_be_planned", "canceled"),
('to_be_processed', 'canceled'),
("processed","validated"),
("processed","to_be_corrected"),
("corrected","to_be_corrected"),
("corrected","validated"),
],
"operator": [
("in_preparation","to_be_approved"),
("in_preparation","to_be_planned"),
("in_preparation","to_be_processed"),
("in_preparation","canceled"),
("to_be_approved","to_be_planned"),
("to_be_approved","to_be_processed"),
("to_be_approved","canceled"),
("to_be_planned", "to_be_processed"),
("to_be_planned", "canceled"),
('to_be_processed', 'canceled'),
],
"admin": "ALL", # interprété comme toutes les transitions du graph
}
TRANSITION_LABEL = {
"to_be_approved": _("Envoyer pour approbation"),
"to_be_planned": _("Envoyer pour planification"),
"to_be_processed": _("Envoyer pour traitement"),
"assigned": _("Prendre en charge"),
"in_progress": _("Démarrer l'intervention"),
"on_pause": _("Mettre en pause"),
"finished": _("Terminer l'intervention"),
"processed": _("Envoyer pour validation"),
"to_be_corrected": _("Demander correction"),
"corrected": _("Corriger"),
"validated": _("Valider"),
"invoiced": _("Facturer"),
"closed": _("Finaliser"),
"canceled": _("Annuler"),
}
def get_allowed_statuses_for_user(user):
"""
Retourne la liste des statuts que l'utilisateur peut voir,
basée sur les nouvelles permissions par statut (thématique + contrat).
"""
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return []
if user_config.roles.filter(name='admin').exists():
return ALL_STATUS_KEYS
statuses = set()
statuses.update(
UserThematicStatusPermission.objects.filter(
user_thematic__user_config=user_config,
can_view=True,
).values_list('status', flat=True)
)
statuses.update(
UserContractStatusPermission.objects.filter(
user_contract__user_config=user_config,
can_view=True,
).values_list('status', flat=True)
)
return list(statuses)
def get_today_intervention_visibility_q(today):
"""
Condition Q pour filtrer les interventions actives le jour 'today' :
1. Planifiée pour aujourd'hui (date exacte ou comprise dans la période planifiée [planned_begin, planned_end])
2. Démarrée aujourd'hui (begin_time__date == today)
3. Travail en cours ou en pause (status in ['in_progress', 'on_pause'])
4. Créée aujourd'hui sans planification préalable (urgences du jour)
"""
return (
Q(planned_begin_time__date=today) |
Q(planned_begin_time__date__lte=today, planned_end_time__date__gte=today) |
Q(begin_time__date=today) |
Q(status__in=['in_progress', 'on_pause']) |
Q(planned_begin_time__isnull=True, begin_time__isnull=True, expected_begin_time__date=today) |
Q(planned_begin_time__isnull=True, begin_time__isnull=True, expected_begin_time__isnull=True, creation_time__date=today)
)
def is_intervention_active_for_today(intervention, today):
"""
Vérifie si une instance d'intervention concerne la journée 'today' :
1. Travail en cours ou en pause
2. Planifiée pour aujourd'hui (date exacte ou comprise dans la période planifiée)
3. Démarrée aujourd'hui
4. Créée aujourd'hui sans planification ni démarrage préalable (urgences du jour)
"""
if intervention.status in ('in_progress', 'on_pause'):
return True
if intervention.planned_begin_time:
p_begin = timezone.localtime(intervention.planned_begin_time).date() if timezone.is_aware(intervention.planned_begin_time) else intervention.planned_begin_time.date()
if intervention.planned_end_time:
p_end = timezone.localtime(intervention.planned_end_time).date() if timezone.is_aware(intervention.planned_end_time) else intervention.planned_end_time.date()
if p_begin <= today <= p_end:
return True
elif p_begin == today:
return True
if intervention.begin_time:
b_date = timezone.localtime(intervention.begin_time).date() if timezone.is_aware(intervention.begin_time) else intervention.begin_time.date()
if b_date == today:
return True
if not intervention.planned_begin_time and not intervention.begin_time:
if intervention.expected_begin_time:
e_date = timezone.localtime(intervention.expected_begin_time).date() if timezone.is_aware(intervention.expected_begin_time) else intervention.expected_begin_time.date()
if e_date == today:
return True
elif intervention.creation_time:
c_date = timezone.localtime(intervention.creation_time).date() if timezone.is_aware(intervention.creation_time) else intervention.creation_time.date()
if c_date == today:
return True
return False
def can_view_intervention(user, intervention):
"""
Vérifie si l'utilisateur peut voir l'intervention.
Priorité : contrat > thématique.
Si une ligne UserContractStatusPermission existe pour ce contrat et ce statut,
c'est cette valeur qui fait foi (même si False) — pas de fallback thématique.
Si aucune ligne n'existe pour ce statut dans le contrat, on tombe sur la thématique.
En dernier recours : prestataire assigné (CompanyMember).
"""
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name='admin').exists():
return True
# -- Exclusion des interventions bloquées pour les utilisateurs externes (prestataires) --
if not getattr(user_config, 'is_intern', False) and getattr(intervention, 'is_blocked_due_to_contract', False):
return False
if intervention.created_by_id == user.id:
return True
# -- Restriction de visibilité quotidienne et par équipe --
company_member = CompanyMember.objects.filter(user=user).prefetch_related('teams').first()
if company_member:
user_roles = set(user_config.roles.values_list('name', flat=True))
privileged_roles = {'admin', 'top_manager', 'manager', 'controller', 'operator', 'external_manager'}
if not (user_roles & privileged_roles):
# 1. Restriction de visibilité quotidienne par équipe
restricted_teams = company_member.teams.filter(restrict_today_interventions_visibility=True)
if restricted_teams.exists():
local_now = timezone.localtime(timezone.now())
today = local_now.date()
# Vérifier la restriction de date (doit concerner aujourd'hui)
if not is_intervention_active_for_today(intervention, today):
return False
# Vérifier la restriction d'heure
start_hours = [t.visibility_start_hour for t in restricted_teams if t.visibility_start_hour is not None]
if start_hours:
min_start_hour = min(start_hours)
if local_now.time() < min_start_hour:
return False
# 2. Restriction aux interventions assignées
unrestricted_teams = company_member.teams.filter(restrict_to_assigned_interventions=False)
unrestricted_companies = set(unrestricted_teams.values_list('company_id', flat=True))
is_assigned_to_user = (intervention.assigned_member == company_member)
is_assigned_to_user_team = (intervention.assigned_team in company_member.teams.all())
is_unrestricted_company = (intervention.assigned_provider_id in unrestricted_companies)
is_team_member_edit_allowed = bool(
intervention.contract
and getattr(intervention.contract, 'allow_team_members_edit', False)
and intervention.assigned_member
and intervention.assigned_member.teams.filter(id__in=company_member.teams.all()).exists()
)
if not (is_assigned_to_user or is_assigned_to_user_team or is_unrestricted_company or is_team_member_edit_allowed):
return False
contract = intervention.contract
thematic = intervention.thematic
status = intervention.status
# -- Priorité contrat --
if contract:
uca = UserContractAccess.objects.filter(
user_config=user_config,
contract=contract,
).first()
if uca:
perm = UserContractStatusPermission.objects.filter(
user_contract=uca,
status=status,
).first()
if perm is not None:
return perm.can_view # ligne définie → valeur finale, pas de fallback thématique
# Aucune ligne pour ce statut → fallback sur les droits par rôle (via l'accès contrat)
uca_roles = set(user_config.roles.values_list('name', flat=True))
for role in uca_roles:
if role in VIEW_ROLE_STATUS_PERMISSIONS and status in VIEW_ROLE_STATUS_PERMISSIONS[role]:
return True
# -- Accès thématique --
if thematic:
ut = UserThematics.objects.filter(
user_config=user_config,
thematic=thematic,
).first()
if ut:
return UserThematicStatusPermission.objects.filter(
user_thematic=ut,
status=status,
can_view=True,
).exists()
# -- Fallback prestataire assigné --
assigned_provider = intervention.assigned_provider
if assigned_provider:
company_member = CompanyMember.objects.filter(user=user).prefetch_related('teams__company').first()
if company_member:
return company_member.teams.filter(company=assigned_provider).exists()
return False
def can_view_intervention_summary(user, intervention):
"""
Vérifie si l'utilisateur peut voir le résumé de l'intervention (vue allégée).
Basé sur les permissions thématique uniquement (accès résumé = droit de visibilité
thématique sur le statut courant).
"""
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name='admin').exists():
return True
if not intervention.thematic:
return False
ut = UserThematics.objects.filter(
user_config=user_config,
thematic=intervention.thematic,
).first()
if not ut:
return False
return UserThematicStatusPermission.objects.filter(
user_thematic=ut,
status=intervention.status,
can_view=True,
).exists()
def intervention_permission_required(view_func):
@wraps(view_func)
def _wrapped_view(request, intervention_id, *args, **kwargs):
intervention = get_object_or_404(Intervention, pk=intervention_id)
if not can_view_intervention(request.user, intervention):
raise PermissionDenied(_("Vous n'avez pas la permission de voir cette intervention."))
return view_func(request, intervention_id, *args, **kwargs)
return _wrapped_view
def can_control_intervention(user, intervention):
"""
Vérifie si l'utilisateur peut contrôler (valider/corriger) l'intervention.
Réservé aux utilisateurs internes ayant le droit d'édition sur le statut courant.
"""
if intervention.status not in ('processed', 'corrected'):
return False
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if not user_config.is_intern:
return False
return can_edit_intervention(user, intervention, user_config=user_config)
def get_allowed_update_fields(user, intervention):
"""
Get the fields that the user can update based on their roles and the intervention's status.
"""
allowed_fields = set()
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return []
if not can_edit_intervention(user, intervention, user_config=user_config):
return []
user_roles = user_config.roles.all().values_list('name', flat=True)
# Si l'utilisateur n'a pas de rôle 'admin', 'operator', 'manager', 'controller', 'external_manager', 'technician' ou 'inspector', il ne peut rien mettre à jour
if not any(role in ['admin', 'operator', 'manager', 'controller', 'external_manager', 'technician', 'inspector'] for role in user_roles):
return []
# Notes et Documents
allowed_fields.update(['notes', 'documents'])
# Opérations (statuts, mesures, commentaires) - uniquement pour technician, external_manager et inspector
if intervention.status_order < STATUS_ORDERS.get('validated') and any(role in user_roles for role in ['technician', 'external_manager', 'admin', 'inspector']):
allowed_fields.add('operations')
# Prestataire, Contrat et Type de maintenance (uniquement en phase initiale)
# Exception : si la thématique autorise la planification sans contrat, ces champs
# restent également modifiables au statut 'to_be_planned'.
_thematic_allows_planning_without_contract = getattr(intervention.thematic, 'allow_planning_without_contract', False)
_initial_statuses = ('in_preparation', 'to_be_approved')
if _thematic_allows_planning_without_contract:
_initial_statuses = ('in_preparation', 'to_be_approved', 'to_be_planned')
if intervention.status in _initial_statuses and (user_config.is_intern or (intervention.contract is None and intervention.created_by_id == user.id)):
allowed_fields.update(['assigned_provider', 'contract', 'maintain_type'])
# Localisation : modifiable par les internes sur toutes les interventions non validées
if intervention.status_order < STATUS_ORDERS.get('validated') and user_config.is_intern:
allowed_fields.update(['location', 'address', 'lat', 'lon', 'intersections'])
# Type d'intervention, Osiris, PMO, Assets
if intervention.status_order < STATUS_ORDERS.get('validated'):
allowed_fields.update(['assets'])
# Type d'intervention, Osiris, PMO, Assets
if intervention.status_order < STATUS_ORDERS.get('validated') and any(role in user_roles for role in ['admin', 'manager', 'controller', 'external_manager', 'inspector']):
allowed_fields.update(['type', 'sub_type', 'osiris', 'pmo_number'])
if intervention.status_order < STATUS_ORDERS.get('validated'):
allowed_fields.add('category')
# Source, Référence, Détail, FMS, Type d'origine et Symptôme
if intervention.status_order < STATUS_ORDERS.get('to_be_processed') and user_config.is_intern:
allowed_fields.update(['source_category', 'source_ref', 'source_detail', 'origin_type', 'symptom'])
from common.hooks import hooks
allowed_fields.update(hooks.get_custom_allowed_fields())
# Priorité : modifiable par les internes tant que le statut n'est pas 'démarré' (in_progress)
if intervention.status_order < STATUS_ORDERS.get('in_progress') and user_config.is_intern:
allowed_fields.add('priority')
# Dossier de référence interne
if intervention.status_order < STATUS_ORDERS.get('validated') and user_config.is_intern and any(role in user_roles for role in ['admin', 'manager', 'controller']):
allowed_fields.update(['linked_folder'])
# Projet
if intervention.status_order < STATUS_ORDERS.get('validated') and user_config.is_intern and any(role in user_roles for role in ['admin', 'manager']):
allowed_fields.update(['project'])
# Réalisation et Planification (pour tous)
if intervention.status_order < STATUS_ORDERS.get('validated') and any(role in user_roles for role in ['admin', 'manager', 'controller', 'external_manager', 'technician', 'inspector']):
allowed_fields.update(['realization', 'planification'])
# Assignation (seulement pour admin, external_manager, technician, inspector - PAS manager)
if intervention.status_order < STATUS_ORDERS.get('validated') and any(role in user_roles for role in ['admin', 'external_manager', 'technician', 'inspector']):
allowed_fields.add('member_assignment')
# Rapports de suivi
if intervention.status_order < STATUS_ORDERS.get('validated') and user_config.is_intern and any(role in user_roles for role in ['admin', 'manager', 'controller']):
allowed_fields.update(['follow_up_reports'])
# Bon de commande et Titre
if intervention.status_order < STATUS_ORDERS.get('validated') and any(role in user_roles for role in ['admin', 'manager', 'controller', 'external_manager', 'inspector']):
allowed_fields.update(['order', 'title'])
# Postes
if intervention.status_order < STATUS_ORDERS.get('validated') and any(role in user_roles for role in ['admin', 'external_manager']):
allowed_fields.update(['posts'])
# Traitement via une autre intervention
if intervention.status_order < STATUS_ORDERS.get('validated') and any(role in user_roles for role in ['admin', 'manager', 'controller', 'external_manager']):
allowed_fields.add('processed_via_other_intervention')
return list(allowed_fields)
def get_visible_maintain_types_for_user(user):
"""
Retourne la liste des codes de types de maintenance visibles pour l'utilisateur.
Si l'utilisateur n'est pas membre d'une équipe ou si le membre n'a pas de restrictions,
retourne None (tous les types sont visibles).
"""
company_member = CompanyMember.objects.prefetch_related('teams', 'visible_maintain_types').filter(user=user).first()
if not company_member:
return None # Pas de restriction si pas membre d'une équipe
return company_member.get_visible_maintain_type_codes()
def apply_maintain_type_filter(queryset, user):
"""
Applique le filtre des types de maintenance visibles au queryset.
Si l'utilisateur n'a pas de restrictions, le queryset est retourné tel quel.
Les interventions assignées à l'utilisateur restent toujours visibles.
"""
visible_types = get_visible_maintain_types_for_user(user)
if visible_types is None:
return queryset # Pas de restriction
# Inclure les interventions du type visible OU celles assignées à l'utilisateur
return queryset.filter(
Q(maintain_type__in=visible_types) | Q(assigned_member__user=user)
)
def get_accessible_interventions_for_technician(user):
"""
Retourne un queryset des interventions que l'utilisateur a le droit de voir :
- Celles qui lui sont directement assignées
- Ou celles qui sont non assignées mais associées à la société à laquelle il appartient
- Filtré par les types de maintenance visibles pour l'utilisateur (sauf interventions assignées)
"""
base_qs = Intervention.objects.all()
company_member = CompanyMember.objects.prefetch_related('teams__company', 'visible_maintain_types').filter(user=user).first()
if company_member:
unrestricted_teams = company_member.teams.filter(restrict_to_assigned_interventions=False)
unrestricted_companies = unrestricted_teams.values_list('company_id', flat=True).distinct()
qs = base_qs.filter(
Q(assigned_member__user=user) |
Q(assigned_team__in=company_member.teams.all(), status__in=['to_be_processed', 'assigned']) |
Q(assigned_member__isnull=True, assigned_team__isnull=True, assigned_provider_id__in=unrestricted_companies, status__in=['to_be_processed', 'assigned'])
)
# Appliquer le filtre des types de maintenance visibles (sauf pour les interventions assignées)
visible_types = company_member.get_visible_maintain_type_codes()
if visible_types is not None:
qs = qs.filter(
Q(maintain_type__in=visible_types) | Q(assigned_member__user=user)
)
# -- Restriction de visibilité quotidienne par équipe --
user_config = UserConfig.objects.filter(user=user).first()
user_roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
privileged_roles = {'admin', 'top_manager', 'manager', 'controller', 'operator', 'external_manager'}
if not (user_roles & privileged_roles):
restricted_teams = company_member.teams.filter(restrict_today_interventions_visibility=True)
if restricted_teams.exists():
local_now = timezone.localtime(timezone.now())
today = local_now.date()
# Vérifier la restriction d'heure
start_hours = [t.visibility_start_hour for t in restricted_teams if t.visibility_start_hour is not None]
if start_hours:
min_start_hour = min(start_hours)
if local_now.time() < min_start_hour:
return base_qs.none()
# Filtrer sur les interventions du jour uniquement
qs = qs.filter(get_today_intervention_visibility_q(today))
return qs
else:
qs = base_qs.filter(assigned_member__user=user)
# -- Restriction de visibilité quotidienne par équipe --
user_config = UserConfig.objects.filter(user=user).first()
user_roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
privileged_roles = {'admin', 'top_manager', 'manager', 'controller', 'operator', 'external_manager'}
if not (user_roles & privileged_roles):
company_member_basic = CompanyMember.objects.filter(user=user).first()
if company_member_basic:
restricted_teams = company_member_basic.teams.filter(restrict_today_interventions_visibility=True)
if restricted_teams.exists():
local_now = timezone.localtime(timezone.now())
today = local_now.date()
# Vérifier la restriction d'heure
start_hours = [t.visibility_start_hour for t in restricted_teams if t.visibility_start_hour is not None]
if start_hours:
min_start_hour = min(start_hours)
if local_now.time() < min_start_hour:
return base_qs.none()
# Filtrer sur les interventions du jour uniquement
qs = qs.filter(get_today_intervention_visibility_q(today))
return qs
def filter_viewable_interventions_for_user(user, limit_to_default_thematic=False):
"""
Renvoie un queryset des interventions visibles par l'utilisateur
selon les règles de can_view_intervention() avec le nouveau système de permissions.
Logique (priorité contrat > thématique) :
- Si une ligne UserContractStatusPermission existe pour (user_config, contrat, statut)
→ la valeur can_view de cette ligne fait foi — pas de fallback thématique.
- Si aucune ligne n'existe pour ce statut dans le contrat
→ fallback sur UserThematicStatusPermission(status).can_view
- Fallback : prestataire assigné (CompanyMember)
"""
try:
user_config = UserConfig.objects.select_related('user').prefetch_related('roles').get(user=user)
except UserConfig.DoesNotExist:
return Intervention.objects.none()
# Admin ou top_manager → accès global
if user_config.roles.filter(name__in=['admin', 'top_manager']).exists():
qs = Intervention.objects.all()
if limit_to_default_thematic and user_config.default_thematic:
qs = qs.filter(thematic=user_config.default_thematic)
return qs
# -- Précharger les permissions depuis les petites tables de permissions --
# Remplace les sous-requêtes corrélées (Exists + OuterRef) par des ensembles
# calculés une seule fois en Python, traduits en conditions Q non-corrélées.
# 1. Permissions contrat-statut pour ce user_config
cp_rows = list(
UserContractStatusPermission.objects.filter(
user_contract__user_config=user_config,
).values_list('user_contract__contract_id', 'status', 'can_view')
)
explicit_cs_allowed = set() # (contract_id, status) avec can_view=True
denied_contracts_by_status = defaultdict(set) # status → contract_ids avec can_view=False
for c_id, st, cv in cp_rows:
if cv:
explicit_cs_allowed.add((c_id, st))
else:
denied_contracts_by_status[st].add(c_id)
# 2. Permissions thématique-statut (can_view=True uniquement)
thematic_ids_by_status = defaultdict(set) # status → set de thematic_id autorisés
for t_id, st in UserThematicStatusPermission.objects.filter(
user_thematic__user_config=user_config,
can_view=True,
).values_list('user_thematic__thematic_id', 'status'):
thematic_ids_by_status[st].add(t_id)
# -- Construire les conditions Q sans sous-requêtes corrélées --
# Règle (priorité contrat > thématique) :
# (a) Ligne contrat can_view=True → visible
# (b) Ligne contrat can_view=False → refusé, pas de fallback
# (c)+(d) Si limit_interventions_to_contracts=False : fallback thématique
# pour toute intervention sans refus explicite de contrat.
conditions = []
# Cas (a) : contrat + can_view=True explicite
for c_id, st in explicit_cs_allowed:
conditions.append(Q(contract_id=c_id, status=st))
# Cas (c)+(d) : fallback thématique — uniquement si l'utilisateur n'est PAS
# limité aux contrats. Quand limit_interventions_to_contracts=True (défaut),
# seules les permissions contrat explicites (cas a) s'appliquent.
if not user_config.limit_interventions_to_contracts:
for st, t_ids in thematic_ids_by_status.items():
if not t_ids:
continue
base_q = Q(status=st, thematic_id__in=sorted(t_ids))
denied_for_st = denied_contracts_by_status.get(st, set())
if denied_for_st:
# Exclure les interventions dont le contrat est explicitement refusé.
# contract=NULL doit être géré séparément : NULL NOT IN (...) = UNKNOWN en SQL.
base_q &= Q(contract__isnull=True) | ~Q(contract_id__in=sorted(denied_for_st))
conditions.append(base_q)
# Fallback prestataire assigné (technicians via CompanyMember)
company_member = (
CompanyMember.objects
.prefetch_related('teams__company', 'visible_maintain_types')
.filter(user=user)
.first()
)
if company_member:
user_roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
privileged_roles = {'admin', 'top_manager', 'manager', 'controller', 'operator', 'external_manager'}
if not (user_roles & privileged_roles):
unrestricted_teams = company_member.teams.filter(restrict_to_assigned_interventions=False)
unrestricted_companies = list(unrestricted_teams.values_list('company_id', flat=True).distinct())
company_q = (
Q(assigned_member=company_member) |
Q(assigned_team__in=list(company_member.teams.all())) |
Q(contract__allow_team_members_edit=True, assigned_member__teams__in=list(company_member.teams.all()))
)
if unrestricted_companies:
company_q |= Q(assigned_member__isnull=True, assigned_team__isnull=True, assigned_provider_id__in=unrestricted_companies)
else:
user_companies = company_member.teams.values_list('company_id', flat=True).distinct()
company_q = Q(assigned_provider_id__in=user_companies)
if user_config.limit_interventions_to_contracts:
# Limiter aux contrats configurés pour cet utilisateur afin d'exclure
# les interventions d'autres contrats de la même compagnie.
allowed_contract_ids = set(
UserContractAccess.objects.filter(
user_config=user_config,
can_view_interventions=True,
).values_list('contract_id', flat=True)
)
company_q &= Q(contract__isnull=True) | Q(contract_id__in=sorted(allowed_contract_ids))
conditions.append(company_q)
conditions.append(Q(created_by=user))
if not conditions:
return Intervention.objects.none()
q = reduce(or_, conditions)
qs = Intervention.objects.filter(q)
if limit_to_default_thematic and user_config.default_thematic:
qs = qs.filter(thematic=user_config.default_thematic)
if company_member:
qs = apply_maintain_type_filter(qs, user)
# -- Restriction de visibilité quotidienne par équipe --
user_roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
privileged_roles = {'admin', 'top_manager', 'manager', 'controller', 'operator', 'external_manager'}
if not (user_roles & privileged_roles):
# 1. Restriction aux interventions assignées par équipe
unrestricted_teams = company_member.teams.filter(restrict_to_assigned_interventions=False)
unrestricted_companies = list(unrestricted_teams.values_list('company_id', flat=True).distinct())
assign_filter = (
Q(assigned_member=company_member) |
Q(assigned_team__in=list(company_member.teams.all())) |
Q(contract__allow_team_members_edit=True, assigned_member__teams__in=list(company_member.teams.all()))
)
if unrestricted_companies:
assign_filter |= Q(assigned_provider_id__in=unrestricted_companies)
qs = qs.filter(assign_filter)
# 2. Restriction de visibilité quotidienne par équipe
restricted_teams = company_member.teams.filter(restrict_today_interventions_visibility=True)
if restricted_teams.exists():
local_now = timezone.localtime(timezone.now())
today = local_now.date()
# Vérifier la restriction d'heure
start_hours = [t.visibility_start_hour for t in restricted_teams if t.visibility_start_hour is not None]
if start_hours:
min_start_hour = min(start_hours)
if local_now.time() < min_start_hour:
return Intervention.objects.none()
# Filtrer sur les interventions du jour uniquement
qs = qs.filter(get_today_intervention_visibility_q(today))
# Exclusion des interventions bloquées pour les utilisateurs externes (prestataires)
if not getattr(user_config, 'is_intern', False):
qs = qs.exclude(is_blocked_due_to_contract=True)
return qs
def can_add_intervention(user, thematic, contract=None):
"""
Vérifie si l'utilisateur peut créer une nouvelle intervention.
La création correspond au statut 'in_preparation' : on vérifie le droit
can_edit sur ce statut dans les permissions thématique ou contrat.
Pour les prestataires (external_manager), le flag can_create_interventions
du contrat est également vérifié (rétrocompatibilité).
"""
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name__in=['admin', 'top_manager']).exists():
return True
# Vérification via permissions thématique
if thematic:
ut = UserThematics.objects.filter(
user_config=user_config,
thematic=thematic,
).first()
if ut:
# can_edit_interventions=True est suffisant pour créer une intervention
# (les opérateurs n'ont pas forcément de UserThematicStatusPermission pour in_preparation)
if ut.can_edit_interventions:
return True
if UserThematicStatusPermission.objects.filter(
user_thematic=ut,
status='in_preparation',
can_edit=True,
).exists():
return True
# Vérification via permissions contrat
if contract:
uca = UserContractAccess.objects.filter(
user_config=user_config,
contract=contract,
).first()
if uca:
if UserContractStatusPermission.objects.filter(
user_contract=uca,
status='in_preparation',
can_edit=True,
).exists():
return True
# Rétrocompatibilité : flag can_create_interventions
if uca.can_create_interventions:
return True
else:
# Pas de contrat spécifié : vérifier si au moins un contrat autorise la création
uca_qs = UserContractAccess.objects.filter(user_config=user_config)
if uca_qs.filter(can_create_interventions=True).exists():
return True
if UserContractStatusPermission.objects.filter(
user_contract__user_config=user_config,
status='in_preparation',
can_edit=True,
).exists():
return True
return False
def get_contracts_for_intervention_creation(user):
"""
Retourne les contrats pour lesquels l'utilisateur peut créer des interventions.
Utilisé pour les external_managers.
"""
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return []
if not user_config.roles.filter(name='external_manager').exists():
return []
contract_ids = UserContractAccess.objects.filter(
user_config=user_config,
can_create_interventions=True
).values_list('contract_id', flat=True)
from contracts.models import Contract
return Contract.objects.filter(id__in=contract_ids, is_active=True)
def intervention_add_permission_required(view_func):
@wraps(view_func)
def _wrapped_view(request, thematic_code=None, *args, **kwargs):
thematic = None
if thematic_code:
thematic = get_object_or_404(Thematic, code=thematic_code)
if not can_add_intervention(request.user, thematic):
raise PermissionDenied(_("Vous n'avez pas la permission d'ajouter une intervention pour cette thématique."))
return view_func(request, thematic_code, *args, **kwargs)
return _wrapped_view
def intervention_add_preventive_permission_required(view_func):
"""
Décorateur pour les vues de création d'intervention préventive.
Seuls les admin, manager, controller, top_manager et les external_managers
avec droit de création d'interventions sur au moins un contrat peuvent créer des
interventions préventives.
"""
@wraps(view_func)
def _wrapped_view(request, thematic_code=None, *args, **kwargs):
thematic = None
if thematic_code:
thematic = get_object_or_404(Thematic, code=thematic_code)
# Vérifie d'abord si l'utilisateur peut ajouter des interventions
if not can_add_intervention(request.user, thematic):
raise PermissionDenied(_("Vous n'avez pas la permission d'ajouter une intervention pour cette thématique."))
# Seuls admin, manager, controller, top_manager, et external_manager avec droits
try:
user_config = UserConfig.objects.get(user=request.user)
user_roles = set(user_config.roles.values_list('name', flat=True))
is_allowed = bool(user_roles & {'admin', 'manager', 'controller', 'top_manager'})
if not is_allowed and 'external_manager' in user_roles:
is_allowed = is_external_manager_with_contract_creation_rights(request.user)
if not is_allowed:
raise PermissionDenied(_("Vous n'avez pas la permission de créer des interventions préventives."))
except UserConfig.DoesNotExist:
raise PermissionDenied(_("Vous n'avez pas la permission de créer des interventions préventives."))
return view_func(request, thematic_code, *args, **kwargs)
return _wrapped_view
def can_add_repair_intervention(user, intervention):
"""
Vérifie si l'utilisateur peut ajouter une intervention de réparation liée à l'intervention donnée.
L'utilisateur doit avoir les droits d'édition sur l'intervention source ET pouvoir créer
une nouvelle intervention (can_edit sur in_preparation).
"""
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name='admin').exists():
return True
# Nécessite le droit d'édition sur l'intervention existante
if not can_edit_intervention(user, intervention, user_config=user_config):
return False
# Et le droit de créer sur la thématique/contrat de cette intervention
return can_add_intervention(user, intervention.thematic, contract=intervention.contract)
def can_edit_intervention(user, intervention, user_config=None):
"""
Vérifie si l'utilisateur peut modifier l'intervention.
Priorité : contrat > thématique.
Si une ligne UserContractStatusPermission existe pour ce contrat et ce statut,
c'est cette valeur qui fait foi (même si False) — pas de fallback thématique.
Si aucune ligne n'existe pour ce statut, fallback sur la thématique.
Pour les techniciens non-privilégiés, la contrainte d'assignation reste en place.
"""
if user_config is None:
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name__in=['admin', 'top_manager']).exists():
return True
# Pour les techniciens sans rôle privilégié, vérifier l'assignation
roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
privileged_roles = {'admin', 'manager', 'controller', 'external_manager', 'operator'}
is_inspector_on_inspection = (intervention.type == 'inspection' and 'inspector' in roles)
if 'inspector' in roles:
# Si l'intervention n'est pas de type 'inspection', le rôle inspector ne permet pas de modifier.
# Mais si l'utilisateur a d'autres rôles d'édition valides, on poursuit.
other_edit_roles = {'admin', 'top_manager', 'manager', 'controller', 'external_manager', 'operator', 'technician'}
if intervention.type != 'inspection' and not (roles & other_edit_roles):
return False
if 'technician' in roles and not (roles & privileged_roles) and not is_inspector_on_inspection:
assigned_member = getattr(intervention, 'assigned_member', None)
assigned_user_id = getattr(assigned_member, 'user_id', None) if assigned_member else None
if assigned_user_id is not None and assigned_user_id != user.id:
# Vérifier si le contrat autorise la modification par les membres de la même équipe
contract = intervention.contract
allow_team_edit = bool(contract and getattr(contract, 'allow_team_members_edit', False))
is_same_team_member = False
if allow_team_edit:
company_member = CompanyMember.objects.filter(user=user).prefetch_related('teams').first()
if company_member:
user_team_ids = set(company_member.teams.values_list('id', flat=True))
if intervention.assigned_team_id and intervention.assigned_team_id in user_team_ids:
is_same_team_member = True
elif assigned_member:
assigned_member_team_ids = set(assigned_member.teams.values_list('id', flat=True))
if user_team_ids & assigned_member_team_ids:
is_same_team_member = True
if not is_same_team_member:
return False
contract = intervention.contract
thematic = intervention.thematic
status = intervention.status
# -- Priorité contrat --
if contract:
uca = UserContractAccess.objects.filter(
user_config=user_config,
contract=contract,
).first()
if uca:
perm = UserContractStatusPermission.objects.filter(
user_contract=uca,
status=status,
).first()
# Droit d'édition explicite sur le statut courant → valeur finale
if perm is not None and perm.can_edit:
return True
# Pas de droit d'édition (ligne absente ou can_edit=False) :
# vérifier si l'utilisateur peut changer le statut vers une cible autorisée
# (ex : 'to_be_processed' → 'assigned' = prise en charge)
target_statuses = set(ALLOWED_TRANSITIONS.get(status, []))
if target_statuses and UserContractStatusPermission.objects.filter(
user_contract=uca,
status__in=target_statuses,
can_change_status_to=True,
).exists():
return True
# Sinon, droits insuffisants (droits explicites obligatoires)
return False
# -- Accès thématique --
if thematic:
ut = UserThematics.objects.filter(
user_config=user_config,
thematic=thematic,
).first()
if ut:
if UserThematicStatusPermission.objects.filter(
user_thematic=ut,
status=status,
can_edit=True,
).exists():
return True
# Si pas de contrat et pas de permission de statut thématique explicite :
if contract is None:
# 1. Le créateur de l'intervention peut toujours la modifier s'il n'y a pas de contrat
if intervention.created_by_id == user.id:
return True
# 2. Utilisateur ayant des droits d'édition sur au moins un contrat de la thématique
if UserContractStatusPermission.objects.filter(
user_contract__user_config=user_config,
user_contract__contract__thematics=thematic,
status=status,
can_edit=True,
).exists():
return True
# 3. Utilisateur ayant des droits d'édition sur au moins un contrat du prestataire assigné
if intervention.assigned_provider_id and UserContractStatusPermission.objects.filter(
user_contract__user_config=user_config,
user_contract__contract__company_id=intervention.assigned_provider_id,
status=status,
can_edit=True,
).exists():
return True
# 4. Si l'utilisateur a can_edit_interventions sur la thématique pour les statuts initiaux
if ut.can_edit_interventions and status in ('in_preparation', 'to_be_approved', 'to_be_planned'):
return True
return False
def intervention_edit_permission_required(view_func):
@wraps(view_func)
def _wrapped_view(request, intervention_id, *args, **kwargs):
intervention = get_object_or_404(Intervention, pk=intervention_id)
if not can_edit_intervention(request.user, intervention):
raise PermissionDenied(_("Vous n'avez pas la permission de modifier cette intervention."))
return view_func(request, intervention_id, *args, **kwargs)
return _wrapped_view
def can_manage_occupations(user, intervention, user_config=None):
if not user.is_authenticated:
return False
if user_config is None:
try:
user_config = (UserConfig.objects
.select_related('user')
.prefetch_related('roles')
.get(user=user))
except UserConfig.DoesNotExist:
user_config = None
roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
if roles.intersection({'admin', 'external_manager'}):
return True
if 'technician' in roles:
company_member = (CompanyMember.objects
.prefetch_related('teams__company')
.filter(user=user)
.first())
if not company_member:
return False
member_team_ids = company_member.teams.values_list('id', flat=True)
if intervention.assigned_member_id == getattr(company_member, 'id', None):
return True
# Autoriser tous les techniciens de l'équipe assignée, même si un membre
# spécifique est aussi assigné (ex : intervention démarrée par un collègue)
if (intervention.assigned_team_id is not None
and intervention.assigned_team_id in member_team_ids):
return True
# Si le contrat autorise la modification par les membres d'équipe, autoriser si partage d'équipe
contract = intervention.contract
if contract and getattr(contract, 'allow_team_members_edit', False):
assigned_member = getattr(intervention, 'assigned_member', None)
if assigned_member:
assigned_member_team_ids = set(assigned_member.teams.values_list('id', flat=True))
if set(member_team_ids) & assigned_member_team_ids:
return True
# Autoriser tous les techniciens qui ont démarré cette intervention, même si elle est ensuite réassignée à une autre équipe ou un autre membre
if InterventionTimeLine.objects.filter(
intervention=intervention,
event_type='status_change',
to_status='in_progress',
event_user=user,
).exists():
return True
return False
return False
def intervention_manage_occupations_required(view_func):
@wraps(view_func)
def _wrapped_view(request, intervention_id, *args, **kwargs):
intervention = get_object_or_404(Intervention, pk=intervention_id)
if not can_manage_occupations(request.user, intervention):
raise PermissionDenied(_("Vous n'avez pas la permission de gérer les occupations pour cette intervention."))
return view_func(request, intervention_id, *args, **kwargs)
return _wrapped_view
def user_allowed_transitions(user, current_status, intervention=None):
"""
Retourne la liste des statuts vers lesquels l'utilisateur peut transitionner
depuis current_status.
Si une intervention est fournie, utilise les permissions spécifiques au contrat
ou à la thématique de cette intervention (priorité contrat > thématique).
Sans intervention, retourne l'union de toutes les permissions de l'utilisateur.
Le résultat est toujours intersecté avec ALLOWED_TRANSITIONS[current_status]
pour garantir la validité du graphe global de transitions.
"""
graph_targets = set(ALLOWED_TRANSITIONS.get(current_status, []))
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return []
if user_config.roles.filter(name='admin').exists():
return sorted(s for s in ALL_STATUS_KEYS if s != current_status)
if not graph_targets:
return []
def _filter_transitions(transitions):
if STATUS_ORDERS.get(current_status, 0) >= STATUS_ORDERS.get('assigned', 15):
transitions = [s for s in transitions if s != 'canceled']
if intervention is not None and getattr(intervention, 'is_contract_blocked', False):
# Le marché est bloqué : interdire les transitions vers l'exécution / le prestataire
blocked_statuses = {'to_be_processed', 'assigned', 'in_progress'}
transitions = [s for s in transitions if s not in blocked_statuses]
return transitions
# -- Cas avec intervention : priorité contrat > thématique --
if intervention is not None:
if intervention.contract_id:
uca = UserContractAccess.objects.filter(
user_config=user_config,
contract_id=intervention.contract_id,
).first()
if uca:
# Récupérer toutes les lignes contrat pour les statuts cibles
contract_rows = {
row.status: row.can_change_status_to
for row in UserContractStatusPermission.objects.filter(
user_contract=uca,
status__in=graph_targets,
)
}
contract_permitted = {s for s, v in contract_rows.items() if v}
# Statuts cibles sans ligne contrat → fallback thématique puis rôle
uncovered = graph_targets - set(contract_rows.keys())
thematic_extra = set()
if uncovered and intervention.thematic_id:
ut = UserThematics.objects.filter(
user_config=user_config,
thematic_id=intervention.thematic_id,
).first()
if ut:
thematic_extra = set(
UserThematicStatusPermission.objects.filter(
user_thematic=ut,
status__in=uncovered,
can_change_status_to=True,
).values_list('status', flat=True)
)
# Réduire uncovered aux statuts sans ligne thématique explicite
thematic_covered = set(
UserThematicStatusPermission.objects.filter(
user_thematic=ut,
status__in=uncovered,
).values_list('status', flat=True)
)
uncovered = uncovered - thematic_covered
# Pour les statuts encore non couverts : fallback sur ROLE_TRANSITIONS
if uncovered:
user_roles_names = set(user_config.roles.values_list('name', flat=True))
for role in user_roles_names:
role_extra = {dst for src, dst in ROLE_TRANSITIONS.get(role, []) if src == current_status}
thematic_extra |= role_extra & uncovered
return sorted(_filter_transitions(graph_targets & (contract_permitted | thematic_extra)))
# Pas d'accès contrat → fallback thématique
if intervention.thematic_id:
ut = UserThematics.objects.filter(
user_config=user_config,
thematic_id=intervention.thematic_id,
).first()
if ut:
permitted = set(
UserThematicStatusPermission.objects.filter(
user_thematic=ut,
can_change_status_to=True,
).values_list('status', flat=True)
)
return sorted(_filter_transitions(graph_targets & permitted))
return []
# -- Sans intervention : union de toutes les permissions de l'utilisateur --
permitted = set()
permitted.update(
UserThematicStatusPermission.objects.filter(
user_thematic__user_config=user_config,
can_change_status_to=True,
).values_list('status', flat=True)
)
permitted.update(
UserContractStatusPermission.objects.filter(
user_contract__user_config=user_config,
can_change_status_to=True,
).values_list('status', flat=True)
)
return sorted(_filter_transitions(graph_targets & permitted))
def can_approve_intervention(user, intervention):
"""
Vérifie si l'utilisateur peut faire passer une intervention du statut 'to_be_approved'
vers un autre statut, dans le cas où un contrat est renseigné.
Avec le nouveau système : l'utilisateur doit avoir can_change_status_to=True
pour au moins un des statuts cibles valides depuis 'to_be_approved'
sur ses permissions contrat. Rétrocompatibilité : can_approve=True est aussi accepté.
"""
if not intervention.contract_id:
return True # Pas de contrat → pas de restriction sur l'approbation
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name='admin').exists():
return True
uca = UserContractAccess.objects.filter(
user_config=user_config,
contract_id=intervention.contract_id,
).first()
if not uca:
return False
# Rétrocompatibilité : flag can_approve
if uca.can_approve:
return True
# Nouveau système : peut transitionner vers un statut cible depuis to_be_approved
approval_targets = set(ALLOWED_TRANSITIONS.get('to_be_approved', []))
return UserContractStatusPermission.objects.filter(
user_contract=uca,
status__in=approval_targets,
can_change_status_to=True,
).exists()
def intervention_requires_approval(intervention):
"""
Détermine si une intervention liée à un contrat nécessite une approbation avant traitement.
Si le contrat a needs_approval=True :
- Si le contrat a approval_except_urgent=True et que l'intervention est urgente (priority='1'),
l'approbation n'est pas requise.
- Sinon, l'approbation est requise (y compris en cas d'urgence si approval_except_urgent=False).
"""
if not intervention or not intervention.contract_id:
return False
contract = getattr(intervention, 'contract', None)
if not contract or not getattr(contract, 'needs_approval', False):
return False
# Si le contrat prévoit 'sauf si urgence' et que l'intervention est urgente
if getattr(contract, 'approval_except_urgent', False) and getattr(intervention, 'priority', None) == '1':
return False
return True
def user_has_explicit_contract_status_permission(user, contract_id, target_status='to_be_processed'):
"""
Vérifie si l'utilisateur a explicitement le droit de passer à ce statut sur ce contrat
via ses permissions de statut de contrat (UserContractStatusPermission.can_change_status_to).
Les administrateurs et superutilisateurs ont tous les droits.
Rétrocompatibilité : can_approve=True sur UserContractAccess confère le droit vers 'to_be_processed'.
"""
if not contract_id or not user or not user.is_authenticated:
return False
if getattr(user, 'is_superuser', False):
return True
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name='admin').exists():
return True
uca = UserContractAccess.objects.filter(
user_config=user_config,
contract_id=contract_id,
).first()
if not uca:
return False
# Permission explicite par statut sur le contrat
if UserContractStatusPermission.objects.filter(
user_contract=uca,
status=target_status,
can_change_status_to=True,
).exists():
return True
# Rétrocompatibilité avec le flag can_approve
if target_status == 'to_be_processed' and uca.can_approve:
return True
return False
def get_allowed_transitions(request, intervention):
current_status = intervention.status
next_candidates = user_allowed_transitions(request.user, current_status, intervention=intervention)
# Si le marché est bloqué, interdire le passage à to_be_processed, assigned, in_progress
if getattr(intervention, 'is_contract_blocked', False):
blocked_statuses = {'to_be_processed', 'assigned', 'in_progress'}
next_candidates = [s for s in next_candidates if s not in blocked_statuses]
# Si l'intervention est au statut 'to_be_approved' et qu'un contrat est renseigné,
# seuls les utilisateurs avec can_approve=True (ou admin) peuvent changer le statut.
if current_status == 'to_be_approved' and intervention.contract_id:
if not can_approve_intervention(request.user, intervention):
return []
# Si l'intervention est en préparation et nécessite une approbation :
# seuls les utilisateurs ayant explicitement le droit de passer au statut 'à traiter'
# sur ce contrat peuvent faire passer dans ce statut directement.
# Pour les autres, seul le passage à 'to_be_approved' (ou 'canceled') est autorisé.
if current_status == 'in_preparation' and intervention_requires_approval(intervention):
if not user_has_explicit_contract_status_permission(request.user, intervention.contract_id, 'to_be_processed'):
next_candidates = [s for s in next_candidates if s in ('to_be_approved', 'canceled')]
# Un external_manager (sans rôle privilégié interne) ne peut annuler que les interventions
# créées par un prestataire (created_by_provider=True). On masque le bouton sinon.
if 'canceled' in next_candidates:
try:
user_config = UserConfig.objects.get(user=request.user)
roles = set(user_config.roles.values_list('name', flat=True))
except UserConfig.DoesNotExist:
roles = set()
is_external_manager_only = (
'external_manager' in roles and
not roles.intersection({'admin', 'manager', 'operator', 'controller'})
)
if is_external_manager_only and not intervention.created_by_provider:
next_candidates = [s for s in next_candidates if s != 'canceled']
items = []
for nxt in next_candidates:
items.append({
"from": current_status,
"to": nxt,
"allowed": True, # Could be used later with transition guards (False + reason if not allowed)
"label": TRANSITION_LABEL.get(nxt) or f"Passer à {nxt}",
"reason": '',
})
return items
def can_edit_note(user, user_config, note):
"""
Règle:
- Admin: peut tout modifier.
- Thématique 'structures': l'auteur OU rôle manager/external_manager (pas de contrainte horaire).
- Sinon: uniquement l'auteur ET la note a été créée il y a < 1h.
"""
if not user.is_authenticated or user_config is None:
return False
# 1) Admin => OK
if user_config.roles.filter(name='admin').exists():
return True
# 2) Thématique 'structures'
intervention = getattr(note, 'intervention', None)
thematic_code = getattr(getattr(intervention, 'thematic', None), 'code', None)
if thematic_code == 'structures':
# Auteur OU rôle manager/external_manager
if note.note_author_id == user.id:
return True
if user_config.roles.filter(name__in=['manager', 'external_manager']).exists():
return True
return False
# 3) Auteur + moins d'une heure (autres thématiques)
if note.note_author_id != user.id:
return False
created_at = getattr(note, 'note_time', None) or getattr(note, 'created_at', None)
if created_at is None:
return False
if timezone.is_naive(created_at):
created_at = timezone.make_aware(created_at, timezone.get_current_timezone())
return (timezone.now() - created_at) <= timedelta(hours=1)
def can_delete_document(user, user_config, document):
"""
Règles de suppression d'un document attaché à une intervention :
- Admin: peut tout supprimer.
- Thématique 'structures': l'auteur OU rôle manager/external_manager (pas de contrainte).
- Sinon (auteur uniquement) :
* Avant 'Pris en charge' (status_order < 15) : toujours autorisé.
* À partir de 'Pris en charge' : autorisé seulement si aucun changement de statut
n'a eu lieu depuis l'upload du document (i.e. le statut n'a pas bougé depuis
que le document a été ajouté).
"""
if not user.is_authenticated or user_config is None:
return False
if user_config.roles.filter(name='admin').exists():
return True
if user_config.is_intern and user_config.roles.filter(name='manager').exists():
return True
intervention = getattr(document, 'intervention', None)
thematic_code = getattr(getattr(intervention, 'thematic', None), 'code', None)
# Thématique 'structures'
if thematic_code == 'structures':
# Auteur OU rôle manager/external_manager
if document.uploaded_by_id == user.id:
return True
if user_config.roles.filter(name__in=['manager', 'external_manager']).exists():
return True
return False
if document.uploaded_by_id != user.id:
return False
if intervention is None:
return False
# Avant 'Pris en charge' : toujours supprimable par l'auteur
if intervention.status_order < STATUS_ORDERS.get('assigned', 15):
return True
# À partir de 'Pris en charge' : supprimable uniquement si aucun changement de statut
# n'a eu lieu depuis que le document a été uploadé.
upload_date = getattr(document, 'upload_date', None)
if upload_date is None:
return False
return not InterventionTimeLine.objects.filter(
intervention=intervention,
event_type='status_change',
event_time__gt=upload_date,
).exists()
# ============================================================================
# Guards pour les fichiers privés (private_media)
# ============================================================================
def guard_intervention_document_file(obj, user):
"""
Guard pour InterventionDocument.file et thumbnail.
L'utilisateur doit pouvoir voir l'intervention associée,
ou au minimum avoir accès au résumé par thématique.
"""
if not user.is_authenticated:
return False
# Peut voir l'intervention en accès complet
if can_view_intervention(user, obj.intervention):
return True
# Ou a accès au résumé par thématique
return can_view_intervention_summary(user, obj.intervention)
def guard_intervention_sheet_file(obj, user):
"""
Guard pour Intervention.intervention_sheet.
L'utilisateur doit pouvoir voir l'intervention.
"""
if not user.is_authenticated:
return False
return can_view_intervention(user, obj)
def guard_operation_photo_file(obj, user):
"""
Guard pour OperationPhoto.photo et thumbnail.
L'utilisateur doit pouvoir voir l'intervention associée à l'opération.
"""
if not user.is_authenticated:
return False
return can_view_intervention(user, obj.operation.intervention)
def guard_operation_photo_file(obj, user):
"""
Guard pour OperationPhoto.photo et thumbnail.
L'utilisateur doit pouvoir voir l'intervention associée à l'opération.
"""
if not user.is_authenticated:
return False
return can_view_intervention(user, obj.operation.intervention)
def guard_guided_operation_media_file(obj, user):
"""
Guard pour GuidedOperationMedia.file.
L'utilisateur doit pouvoir voir l'intervention associée.
"""
if not user.is_authenticated:
return False
return can_view_intervention(user, obj.guided_data.intervention)
def guard_symptom_recommendation_document(obj, user):
"""
Guard pour SymptomRecommendation.document.
Accessible à tout utilisateur authentifié (données de référence).
"""
return user.is_authenticated
# ============================================================================
# Fonctions pour les interventions créées par les prestataires
# ============================================================================
def should_show_created_by_provider_badge(user, intervention):
"""
Vérifie si le badge 'créé par le prestataire' doit être affiché.
Le badge est visible pour les managers et controllers tant que l'intervention
n'a pas été validée.
"""
if not intervention.created_by_provider:
return False
# Le badge n'est plus visible après validation
if intervention.status_order >= STATUS_ORDERS.get('validated', 51):
return False
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
# Visible pour admin, manager, controller et top_manager
return user_config.roles.filter(name__in=['admin', 'manager', 'controller', 'top_manager']).exists()
def is_external_manager_with_contract_creation_rights(user, contract=None):
"""
Vérifie si l'utilisateur est un external_manager avec le droit de créer des interventions.
"""
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if not user_config.roles.filter(name='external_manager').exists():
return False
if contract:
return UserContractAccess.objects.filter(
user_config=user_config,
contract=contract,
can_create_interventions=True
).exists()
return UserContractAccess.objects.filter(
user_config=user_config,
can_create_interventions=True
).exists()
def can_view_correction_messages(user):
"""
Vérifie si l'utilisateur a le droit de voir les messages de correction.
Rôles autorisés: admin, manager, controller, external_manager
"""
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
allowed_roles = ['admin', 'manager', 'controller', 'external_manager']
return user_config.roles.filter(name__in=allowed_roles).exists()
def can_user_create_inspection_for_contract(user, contract):
"""
Vérifie si l'utilisateur détient les droits de changement de statut vers l'un des statuts :
in_preparation, to_be_approved, to_be_planned, assigned, ou in_progress
pour le contrat spécifié. Les administrateurs contournent toutes les restrictions.
"""
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name='admin').exists():
return True
uca = UserContractAccess.objects.filter(
user_config=user_config,
contract=contract,
).first()
if not uca:
return False
target_statuses = ['in_preparation', 'to_be_approved', 'to_be_planned', 'assigned', 'in_progress']
return UserContractStatusPermission.objects.filter(
user_contract=uca,
status__in=target_statuses,
can_change_status_to=True,
).exists()
def can_duplicate_intervention(user, intervention):
"""
Vérifie si l'utilisateur peut dupliquer l'intervention donnée.
L'utilisateur doit pouvoir voir l'intervention source et pouvoir créer une nouvelle intervention
pour la thématique concernée.
"""
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name__in=['admin', 'top_manager']).exists():
return True
if not can_view_intervention(user, intervention):
return False
return can_add_intervention(user, intervention.thematic)