1436 lines
64 KiB
Python
1436 lines
64 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_qs = PreparationOrder.objects.filter(status__in=['to_process', 'pending'])
|
|
preparation_order_to_process_count = _prep_qs.count()
|
|
preparation_order_to_process_list = list(_prep_qs.order_by('-date')[:5])
|
|
|
|
_prod_qs = ProductionOrder.objects.filter(status__in=['to_process', 'in_production'])
|
|
production_order_to_process_count = _prod_qs.count()
|
|
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=['processed', 'corrected'])
|
|
if user_teams and user_teams.exists():
|
|
control_team_ids = list(user_teams.values_list('id', flat=True))
|
|
controller_q = (
|
|
Q(assigned_control_team_id__in=control_team_ids) |
|
|
Q(assigned_controller__user=request.user)
|
|
)
|
|
if not (user_role_names & {'admin', 'top_manager', 'manager'}):
|
|
controller_filter &= controller_q
|
|
|
|
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 user_teams and user_teams.exists() and not (user_role_names & {'admin', 'top_manager', 'manager'}):
|
|
controller_to_correct_qs = controller_to_correct_qs.filter(
|
|
Q(assigned_control_team_id__in=control_team_ids) |
|
|
Q(assigned_controller__user=request.user)
|
|
)
|
|
controller_to_correct_count = controller_to_correct_qs.count()
|
|
|
|
controller_validated_qs = interventions.filter(status='validated')
|
|
if user_teams and user_teams.exists() and not (user_role_names & {'admin', 'top_manager', 'manager'}):
|
|
controller_validated_qs = controller_validated_qs.filter(
|
|
Q(assigned_control_team_id__in=control_team_ids) |
|
|
Q(assigned_controller__user=request.user)
|
|
)
|
|
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_list': preparation_order_to_process_list,
|
|
'production_order_to_process_count': production_order_to_process_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])[:-2]
|
|
obs_base_url = reverse("observations:observation_detail", args=[0])[:-2]
|
|
ctrl_base_url = reverse("controls:controls_detail", args=[0])[:-2]
|
|
insp_list_url = reverse("inspections:inspections_list")
|
|
|
|
# Dictionnaires de libellés de statut pour accès O(1)
|
|
interv_status_dict = dict(Intervention.STATUS_CHOICES)
|
|
obs_status_dict = dict(Observation.STATUS_CHOICES)
|
|
insp_status_dict = dict(getattr(Inspection, 'STATUS_CHOICES', []))
|
|
ctrl_status_dict = dict(getattr(Control, 'STATUS_CHOICES', []))
|
|
|
|
# 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', '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', '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', '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)
|