loko/loko/home/views.py
kdeterme b86d805cb5 feat(contracts,home): include finished status in controller shortcuts and display assigned controller in order interventions
- Add 'finished' status to controller shortcuts widget filter and links
- Add conditional 'Contrôleur assigné' column in order interventions table for contracts with auto_assign_control_team
- Optimize order interventions query with select_related for controllers
- Update and compile translations (fr, nl, en)
- Add unit tests for order detail controller display and widget status inclusion
2026-09-30 18:17:30 +02:00

1451 lines
66 KiB
Python

from django.shortcuts import render, get_object_or_404, redirect
from django.utils import timezone
from django.db.models.functions import Coalesce, TruncDate
from django.db.models import DateTimeField, Q, Count, OuterRef, Subquery
from django.urls import reverse
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required, login_not_required
from datetime import timedelta
from collections import defaultdict
import json
import logging
import time
# logger = logging.getLogger('home.perf')
# def _t(label, t0, splits):
# """Enregistre le temps écoulé depuis t0 et renvoie le nouveau t0."""
# now = time.perf_counter()
# splits.append((label, now - t0))
# return now
def _t(label, t0, splits):
return t0
from interventions.models import Intervention, InterventionTimeLine, STATUS_ORDERS, Symptom
from common.models import UserConfig, UserThematics, UserContractAccess
from contracts.models import CompanyMember, Contract
from contracts.permissions import get_allowed_contracts_for_user
from home.models import HomeWidget
from observations.models import Observation
from stock.models import PreparationOrder, ProductionOrder, StockAlert, PurchaseOrderItem, StockMovement, StockMovementBatch, PurchaseOrder
from interventions.permissions import filter_viewable_interventions_for_user
def index(request):
_perf_splits = []
_perf_total_start = time.perf_counter()
t = time.perf_counter()
# Vérifier les vues accessibles par l'utilisateur
user_config = get_object_or_404(UserConfig, user=request.user)
# Évaluer les vues accessibles une seule fois pour éviter des requêtes multiples
accessible_views_list = list(user_config.get_accessible_views())
t = _t('user_config + accessible_views', t, _perf_splits)
# S'il y a des vues accessibles définies, vérifier si home est accessible
if accessible_views_list:
# Vérifier si l'utilisateur a accès à la vue home
has_home_access = any(v.code == 'asset_management' for v in accessible_views_list)
if not has_home_access:
# Rediriger vers la première vue accessible valide
for first_view in accessible_views_list:
if first_view and first_view.url_name:
try:
return redirect(first_view.url_name)
except NoReverseMatch:
continue
# Resolve widgets for the user
company_member = CompanyMember.objects.filter(
user=request.user
).prefetch_related('teams').first()
user_teams = company_member.teams.all() if company_member else None
user_team = user_teams.first() if user_teams else None
user_contracts = get_allowed_contracts_for_user(request.user)
t = _t('company_member + user_contracts', t, _perf_splits)
# Contrats explicites de l'utilisateur pour la limitation de la page d'accueil
if user_config.limit_shortcuts_to_contracts:
home_contract_ids = list(
UserContractAccess.objects.filter(user_config=user_config)
.values_list('contract_id', flat=True)
)
else:
home_contract_ids = None
resolved_widgets = []
for widget in HomeWidget.objects.filter(is_active=True).prefetch_related(
'condition_groups__conditions'
):
result = widget.resolve_for_user(user_config, user_teams, user_contracts)
if result:
resolved_widgets.append(result)
resolved_widgets.sort(key=lambda r: (r['order'], r['col_order']))
# Group widgets by order into rows; within each row sort by col_order.
widget_rows = []
for w in resolved_widgets:
if widget_rows and widget_rows[-1][0]['order'] == w['order']:
widget_rows[-1].append(w)
else:
widget_rows.append([w])
t = _t('widgets_resolve', t, _perf_splits)
# Si aucune vue n'est définie ou si home est accessible, afficher la page d'accueil
today = timezone.now().date()
start_date = today - timedelta(days=29)
# Initialisation des tableaux
date_labels = [(start_date + timedelta(days=i)).strftime('%d/%m') for i in range(30)]
counters = {
'corrective': [0] * 30,
'preventive': [0] * 30,
'ameliorative': [0] * 30,
}
interventions = filter_viewable_interventions_for_user(request.user)
t = _t('filter_viewable_interventions', t, _perf_splits)
# Restreindre les interventions de la page d'accueil aux contrats explicitement
# autorisés quand la limitation interventions->contrats est active.
if user_config.limit_interventions_to_contracts:
intervention_contract_ids = list(
UserContractAccess.objects.filter(
user_config=user_config,
can_view_interventions=True,
).values_list('contract_id', flat=True)
)
interventions = interventions.filter(contract_id__in=intervention_contract_ids)
# Restreindre les widgets home aux contrats explicites si l'option dédiée est active
if home_contract_ids is not None:
interventions = interventions.filter(contract_id__in=home_contract_ids)
# Filtrer d'abord sur les thématiques avec droits d'édition
user_thematics_with_edit = UserThematics.objects.filter(
user_config=user_config,
can_edit_interventions=True
).select_related('thematic')
editable_thematics = [ut.thematic for ut in user_thematics_with_edit]
if editable_thematics:
interventions = interventions.filter(thematic__in=editable_thematics)
active_thematics = editable_thematics
elif user_config.default_thematic:
interventions = interventions.filter(thematic_id=user_config.default_thematic.id)
active_thematics = [user_config.default_thematic]
else:
active_thematics = []
t = _t('user_thematics_edit', t, _perf_splits)
# GROUP BY (day, maintain_type) côté DB : retourne au max 90 lignes au lieu de N interventions
for _row in (
interventions
.filter(maintain_type__in=['corrective', 'preventive', 'ameliorative'])
.annotate(day=TruncDate(Coalesce('begin_time', 'planned_begin_time', 'expected_begin_time', output_field=DateTimeField())))
.filter(day__gte=start_date, day__lte=today)
.values('day', 'maintain_type')
.annotate(cnt=Count('id'))
.order_by()
):
if _row['day'] is not None and _row['maintain_type'] in counters:
_idx = (_row['day'] - start_date).days
if 0 <= _idx < 30:
counters[_row['maintain_type']][_idx] += _row['cnt']
# Préparer les données pour le template
series_data = {
'dates': date_labels,
'Correctif': counters['corrective'],
'Préventif': counters['preventive'],
'Amélioratif': counters['ameliorative'],
}
t = _t('chart_series_data', t, _perf_splits)
# Compteur des interventions à traiter
# count_to_be_processed_future = _count_to_be_processed_future(interventions)
# Détermine les droits de création d'interventions par type
user_roles = set(user_config.roles.values_list('name', flat=True))
can_create_preventive = bool(user_roles & {'admin', 'manager', 'controller', 'top_manager'})
if not can_create_preventive and 'external_manager' in user_roles:
from interventions.permissions import is_external_manager_with_contract_creation_rights
can_create_preventive = is_external_manager_with_contract_creation_rights(request.user)
can_create_ameliorative = bool(user_roles & {'admin', 'manager', 'controller', 'external_manager', 'operator', 'top_manager'})
is_operator = 'operator' in user_roles
is_admin = 'admin' in user_roles
t = _t('user_roles', t, _perf_splits)
# Symptômes raccourcis pour les opérateurs (basés sur le champ code)
shortcut_symptoms = []
if is_operator or is_admin:
# Codes des symptômes à afficher en raccourci
shortcut_codes = ['TU_ELECTROMECA_DEFECT', 'TL_ORANGE_FLASH', 'URBAN_FURNITURE_POLE_DAMAGED', 'LI_LIGHT_OFF']
symptoms_qs = Symptom.objects.filter(
code__in=shortcut_codes,
is_active=True
).select_related('thematic')
# Filtrer sur les thématiques autorisées si elles existent
if active_thematics:
symptoms_qs = symptoms_qs.filter(thematic__in=active_thematics)
# Restreindre aux thématiques des contrats explicites si l'option est active
if home_contract_ids is not None:
contract_thematic_ids = Contract.objects.filter(
id__in=home_contract_ids
).values_list('thematics__id', flat=True)
symptoms_qs = symptoms_qs.filter(thematic_id__in=contract_thematic_ids)
for symptom in symptoms_qs:
shortcut_symptoms.append({
'id': symptom.id,
'name': symptom.get_name(),
'thematic_code': symptom.thematic.code,
'icon': symptom.thematic.icon or 'bi-wrench',
})
t = _t('shortcut_symptoms', t, _perf_splits)
# ========== Statistiques observations pour les observateurs ==========
from observations.permissions import get_observation_access_context
is_observer = 'observer' in user_roles
obs_counts_by_type = {}
obs_access_ctx = get_observation_access_context(request.user, user_config=user_config)
t = _t('obs_access_context', t, _perf_splits)
if obs_access_ctx is not None:
obs_statuses = [s for s, _ in Observation.STATUS_CHOICES]
obs_types = ['incident', 'inventory', 'remark']
visible_obs_qs = obs_access_ctx.filter_queryset(Observation.objects.all())
agg_kwargs = {}
for obs_type in obs_types:
for obs_status in obs_statuses:
key = f'obs_{obs_type}_{obs_status}'
agg_kwargs[key] = Count('id', filter=Q(observation_type=obs_type, status=obs_status))
obs_agg = visible_obs_qs.aggregate(**agg_kwargs)
for obs_type in obs_types:
obs_counts_by_type[obs_type] = {
obs_status: obs_agg.get(f'obs_{obs_type}_{obs_status}', 0)
for obs_status in obs_statuses
}
t = _t('obs_aggregate', t, _perf_splits)
# ========== Compteur incidents à traiter (widget incident_counter) ==========
if obs_access_ctx is not None:
_incident_base_qs = obs_access_ctx.filter_queryset(
Observation.objects.filter(observation_type='incident', status='to_process')
)
# Filtrer par équipe de l'utilisateur : ne montrer que les observations
# dont le symptôme est attribué à l'équipe de l'utilisateur.
if user_teams:
_incident_base_qs = _incident_base_qs.filter(symptom__teams__in=user_teams).distinct()
else:
# L'utilisateur n'appartient à aucune équipe : exclure les incidents
# dont le symptôme a au moins une équipe désignée.
_incident_base_qs = _incident_base_qs.exclude(symptom__teams__isnull=False)
incident_to_process_count = _incident_base_qs.count()
_incident_qs = _incident_base_qs.select_related('thematic', 'category').order_by('-created_at')
incident_to_process_list = list(_incident_qs[:5])
else:
incident_to_process_count = 0
incident_to_process_list = []
t = _t('incident_list', t, _perf_splits)
# Vérifier permission structures repair intervention
can_add_structures_repair = False
categories = []
if user_config.default_thematic and user_config.default_thematic.code == 'structures':
is_technician = user_config.roles.filter(name='technician').exists()
has_edit_rights = UserThematics.objects.filter(
user_config=user_config,
thematic=user_config.default_thematic,
can_edit_interventions=True
).exists()
can_add_structures_repair = is_technician and has_edit_rights
if can_add_structures_repair:
from assets.models import AssetCategory
categories = list(AssetCategory.objects.filter(thematic=user_config.default_thematic).order_by('name_fr'))
t = _t('structures_repair_perms', t, _perf_splits)
# Construire les codes thématiques pour les liens
thematic_codes = [t_obj.code for t_obj in active_thematics] if active_thematics else []
thematic_filter_param = '&'.join([f'thematic={code}' for code in thematic_codes]) if thematic_codes else ''
# Vérifier si l'utilisateur peut générer le rapport journalier
# Un utilisateur peut être membre de plusieurs équipes ; il suffit qu'une seule
# ait can_generate_daily_report=True pour afficher le bouton.
can_generate_daily_report = CompanyMember.objects.filter(
user=request.user,
teams__can_generate_daily_report=True,
).exists()
t = _t('daily_report_flag', t, _perf_splits)
# Compteurs par type de maintenance — GROUP BY (maintain_type, status, pause_reason) retourne ~50 lignes
# au lieu de 43 COUNT FILTER sur l'ensemble complet du queryset.
_CORRECTIVE_ACTIVE = frozenset([
'in_preparation', 'to_be_approved', 'to_be_planned',
'to_be_processed', 'in_progress', 'on_pause', 'finished',
])
_MT_PFX = {'corrective': 'corr', 'preventive': 'prev', 'ameliorative': 'amel'}
_PER_TYPE_STATUSES = [
'to_be_approved', 'to_be_processed', 'to_be_corrected',
'assigned', 'finished', 'processed', 'corrected', 'to_be_planned',
'in_preparation', 'in_progress',
]
_PAUSE_REASONS = ['reschedule', 'order_material']
_OTHER_PAUSE_REASONS = ['treated_in_next_maintenance', 'other']
_GLOBAL_STATUSES = [
'to_be_approved', 'to_be_processed', 'assigned', 'to_be_planned',
'processed', 'finished', 'to_be_corrected', 'corrected',
]
# Pré-initialisation : garantit que toutes les clés existent même si le queryset
# ne contient aucune ligne pour une combinaison donnée (sinon le template reçoit ''
# au lieu de 0 et blocktranslate lève une TemplateSyntaxError).
mt_counts = defaultdict(int)
for _pfx in _MT_PFX.values():
for _s in _PER_TYPE_STATUSES:
mt_counts[f'count_{_pfx}_{_s}'] = 0
for _r in _PAUSE_REASONS:
mt_counts[f'count_{_pfx}_on_pause_{_r}'] = 0
mt_counts[f'count_{_pfx}_on_pause_other_reasons'] = 0
for _s in _GLOBAL_STATUSES:
mt_counts[f'count_{_s}'] = 0
for _r in _PAUSE_REASONS:
mt_counts[f'count_on_pause_{_r}'] = 0
mt_counts['count_corrective'] = 0
for _row in (
interventions
.values('maintain_type', 'status', 'pause_reason')
.annotate(cnt=Count('id'))
.order_by()
):
_mt, _st, _pr = _row['maintain_type'], _row['status'], _row['pause_reason'] or ''
_n = _row['cnt']
_pfx = _MT_PFX.get(_mt)
_is_pause = _st == 'on_pause' and bool(_pr)
if _pfx:
if _is_pause:
mt_counts[f'count_{_pfx}_on_pause_{_pr}'] += _n
else:
mt_counts[f'count_{_pfx}_{_st}'] += _n
if _mt == 'corrective' and _st in _CORRECTIVE_ACTIVE:
mt_counts['count_corrective'] += _n
if _is_pause:
mt_counts[f'count_on_pause_{_pr}'] += _n
else:
mt_counts[f'count_{_st}'] += _n
# Compteurs "en pause (autre)" : toutes les raisons hors reschedule et order_material
for _pfx in _MT_PFX.values():
mt_counts[f'count_{_pfx}_on_pause_other_reasons'] = sum(
mt_counts[f'count_{_pfx}_on_pause_{_r}'] for _r in _OTHER_PAUSE_REASONS
)
t = _t('mt_counts_aggregate', t, _perf_splits)
# Compteurs non traitées (critère élargi) :
# - planifiée en retard (to_be_processed/assigned avec planned_begin_time dépassé)
# - démarrée depuis > 7 jours
# - prise en charge depuis > 7 jours
_now = timezone.now()
_1_day_ago = _now - timedelta(days=1)
_7_days_ago = _now - timedelta(days=7)
_latest_in_progress_sq = InterventionTimeLine.objects.filter(
intervention=OuterRef('pk'),
to_status='in_progress',
).order_by('-event_time').values('event_time')[:1]
_latest_assigned_sq = InterventionTimeLine.objects.filter(
intervention=OuterRef('pk'),
to_status='assigned',
).order_by('-event_time').values('event_time')[:1]
overdue_agg = interventions.annotate(
_nt_eff_begin=Coalesce('planned_begin_time', 'expected_begin_time', output_field=DateTimeField()),
_nt_last_in_progress=Coalesce(Subquery(_latest_in_progress_sq), 'begin_time', output_field=DateTimeField()),
_nt_last_assigned=Coalesce(Subquery(_latest_assigned_sq), 'transmission_time', 'creation_time', output_field=DateTimeField()),
).filter(
# Planifiée ou prévisionnelle en retard
Q(status__in=['to_be_processed', 'assigned'], _nt_eff_begin__isnull=False, _nt_eff_begin__lt=_1_day_ago)
# Démarrée depuis > 7 jours
| Q(status='in_progress', _nt_last_in_progress__isnull=False, _nt_last_in_progress__lt=_7_days_ago)
# Prise en charge depuis > 7 jours
| Q(status='assigned', _nt_last_assigned__isnull=False, _nt_last_assigned__lt=_7_days_ago)
).aggregate(
total=Count('id'),
corr=Count('id', filter=Q(maintain_type='corrective')),
prev=Count('id', filter=Q(maintain_type='preventive')),
amel=Count('id', filter=Q(maintain_type='ameliorative')),
)
count_overdue = overdue_agg['total']
count_corr_overdue = overdue_agg['corr']
count_prev_overdue = overdue_agg['prev']
count_amel_overdue = overdue_agg['amel']
t = _t('overdue_aggregate', t, _perf_splits)
_three_months = _now + timedelta(days=90)
planned_soon_agg = interventions.filter(
status='to_be_planned',
).filter(
Q(expected_begin_time__isnull=True) | Q(expected_begin_time__lt=_three_months)
).aggregate(
total=Count('id'),
corr=Count('id', filter=Q(maintain_type='corrective')),
prev=Count('id', filter=Q(maintain_type='preventive')),
amel=Count('id', filter=Q(maintain_type='ameliorative')),
)
count_to_be_planned_soon = planned_soon_agg['total']
count_corr_to_be_planned_soon = planned_soon_agg['corr']
count_prev_to_be_planned_soon = planned_soon_agg['prev']
count_amel_to_be_planned_soon = planned_soon_agg['amel']
t = _t('planned_soon_aggregate', t, _perf_splits)
# Vérifier si l'utilisateur doit voir les compteurs "à approuver" scindés (vérifiées / non vérifiées)
show_checked_split = UserContractAccess.objects.filter(
user_config=user_config,
# can_check_interventions=True,
contract__needs_checking=True,
).exists()
if show_checked_split:
split_checked_counts = interventions.filter(status='to_be_approved').aggregate(
count_corr_to_be_approved_checked=Count('id', filter=Q(maintain_type='corrective', is_checked=True)),
count_corr_to_be_approved_unchecked=Count('id', filter=Q(maintain_type='corrective', is_checked=False)),
count_prev_to_be_approved_checked=Count('id', filter=Q(maintain_type='preventive', is_checked=True)),
count_prev_to_be_approved_unchecked=Count('id', filter=Q(maintain_type='preventive', is_checked=False)),
count_amel_to_be_approved_checked=Count('id', filter=Q(maintain_type='ameliorative', is_checked=True)),
count_amel_to_be_approved_unchecked=Count('id', filter=Q(maintain_type='ameliorative', is_checked=False)),
)
else:
split_checked_counts = {}
t = _t('checked_split', t, _perf_splits)
# ========== Compteurs interventions à approuver (widgets d'approbation) ==========
# Récupérer les contrats sur lesquels l'utilisateur a le droit d'approuver
approve_contract_ids = UserContractAccess.objects.filter(
user_config=user_config,
can_approve=True,
).values_list('contract_id', flat=True)
approval_qs = filter_viewable_interventions_for_user(request.user)
if active_thematics:
approval_qs = approval_qs.filter(thematic__in=active_thematics)
elif user_config.default_thematic:
approval_qs = approval_qs.filter(thematic_id=user_config.default_thematic.id)
if home_contract_ids is not None:
approval_qs = approval_qs.filter(
Q(contract_id__in=home_contract_ids) | Q(contract__isnull=True)
)
approval_qs = approval_qs.filter(
status='to_be_approved',
).filter(
Q(contract__in=approve_contract_ids) | Q(contract__isnull=True)
)
# Filtrer par catégories d'approbation si elles sont définies pour l'utilisateur
approval_categories_list = list(user_config.approval_categories.all())
if approval_categories_list:
approval_qs = approval_qs.filter(asset_category__in=approval_categories_list)
approval_counts = approval_qs.aggregate(
total=Count('id'),
checked=Count('id', filter=Q(is_checked=True)),
)
intervention_to_approve_count = approval_counts['total']
intervention_checked_to_approve_count = approval_counts['checked']
intervention_to_approve_list = list(
approval_qs.select_related('thematic', 'asset_category', 'contract', 'contract__company').order_by('contract__contract_number', '-code')
)
intervention_checked_to_approve_list = list(
approval_qs.filter(is_checked=True).select_related('thematic', 'asset_category', 'contract', 'contract__company').order_by('contract__contract_number', '-code')
)
t = _t('approval_counts', t, _perf_splits)
# ========== Données widget stock_summary ==========
_prep_counts = PreparationOrder.objects.aggregate(
active=Count('id', filter=Q(status__in=['to_process', 'pending'])),
to_process=Count('id', filter=Q(status='to_process')),
pending=Count('id', filter=Q(status='pending')),
)
preparation_order_to_process_count = _prep_counts['active']
preparation_order_to_process_only_count = _prep_counts['to_process']
preparation_order_pending_count = _prep_counts['pending']
_prep_qs = PreparationOrder.objects.filter(status__in=['to_process', 'pending'])
preparation_order_to_process_list = list(_prep_qs.order_by('date')[:5])
from stock.permissions import get_user_accessible_warehouses
accessible_warehouses = get_user_accessible_warehouses(request.user)
_prod_base_qs = ProductionOrder.objects.filter(warehouse__in=accessible_warehouses)
_prod_counts = _prod_base_qs.aggregate(
active=Count('id', filter=Q(status__in=['to_process', 'in_production'])),
to_process=Count('id', filter=Q(status=ProductionOrder.STATUS_TO_PROCESS)),
in_production=Count('id', filter=Q(status=ProductionOrder.STATUS_IN_PRODUCTION)),
)
production_order_to_process_count = _prod_counts['active']
production_order_to_process_only_count = _prod_counts['to_process']
production_order_in_production_count = _prod_counts['in_production']
_prod_qs = _prod_base_qs.filter(status__in=['to_process', 'in_production'])
production_order_to_process_list = list(
_prod_qs.select_related('warehouse').order_by('created_at')[:5]
)
_products_with_active_po = PurchaseOrderItem.objects.filter(
purchase_order__status__in=['pending', 'ordered', 'partial']
).values('product_id')
stock_low_alerts_list = list(
StockAlert.objects.filter(is_active=True)
.exclude(product_id__in=_products_with_active_po)
.select_related('product', 'warehouse')
.order_by('-triggered_at')[:5]
)
# ── Articles en prêt non retournés ────────────────────────────────────
_now = timezone.now()
_cutoff_1m = _now - timedelta(days=30)
_cutoff_3m = _now - timedelta(days=90)
_cutoff_6m = _now - timedelta(days=180)
# 1. Tous les mouvements issus de sorties "loan"
_loan_movements = list(
StockMovement.objects
.filter(
batch__movement_type=StockMovementBatch.TYPE_OUT,
batch__movement_subtype='loan',
)
.select_related('product', 'batch', 'batch__person', 'batch__company')
.order_by('batch__date')
)
# 2. Collect product/person/company IDs pour les retours potentiels
_loan_product_ids = {lm.product_id for lm in _loan_movements}
_loan_person_ids = {lm.batch.person_id for lm in _loan_movements if lm.batch.person_id}
_loan_company_ids = {lm.batch.company_id for lm in _loan_movements if lm.batch.company_id}
# 3. Tous les retours potentiels en une seule requête
_returns_by_product_person = defaultdict(list)
_returns_by_product_company = defaultdict(list)
if _loan_product_ids and (_loan_person_ids or _loan_company_ids):
_return_filter = Q()
if _loan_person_ids:
_return_filter |= Q(batch__person_id__in=_loan_person_ids)
if _loan_company_ids:
_return_filter |= Q(batch__company_id__in=_loan_company_ids)
for rm in StockMovement.objects.filter(
product_id__in=_loan_product_ids,
destination_location__isnull=False,
batch__movement_type=StockMovementBatch.TYPE_IN,
).filter(_return_filter).values('product_id', 'batch__person_id', 'batch__company_id', 'date'):
if rm['batch__person_id']:
_returns_by_product_person[(rm['product_id'], rm['batch__person_id'])].append(rm['date'])
if rm['batch__company_id']:
_returns_by_product_company[(rm['product_id'], rm['batch__company_id'])].append(rm['date'])
# 4. Identifier les prêts non retournés (dédoublonnés par batch + produit)
_unreturned_loans = []
_seen_loan_keys = set()
for lm in _loan_movements:
_key = (lm.batch_id, lm.product_id)
if _key in _seen_loan_keys:
continue
_seen_loan_keys.add(_key)
person_id = lm.batch.person_id
company_id = lm.batch.company_id
loan_date = lm.batch.date
if person_id:
is_returned = any(rd > loan_date for rd in _returns_by_product_person.get((lm.product_id, person_id), []))
elif company_id:
is_returned = any(rd > loan_date for rd in _returns_by_product_company.get((lm.product_id, company_id), []))
else:
continue
if not is_returned:
_unreturned_loans.append({
'product': lm.product,
'batch': lm.batch,
'loan_date': loan_date,
'quantity': abs(lm.quantity),
})
_unreturned_loans.sort(key=lambda x: x['loan_date'])
for _u in _unreturned_loans:
_ld = _u['loan_date']
if _ld < _cutoff_6m:
_u['age_css'] = 'bg-dark'
elif _ld < _cutoff_3m:
_u['age_css'] = 'bg-danger'
elif _ld < _cutoff_1m:
_u['age_css'] = 'bg-warning text-dark'
else:
_u['age_css'] = 'bg-light text-muted border'
loan_items_total = len(_unreturned_loans)
loan_items_gt_1m = sum(1 for u in _unreturned_loans if u['loan_date'] < _cutoff_1m)
loan_items_gt_3m = sum(1 for u in _unreturned_loans if u['loan_date'] < _cutoff_3m)
loan_items_gt_6m = sum(1 for u in _unreturned_loans if u['loan_date'] < _cutoff_6m)
loan_items_list = _unreturned_loans[:8]
# ── Commandes en attente ──────────────────────────────────────────────
_po_qs = PurchaseOrder.objects.filter(status__in=['pending', 'ordered', 'partial'])
pending_purchase_orders_count = _po_qs.count()
pending_purchase_orders_list = list(
_po_qs.select_related('supplier', 'warehouse').order_by('-date_ordered')[:5]
)
t = _t('stock_data', t, _perf_splits)
# Raccourci "interventions à vérifier".
# Construit de zéro à partir des contrats où can_check_interventions=True.
# Ce filtre est TOUJOURS actif (quel que soit limit_interventions_to_contracts
# ou le rôle admin) car la permission de vérifier est fonctionnellement liée
# à can_check_interventions sur UserContractAccess.
# Les interventions sans contrat et celles des contrats non autorisés sont
# naturellement exclues (NULL n'est pas dans la liste).
verify_contract_ids = UserContractAccess.objects.filter(
user_config=user_config,
can_check_interventions=True,
).values_list('contract_id', flat=True)
from interventions.models import Intervention as _Intervention
intervention_to_verify_qs = _Intervention.objects.filter(
status='to_be_approved',
contract_id__in=verify_contract_ids,
)
intervention_to_verify_count = intervention_to_verify_qs.count()
intervention_to_verify_list = list(
intervention_to_verify_qs
.select_related('thematic')
.order_by('-code')[:5]
)
intervention_to_plan_list = list(
interventions.filter(status='to_be_planned')
.select_related('thematic')
.order_by('-code')[:5]
)
intervention_finished_qs = interventions.filter(status='finished').exclude(
Q(contract__uses_intervention_manager=True) &
~Q(intervention_manager=request.user)
)
intervention_finished_count = intervention_finished_qs.count()
intervention_finished_list = list(
intervention_finished_qs
.select_related('thematic')
.order_by('-code')[:5]
)
intervention_on_pause_qs = interventions.filter(status='on_pause').exclude(
Q(contract__uses_intervention_manager=True) &
~Q(intervention_manager=request.user)
)
intervention_on_pause_count = intervention_on_pause_qs.count()
intervention_on_pause_list = list(
intervention_on_pause_qs
.select_related('thematic')
.order_by('-code')[:5]
)
if home_contract_ids is not None:
is_manager_filter_applied = user_contracts.filter(
id__in=home_contract_ids, uses_intervention_manager=True
).exists()
else:
is_manager_filter_applied = user_contracts.filter(
uses_intervention_manager=True
).exists()
total_finished_count = interventions.filter(status='finished').count()
total_on_pause_count = interventions.filter(status='on_pause').count()
# ========== Données widget controller_shortcuts ==========
user_role_names = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
controller_filter = Q(status__in=['finished', 'processed', 'corrected'])
if not (user_role_names & {'admin', 'top_manager', 'manager'}):
control_team_ids = list(user_teams.values_list('id', flat=True)) if (user_teams and user_teams.exists()) else []
controller_q = Q(assigned_controller__user=request.user)
if control_team_ids:
controller_q |= Q(assigned_control_team_id__in=control_team_ids)
controller_filter &= controller_q
else:
controller_q = None
controller_interventions_qs = interventions.filter(controller_filter)
controller_interventions_count = controller_interventions_qs.count()
controller_interventions_list = list(
controller_interventions_qs
.select_related('thematic', 'contract', 'assigned_control_team', 'assigned_team')
.order_by('-code')[:5]
)
controller_to_correct_qs = interventions.filter(status='to_be_corrected')
if controller_q is not None:
controller_to_correct_qs = controller_to_correct_qs.filter(controller_q)
controller_to_correct_count = controller_to_correct_qs.count()
controller_validated_qs = interventions.filter(status='validated')
if controller_q is not None:
controller_validated_qs = controller_validated_qs.filter(controller_q)
controller_validated_count = controller_validated_qs.count()
# ========== Données widget external_interventions ==========
user_company_ids = set()
if user_config.company_id:
user_company_ids.add(user_config.company_id)
if user_teams:
user_company_ids.update(user_teams.values_list('company_id', flat=True))
if user_company_ids:
external_q = (
(Q(assigned_provider__isnull=False) & ~Q(assigned_provider_id__in=user_company_ids)) |
(Q(contract__company__isnull=False) & ~Q(contract__company_id__in=user_company_ids)) |
(Q(assigned_team__company__isnull=False) & ~Q(assigned_team__company_id__in=user_company_ids))
)
else:
external_q = (
Q(assigned_provider__isnull=False) |
Q(contract__company__isnull=False) |
Q(assigned_team__company__isnull=False)
)
external_interventions_qs = interventions.filter(external_q).distinct()
external_interventions_total_count = external_interventions_qs.count()
_UNFINISHED_STATUSES = [
'in_preparation', 'to_be_approved', 'to_be_planned',
'to_be_processed', 'assigned', 'in_progress',
'on_pause', 'to_be_corrected'
]
external_unfinished_qs = external_interventions_qs.filter(status__in=_UNFINISHED_STATUSES)
external_interventions_unfinished_count = external_unfinished_qs.count()
external_status_counts = external_unfinished_qs.aggregate(
count_in_preparation=Count('id', filter=Q(status='in_preparation')),
count_to_be_approved=Count('id', filter=Q(status='to_be_approved')),
count_to_be_planned=Count('id', filter=Q(status='to_be_planned')),
count_to_be_processed=Count('id', filter=Q(status='to_be_processed')),
count_assigned=Count('id', filter=Q(status='assigned')),
count_in_progress=Count('id', filter=Q(status='in_progress')),
count_on_pause=Count('id', filter=Q(status='on_pause')),
count_to_be_corrected=Count('id', filter=Q(status='to_be_corrected')),
)
external_interventions_list = list(
external_interventions_qs
.select_related(
'thematic', 'asset_category', 'contract', 'contract__company',
'assigned_provider', 'assigned_team', 'assigned_team__company'
)
.order_by('-creation_time', '-id')[:10]
)
t = _t('external_interventions', t, _perf_splits)
is_inspector = 'inspector' in user_roles
inspection_configs = []
if is_inspector:
from interventions.models import InspectionConfiguration
from interventions.permissions import can_user_create_inspection_for_contract
configs = list(
InspectionConfiguration.objects.select_related('thematic')
.prefetch_related('contracts_providers__contract', 'operation_templates')
)
try:
is_admin = user_config.roles.filter(name='admin').exists()
except Exception:
is_admin = False
for config in configs:
associated_contracts = [cp.contract for cp in config.contracts_providers.all()]
if not associated_contracts:
inspection_configs.append(config)
elif is_admin:
inspection_configs.append(config)
else:
for contract in associated_contracts:
if can_user_create_inspection_for_contract(request.user, contract):
inspection_configs.append(config)
break
t = _t('inspection_configs', t, _perf_splits)
territory_kpis = _get_territory_kpis(request.user, user_contracts)
recent_activity = _get_recent_activity(request.user, user_contracts)
context = {
'territory_kpis': territory_kpis,
'recent_activity': recent_activity,
'is_inspector': is_inspector,
'inspection_configs': inspection_configs,
'series_data': json.dumps(series_data),
**mt_counts,
'count_to_be_processed_future': _count_to_be_processed_future(interventions),
'count_to_be_planned_soon': count_to_be_planned_soon,
'count_corr_overdue': count_corr_overdue,
'count_prev_overdue': count_prev_overdue,
'count_amel_overdue': count_amel_overdue,
'count_corr_to_be_planned_soon': count_corr_to_be_planned_soon,
'count_prev_to_be_planned_soon': count_prev_to_be_planned_soon,
'count_amel_to_be_planned_soon': count_amel_to_be_planned_soon,
'count_overdue': count_overdue,
'is_intern': user_config.is_intern,
'default_thematic_code': user_config.default_thematic.code if user_config.default_thematic else None,
'thematic_filter_param': thematic_filter_param,
'active_thematics': active_thematics,
'can_create_preventive': can_create_preventive,
'can_create_ameliorative': can_create_ameliorative,
'is_operator': is_operator,
'shortcut_symptoms': shortcut_symptoms,
'can_add_structures_repair': can_add_structures_repair,
'categories': categories,
'can_generate_daily_report': can_generate_daily_report,
'widgets': resolved_widgets,
'widget_rows': widget_rows,
'user_contracts': user_contracts,
'user_team': user_team,
'is_observer': is_observer,
'obs_counts_by_type': obs_counts_by_type,
'incident_to_process_count': incident_to_process_count,
'incident_to_process_list': incident_to_process_list,
'intervention_to_verify_count': intervention_to_verify_count,
'intervention_to_verify_list': intervention_to_verify_list,
'intervention_to_plan_count': mt_counts['count_to_be_planned'],
'intervention_to_plan_list': intervention_to_plan_list,
'intervention_finished_count': intervention_finished_count,
'intervention_finished_list': intervention_finished_list,
'intervention_on_pause_count': intervention_on_pause_count,
'intervention_on_pause_list': intervention_on_pause_list,
'is_manager_filter_applied': is_manager_filter_applied,
'total_finished_count': total_finished_count,
'total_on_pause_count': total_on_pause_count,
'intervention_to_approve_count': intervention_to_approve_count,
'intervention_to_approve_list': intervention_to_approve_list,
'intervention_checked_to_approve_count': intervention_checked_to_approve_count,
'intervention_checked_to_approve_list': intervention_checked_to_approve_list,
'show_checked_split': show_checked_split,
**split_checked_counts,
'preparation_order_to_process_count': preparation_order_to_process_count,
'preparation_order_to_process_only_count': preparation_order_to_process_only_count,
'preparation_order_pending_count': preparation_order_pending_count,
'preparation_order_to_process_list': preparation_order_to_process_list,
'production_order_to_process_count': production_order_to_process_count,
'production_order_to_process_only_count': production_order_to_process_only_count,
'production_order_in_production_count': production_order_in_production_count,
'production_order_to_process_list': production_order_to_process_list,
'stock_low_alerts_list': stock_low_alerts_list,
'loan_items_total': loan_items_total,
'loan_items_gt_1m': loan_items_gt_1m,
'loan_items_gt_3m': loan_items_gt_3m,
'loan_items_gt_6m': loan_items_gt_6m,
'loan_items_list': loan_items_list,
'pending_purchase_orders_count': pending_purchase_orders_count,
'pending_purchase_orders_list': pending_purchase_orders_list,
'external_interventions_total_count': external_interventions_total_count,
'external_interventions_unfinished_count': external_interventions_unfinished_count,
'external_status_counts': external_status_counts,
'external_interventions_list': external_interventions_list,
'controller_interventions_count': controller_interventions_count,
'controller_interventions_list': controller_interventions_list,
'controller_to_correct_count': controller_to_correct_count,
'controller_validated_count': controller_validated_count,
}
# ── Logging de performance (désactivé) ────────────────────────────────
# total_ms = (time.perf_counter() - _perf_total_start) * 1000
# lines = [
# f"[home.perf] index() user={request.user.username!r} "
# f"total={total_ms:.0f}ms"
# ]
# for label, elapsed in _perf_splits:
# lines.append(f" {elapsed * 1000:6.1f}ms {label}")
# logger.info('\n'.join(lines))
# ────────────────────────────────────────────────────────────────────────
return render(request, "home/home_index.html", context)
def _count_overdue_interventions(interventions):
"""
Compte les interventions dont la date de début effective est dépassée
et qui sont dans un statut inférieur à "démarré" (in_progress).
La date de début effective est: planned_begin_time ou expected_begin_time.
Les statuts inférieurs à in_progress sont: in_preparation, to_be_approved, to_be_planned, to_be_processed, assigned.
"""
now = timezone.now()
# Statuts inférieurs à "in_progress" et différents de "in_preparation" (status_order < 21)
overdue_statuses = [
'to_be_processed', 'assigned'
]
# Interventions avec une date de début planifiée ou prévue dans le passé
# et qui sont dans un statut inférieur à "démarré"
return interventions.filter(
status__in=overdue_statuses
).annotate(
effective_begin=Coalesce('begin_time', 'planned_begin_time', 'expected_begin_time', output_field=DateTimeField())
).filter(
effective_begin__lt=now, planned_begin_time__isnull=False
).count()
def _count_to_be_processed_future(interventions):
"""
Compte les interventions au statut 'to_be_processed'
"""
now = timezone.now()
# Interventions à traiter
return interventions.filter(
status='to_be_processed'
).annotate(
effective_begin=Coalesce('planned_begin_time', 'expected_begin_time', output_field=DateTimeField())
).filter(
Q(effective_begin__gte=now) | Q(effective_begin__isnull=True)
).count()
def _count_to_be_planned_soon(interventions):
"""
Compte les interventions au statut 'to_be_planned' dont la date prévue (expected_begin_time)
est nulle ou inférieure à aujourd'hui + 3 mois.
"""
three_months_from_now = timezone.now() + timedelta(days=90)
return interventions.filter(
status='to_be_planned'
).filter(
Q(expected_begin_time__isnull=True) | Q(expected_begin_time__lt=three_months_from_now)
).count()
def privacy(request):
return render(request, "home/privacy.html")
@login_required
def incident_count_api(request):
"""JSON endpoint: count + first 5 'incident' observations with status 'to_process'
accessible to the current user. Used by the incident_counter widget for
auto-refresh every minute."""
from observations.permissions import get_observation_access_context
access_ctx = get_observation_access_context(request.user)
if access_ctx is None:
return JsonResponse({'count': 0, 'incidents': []})
qs = access_ctx.filter_queryset(
Observation.objects.filter(observation_type='incident', status='to_process')
.select_related('thematic', 'category')
.order_by('-created_at')
)
# Filtre par équipe : cohérent avec le compteur initial de la home page
user_teams = None
_cm = CompanyMember.objects.filter(user=request.user).prefetch_related('teams').first()
if _cm:
user_teams = _cm.teams.all()
if user_teams:
qs = qs.filter(symptom__teams__in=user_teams).distinct()
else:
# L'utilisateur n'appartient à aucune équipe : exclure les incidents
# dont le symptôme a au moins une équipe désignée.
qs = qs.exclude(symptom__teams__isnull=False)
incidents = [
{
'id': obs.id,
'description': obs.description or '',
'address': obs.address or '',
'category': obs.category.get_name() if obs.category else '',
'thematic': obs.thematic.name if obs.thematic else '',
'created_at': obs.created_at.strftime('%d/%m/%Y %H:%M'),
}
for obs in qs[:5]
]
return JsonResponse({'count': qs.count(), 'incidents': incidents})
def _get_territory_kpis(user, allowed_contracts):
"""
Calcule les métriques du patrimoine pour les contrats autorisés de l'utilisateur.
Mis en cache 1h pour des performances optimales.
Tout est relié au contrat (commune associée au contrat de l'utilisateur).
"""
from django.core.cache import cache
if not allowed_contracts.exists():
return {}
contract_ids = tuple(sorted(allowed_contracts.values_list('id', flat=True)))
cache_key = f"territory_kpis_{'_'.join(map(str, contract_ids))}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
try:
from assets.models.nature import NatureTree, NatureGreenSurface, NatureBench
from assets.models.publiclighting import PublicLightingPole, PublicLightingElectricalCabinet
from assets.models.sign import SignPole
from assets.models.roads import RoadDrain, Roadway, RoadFurniture
from assets.models.clean import CleanLocation, CleanGlassContainer
from assets.models.structures import Structure
from assets.models.buildings import PublicBuilding
from common.models import Municipality
# Comptages des assets réels présents sur le territoire
trees = NatureTree.objects.count()
surfaces = NatureGreenSurface.objects.count()
benches = NatureBench.objects.count()
poles = PublicLightingPole.objects.count()
cabinets = PublicLightingElectricalCabinet.objects.count()
signs = SignPole.objects.count()
drains = RoadDrain.objects.count()
streets = Roadway.objects.count()
clean_bins = CleanGlassContainer.objects.count() + CleanLocation.objects.count()
bridges = Structure.objects.count()
buildings = PublicBuilding.objects.count()
cutoff = timezone.now() - timedelta(days=90)
from interventions.models import Intervention
from observations.models import Observation
from inspections.models import Inspection
from controls.models import Control
n_interv = Intervention.objects.filter(contract__in=allowed_contracts, creation_time__gte=cutoff).count()
n_obs = Observation.objects.filter(intervention__contract__in=allowed_contracts, created_at__gte=cutoff).count()
n_insp = Inspection.objects.filter(mission_intervention__contract__in=allowed_contracts, created_at__gte=cutoff).count()
n_ctrl = Control.objects.filter(interventions__contract__in=allowed_contracts, created_at__gte=cutoff).distinct().count()
recent_actions = n_interv + n_obs + n_insp + n_ctrl
nature_tot = trees + surfaces + benches
lighting_tot = poles + cabinets
sign_tot = signs + 107
roads_tot = drains + streets + clean_bins
struct_tot = bridges + buildings
grand_total = nature_tot + lighting_tot + sign_tot + roads_tot + struct_tot
def fmt(n):
return f"{n:,}".replace(",", " ")
data = {
'grand_total': fmt(grand_total),
'recent_actions_count': fmt(recent_actions),
'nature_total': fmt(nature_tot),
'nature_trees': fmt(trees),
'nature_surfaces': fmt(surfaces),
'nature_benches': fmt(benches),
'lighting_total': fmt(lighting_tot),
'lighting_poles': fmt(poles),
'lighting_cabinets': fmt(cabinets),
'sign_total': fmt(sign_tot),
'sign_poles': fmt(signs),
'parking_locations': "64",
'parking_spots': "43",
'roads_total': fmt(roads_tot),
'roads_drains': fmt(drains),
'roads_streets': fmt(streets),
'clean_bins': fmt(clean_bins),
'structures_total': fmt(struct_tot),
'structures_count': fmt(bridges),
'buildings_count': fmt(buildings),
}
cache.set(cache_key, data, timeout=3600)
return data
except Exception as e:
return {}
def _normalize_thematic_key(thematic_obj=None, fallback_text=None):
if thematic_obj and getattr(thematic_obj, 'code', None):
c = thematic_obj.code.lower()
if c in ('nature',):
return 'nature'
if c in ('publiclighting', 'lighting'):
return 'lighting'
if c in ('sign', 'parking', 'trafficlights', 'mobility'):
return 'sign'
if c in ('roads', 'clean', 'water'):
return 'roads'
if c in ('structures', 'artworks', 'publicbuildings', 'buildings'):
return 'structures'
text = (str(fallback_text) if fallback_text else '').lower()
if any(k in text for k in ['arbre', 'vert', 'pelouse', 'nature', 'haie', 'végétal', 'vegetal', 'banc']):
return 'nature'
if any(k in text for k in ['éclairage', 'eclairage', 'luminaire', 'candelabre', 'candélabre', 'armoire', 'led', 'ampoule', 'borne']):
return 'lighting'
if any(k in text for k in ['panneau', 'marquage', 'signalisation', 'vélo', 'velo', 'parking', 'stationnement', 'feu', 'potelet']):
return 'sign'
if any(k in text for k in ['avaloir', 'égout', 'egout', 'chaussée', 'chaussee', 'trottoir', 'détritus', 'dechet', 'déchet', 'propreté', 'proprete', 'voirie']):
return 'roads'
if any(k in text for k in ['ouvrage', 'pont', 'passerelle', 'mur', 'bâtiment', 'batiment', 'édifice', 'structure']):
return 'structures'
return 'roads'
def _get_recent_activity(user, allowed_contracts):
"""
Rassemble les événements les plus récents des 90 derniers jours
strictement liés aux contrats autorisés pour l'utilisateur.
"""
if not allowed_contracts.exists():
return []
cutoff = timezone.now() - timedelta(days=90)
from interventions.models import Intervention
from observations.models import Observation
from inspections.models import Inspection
from controls.models import Control
items = []
# 1. Interventions
for item in Intervention.objects.filter(contract__in=allowed_contracts, creation_time__gte=cutoff).select_related('thematic').order_by('-creation_time')[:6]:
status_classes = {
'to_be_processed': 'bg-danger-subtle text-danger border border-danger-subtle',
'assigned': 'bg-warning-subtle text-warning-emphasis border border-warning-subtle',
'in_progress': 'bg-primary-subtle text-primary border border-primary-subtle',
'on_pause': 'bg-secondary-subtle text-secondary border border-secondary-subtle',
'finished': 'bg-info-subtle text-info-emphasis border border-info-subtle',
'validated': 'bg-success-subtle text-success-emphasis border border-success-subtle',
}
t_key = _normalize_thematic_key(item.thematic, f"{item.title} {item.description or ''}")
items.append({
'date': item.creation_time,
'type': 'intervention',
'icon': 'bi-tools',
'thematic_key': t_key,
'title': item.title or "Intervention",
'code': item.code or f"INT-{item.id}",
'address': item.address or (item.thematic.name if item.thematic else ""),
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
'url': reverse('interventions:intervention_detail', args=[item.id]),
})
# 2. Observations
for item in Observation.objects.filter(intervention__contract__in=allowed_contracts, created_at__gte=cutoff).select_related('thematic').order_by('-created_at')[:4]:
status_classes = {
'to_process': 'bg-warning-subtle text-warning-emphasis border border-warning-subtle',
'in_progress': 'bg-primary-subtle text-primary border border-primary-subtle',
'processed': 'bg-success-subtle text-success-emphasis border border-success-subtle',
}
t_key = _normalize_thematic_key(item.thematic, f"{item.description or ''}")
items.append({
'date': item.created_at,
'type': 'observation',
'icon': 'bi-eye-fill',
'thematic_key': t_key,
'title': item.description[:55] + "..." if item.description and len(item.description) > 55 else (item.description or "Observation"),
'code': item.code or f"OBS-{item.id}",
'address': item.address or (item.thematic.name if item.thematic else ""),
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
'url': reverse('observations:observation_detail', args=[item.id]),
})
# 3. Inspections
for item in Inspection.objects.filter(mission_intervention__contract__in=allowed_contracts, created_at__gte=cutoff).select_related('mission_intervention', 'mission_intervention__thematic').order_by('-created_at')[:3]:
status_classes = {
'completed': 'bg-success-subtle text-success-emphasis border border-success-subtle',
'scheduled': 'bg-info-subtle text-info-emphasis border border-info-subtle',
}
thematic_obj = item.mission_intervention.thematic if (item.mission_intervention and item.mission_intervention.thematic) else None
t_key = _normalize_thematic_key(thematic_obj, f"{item.nature or ''} {item.notes or ''}")
insp_url = reverse('inspections:inspections_list') + f"?tab=all&inspection_id={item.id}#inspectionDetailModal{item.id}"
items.append({
'date': item.created_at,
'type': 'inspection',
'icon': 'bi-search',
'thematic_key': t_key,
'title': item.nature or "Inspection périodique",
'code': f"INSP-{item.id:04d}",
'address': item.notes[:50] if item.notes else "",
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
'url': insp_url,
})
# 4. Contrôles
for item in Control.objects.filter(interventions__contract__in=allowed_contracts, created_at__gte=cutoff).distinct().order_by('-created_at')[:3]:
status_classes = {
'validated': 'bg-success-subtle text-success-emphasis border border-success-subtle',
'in_progress': 'bg-primary-subtle text-primary border border-primary-subtle',
}
thematic_obj = None
first_interv = item.interventions.select_related('thematic').first()
if first_interv and first_interv.thematic:
thematic_obj = first_interv.thematic
t_key = _normalize_thematic_key(thematic_obj, f"{item.description or ''} {item.control_code or ''}")
items.append({
'date': item.created_at,
'type': 'control',
'icon': 'bi-clipboard-check',
'thematic_key': t_key,
'title': item.description[:55] if item.description else "Contrôle de conformité",
'code': item.control_code or f"CTRL-{item.id}",
'address': item.location or "Court-Saint-Étienne",
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
'url': reverse('controls:controls_detail', args=[item.id]) if hasattr(item, 'id') else "#",
})
now = timezone.now()
items.sort(key=lambda x: x['date'] or now, reverse=True)
recent_items = items[:10]
for it in recent_items:
d = it['date']
if not d:
it['relative_date'] = ""
continue
diff = now - d
if diff.days == 0:
hours = int(diff.total_seconds() // 3600)
it['relative_date'] = "Il y a 1h" if hours <= 1 else f"Il y a {hours}h"
elif diff.days == 1:
it['relative_date'] = "Hier"
elif diff.days < 7:
it['relative_date'] = f"Il y a {diff.days}j"
elif diff.days < 30:
it['relative_date'] = f"Il y a {diff.days // 7} sem."
else:
it['relative_date'] = f"Il y a {diff.days // 30} mois"
return recent_items
@login_required
def activity_geojson(request):
"""
Renvoie le GeoJSON des actions des 90 derniers jours (interventions, observations, inspections, contrôles)
strictement restreintes aux contrats autorisés pour l'utilisateur connecté.
Optimisé avec projection PostGIS directe, élimination des N+1 requêtes et mise en cache.
"""
import hashlib
from django.core.cache import cache
from django.contrib.gis.db.models.functions import Centroid, Transform
from contracts.permissions import get_allowed_contracts_for_user
from interventions.models import Intervention
from observations.models import Observation
from inspections.models import Inspection
from controls.models import Control
allowed_contracts = get_allowed_contracts_for_user(request.user)
if not allowed_contracts.exists():
return JsonResponse({"type": "FeatureCollection", "features": []})
contract_ids = tuple(sorted(allowed_contracts.values_list('id', flat=True)))
cache_key = f"activity_geojson_{hashlib.md5(str(contract_ids).encode()).hexdigest()}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return JsonResponse(cached_data)
cutoff = timezone.now() - timedelta(days=90)
features = []
# URLs de base précalculées pour éviter d'appeler reverse() des milliers de fois
interv_base_url = reverse("interventions:intervention_detail", args=[0]).rstrip('0/')
obs_base_url = reverse("observations:observation_detail", args=[0]).rstrip('0/')
ctrl_base_url = reverse("controls:controls_detail", args=[0]).rstrip('0/')
insp_list_url = reverse("inspections:inspections_list")
# Dictionnaires de libellés de statut pour accès O(1)
interv_status_dict = dict(Intervention._meta.get_field('status').choices or [])
obs_status_dict = dict(Observation._meta.get_field('status').choices or [])
insp_status_dict = dict(Inspection._meta.get_field('status').choices or [])
ctrl_status_dict = dict(Control._meta.get_field('status').choices or [])
# 1. Interventions du contrat (projection EPSG:4326 au niveau PostGIS)
intervs = (
Intervention.objects.filter(
contract__in=allowed_contracts,
creation_time__gte=cutoff
)
.exclude(geom__isnull=True)
.select_related('thematic')
.annotate(geom_4326=Transform(Centroid('geom'), 4326))
.only(
'id', 'code', 'title', 'description', 'status', 'address', 'creation_time',
'thematic__id', 'thematic__name_fr', 'thematic__name_nl', 'thematic__code'
)
)
for item in intervs:
pt = getattr(item, 'geom_4326', None)
if not pt:
continue
t_key = _normalize_thematic_key(item.thematic, f"{item.title} {item.description or ''}")
features.append({
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [round(pt.x, 6), round(pt.y, 6)]
},
"properties": {
"id": f"int-{item.id}",
"type": "intervention",
"type_label": "Intervention",
"thematic_key": t_key,
"thematic": item.thematic.name if item.thematic else "Patrimoine",
"icon": "bi-tools",
"code": item.code or f"INT-{item.id}",
"title": item.title or "-",
"status": item.status,
"status_label": interv_status_dict.get(item.status, item.status),
"address": item.address or "",
"date": item.creation_time.strftime("%d/%m/%Y") if item.creation_time else "",
"url": f"{interv_base_url}/{item.id}/",
}
})
# 2. Observations liées à un contrat autorisé (projection EPSG:4326 PostGIS)
obs = (
Observation.objects.filter(
intervention__contract__in=allowed_contracts,
created_at__gte=cutoff
)
.exclude(geom__isnull=True)
.select_related('thematic')
.annotate(geom_4326=Transform(Centroid('geom'), 4326))
.only(
'id', 'code', 'description', 'status', 'address', 'created_at',
'thematic__id', 'thematic__name_fr', 'thematic__name_nl', 'thematic__code'
)
)
for item in obs:
pt = getattr(item, 'geom_4326', None)
if not pt:
continue
t_key = _normalize_thematic_key(item.thematic, f"{item.description or ''}")
features.append({
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [round(pt.x, 6), round(pt.y, 6)]
},
"properties": {
"id": f"obs-{item.id}",
"type": "observation",
"type_label": "Observation",
"thematic_key": t_key,
"thematic": item.thematic.name if item.thematic else "Patrimoine",
"icon": "bi-eye-fill",
"code": item.code or f"OBS-{item.id}",
"title": item.description[:60] if item.description else "-",
"status": item.status,
"status_label": obs_status_dict.get(item.status, item.status),
"address": item.address or "",
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
"url": f"{obs_base_url}/{item.id}/",
}
})
# 3. Inspections liées à un contrat autorisé (projection EPSG:4326 PostGIS)
insps = (
Inspection.objects.filter(
mission_intervention__contract__in=allowed_contracts,
created_at__gte=cutoff
)
.exclude(mission_intervention__geom__isnull=True)
.select_related('mission_intervention', 'mission_intervention__thematic')
.annotate(geom_4326=Transform(Centroid('mission_intervention__geom'), 4326))
.only(
'id', 'nature', 'notes', 'status', 'created_at',
'mission_intervention__id', 'mission_intervention__address',
'mission_intervention__thematic__id', 'mission_intervention__thematic__name_fr', 'mission_intervention__thematic__name_nl', 'mission_intervention__thematic__code'
)
)
for item in insps:
pt = getattr(item, 'geom_4326', None)
if not pt:
continue
thematic_obj = item.mission_intervention.thematic if (item.mission_intervention and item.mission_intervention.thematic) else None
t_key = _normalize_thematic_key(thematic_obj, f"{item.nature or ''} {item.notes or ''}")
features.append({
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [round(pt.x, 6), round(pt.y, 6)]
},
"properties": {
"id": f"insp-{item.id}",
"type": "inspection",
"type_label": "Inspection",
"thematic_key": t_key,
"thematic": thematic_obj.name if thematic_obj else "Patrimoine",
"icon": "bi-search",
"code": f"INSP-{item.id:04d}",
"title": item.nature or "Inspection",
"status": item.status,
"status_label": insp_status_dict.get(item.status, item.status),
"address": item.mission_intervention.address if item.mission_intervention else "",
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
"url": f"{insp_list_url}?tab=all&inspection_id={item.id}#inspectionDetailModal{item.id}",
}
})
# 4. Contrôles liés à un contrat autorisé (avec prefetch_related pour supprimer les requêtes N+1)
ctrls = (
Control.objects.filter(
interventions__contract__in=allowed_contracts,
created_at__gte=cutoff
)
.distinct()
.exclude(geom__isnull=True)
.prefetch_related('interventions__thematic')
.annotate(geom_4326=Transform(Centroid('geom'), 4326))
.only('id', 'control_code', 'description', 'status', 'location', 'created_at')
)
for item in ctrls:
pt = getattr(item, 'geom_4326', None)
if not pt:
continue
first_interv = next(iter(item.interventions.all()), None)
thematic_obj = first_interv.thematic if (first_interv and first_interv.thematic) else None
t_key = _normalize_thematic_key(thematic_obj, f"{item.description or ''} {item.control_code or ''}")
features.append({
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [round(pt.x, 6), round(pt.y, 6)]
},
"properties": {
"id": f"ctrl-{item.id}",
"type": "control",
"type_label": "Contrôle",
"thematic_key": t_key,
"thematic": thematic_obj.name if thematic_obj else "Patrimoine",
"icon": "bi-clipboard-check",
"code": item.control_code or f"CTRL-{item.id}",
"title": item.description[:60] if item.description else "-",
"status": item.status,
"status_label": ctrl_status_dict.get(item.status, item.status),
"address": item.location or "",
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
"url": f"{ctrl_base_url}/{item.id}/",
}
})
result_data = {
"type": "FeatureCollection",
"features": features
}
cache.set(cache_key, result_data, timeout=120)
return JsonResponse(result_data)