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, TeambuildingVote 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 first_view = accessible_views_list[0] if first_view and first_view.url_name: return redirect(first_view.url_name) # 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'}) 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] ) 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() 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, 'is_manager_filter_applied': is_manager_filter_applied, 'total_finished_count': total_finished_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}) @login_not_required def flexi_teambuilding(request): if request.method == "POST": voter_name = request.POST.get("voter_name", "").strip() or None vote_gael = int(request.POST.get("vote_gael", 0)) vote_charly = int(request.POST.get("vote_charly", 0)) vote_tequila = int(request.POST.get("vote_tequila", 0)) vote_vivien = int(request.POST.get("vote_vivien", 0)) # Validate rating values vote_gael = max(0, min(3, vote_gael)) vote_charly = max(0, min(3, vote_charly)) vote_tequila = max(0, min(3, vote_tequila)) vote_vivien = max(0, min(3, vote_vivien)) TeambuildingVote.objects.create( voter_name=voter_name, vote_gael=vote_gael, vote_charly=vote_charly, vote_tequila=vote_tequila, vote_vivien=vote_vivien ) return redirect(reverse("home:flexi_teambuilding_results") + "?success=1") return render(request, "home/flexi_teambuilding.html") @login_not_required def flexi_teambuilding_results(request): from django.db.models import Avg votes = TeambuildingVote.objects.all().order_by("-created_at") total_votes = votes.count() averages = votes.aggregate( avg_gael=Avg("vote_gael"), avg_charly=Avg("vote_charly"), avg_tequila=Avg("vote_tequila"), avg_vivien=Avg("vote_vivien") ) avg_gael = round(averages["avg_gael"] or 0, 2) avg_charly = round(averages["avg_charly"] or 0, 2) avg_tequila = round(averages["avg_tequila"] or 0, 2) avg_vivien = round(averages["avg_vivien"] or 0, 2) success = request.GET.get("success") == "1" context = { "votes": votes, "total_votes": total_votes, "avg_gael": avg_gael, "avg_charly": avg_charly, "avg_tequila": avg_tequila, "avg_vivien": avg_vivien, "pct_gael": round((avg_gael / 3) * 100, 1) if avg_gael else 0, "pct_charly": round((avg_charly / 3) * 100, 1) if avg_charly else 0, "pct_tequila": round((avg_tequila / 3) * 100, 1) if avg_tequila else 0, "pct_vivien": round((avg_vivien / 3) * 100, 1) if avg_vivien else 0, "success": success } return render(request, "home/flexi_teambuilding_results.html", context)