loko/streetup/home/views.py

864 lines
No EOL
38 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()
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)
context = {
'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,
}
# ── 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})