loko/streetup/interventions/views/preventive_import.py
2026-07-22 14:48:40 +02:00

558 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Service d'import d'interventions préventives depuis un fichier Excel.
Ce module expose la fonction principale `process_worksheet` utilisée à la fois
par la commande de management `import_preventive_interventions` et par la vue
d'administration dédiée.
Format du fichier Excel attendu :
- B1 : code de la thématique (ex: 'structures')
- B2 : code du contrat
- B3 : code de la commande (order)
- Ligne 5 : codes de localisations en colonnes (B5, C5, D5, …)
- Colonne A à partir de la ligne 6 : codes de templates d'opérations
- Cellules : 'x', '1' ou 'v' = créer une intervention
"""
from __future__ import annotations
from datetime import datetime
from typing import Callable, Optional
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.geos import MultiPolygon, Point
from django.db.models import QuerySet
from django.utils import timezone
from common.models import Thematic
from contracts.models import Contract, ContractOrder
from interventions.models import (
Intervention,
InterventionAsset,
InterventionLocation,
InterventionPlanificationTimeLine,
InterventionTimeLine,
Operation,
OperationTemplate,
OperationTemplateItem,
STATUS_ORDERS,
)
# ---------------------------------------------------------------------------
# Constantes
# ---------------------------------------------------------------------------
CHECKED_VALUES = {"x", "1", "v", "oui", "yes", "true", "✓", "✔"}
THEMATIC_LOCATION_MODELS = {
'structures': ('assets.models.structures', 'StructureLocation'),
'trafficlights': ('assets.models.trafficlights', 'TrafficLightIntersection'),
'publiclighting': ('assets.models.publiclighting','PublicLightingStreet'),
'nature': ('assets.models.nature', 'NatureLocation'),
'roads': ('assets.models.roads', 'RoadStreet'),
'sign': ('assets.models.sign', 'SignStreet'),
}
# ---------------------------------------------------------------------------
# Helpers — géométrie
# ---------------------------------------------------------------------------
def _to_multipolygon(geom):
"""Convertit une géométrie en MultiPolygon (SRID 3812)."""
if geom is None or (hasattr(geom, 'empty') and geom.empty):
return None
gtype = geom.geom_type
if gtype == 'Polygon':
return MultiPolygon(geom)
if gtype == 'MultiPolygon':
return geom
if gtype == 'GeometryCollection':
polys = [g for g in geom if g.geom_type == 'Polygon']
return MultiPolygon(polys) if polys else None
return None
def _build_geom(location):
geom_raw = getattr(location, 'geom', None)
if geom_raw is None:
lon = getattr(location, 'lon', None)
lat = getattr(location, 'lat', None)
if lon is not None and lat is not None:
pt = Point(lon, lat, srid=4326)
pt.transform(3812)
return _to_multipolygon(pt.buffer(1))
return None
if geom_raw.geom_type in ('Point', 'MultiPoint', 'LineString', 'MultiLineString'):
return _to_multipolygon(geom_raw.buffer(1))
return _to_multipolygon(geom_raw)
# ---------------------------------------------------------------------------
# Helpers — assets enfants
# ---------------------------------------------------------------------------
def _flatten_asset_collection(collection):
"""Aplatit récursivement une collection d'assets (dict, list, QuerySet, …)."""
if collection is None:
return
if isinstance(collection, dict):
for value in collection.values():
yield from _flatten_asset_collection(value)
return
if isinstance(collection, (list, tuple, set)):
for value in collection:
yield from _flatten_asset_collection(value)
return
if isinstance(collection, QuerySet):
for item in collection.iterator():
yield item
return
yield collection
def _extract_assets_from_location(location):
"""Retourne les assets actifs d'une localisation (via get_active_assets)."""
getter = getattr(location, 'get_active_assets', None)
if not callable(getter):
return []
return _flatten_asset_collection(getter())
def _asset_matches_template_filters(asset_obj, template, allow_unfiltered: bool = True) -> bool:
"""
Vérifie si un asset correspond aux filtres catégorie/modèle d'un template.
- allow_unfiltered=True : si aucun filtre n'est défini, l'asset est accepté
(cas : asset sélectionné explicitement par l'utilisateur)
- allow_unfiltered=False : si aucun filtre n'est défini, l'asset est rejeté
(cas : parcours automatique des assets enfants d'une localisation)
"""
category_ids = template.get_category_ids()
model_filters = template.get_asset_model_filters()
if not category_ids and not model_filters:
return allow_unfiltered
if category_ids:
asset_category = getattr(asset_obj, 'category', None)
if not asset_category or asset_category.id not in category_ids:
return False
if model_filters:
asset_model = getattr(asset_obj, 'model', None)
if not asset_model:
return False
model_name = asset_model._meta.model_name.lower()
allowed_ids = model_filters.get(model_name)
if not allowed_ids or asset_model.pk not in allowed_ids:
return False
return True
def _get_templates_with_equipment_filters(templates):
"""Retourne uniquement les templates ayant des filtres catégorie ou modèle."""
return [t for t in templates if t.get_category_ids() or t.get_asset_model_filters()]
# ---------------------------------------------------------------------------
# Helpers — opérations
# ---------------------------------------------------------------------------
def _add_operations_from_template(
intervention: Intervention,
template: OperationTemplate,
asset_obj=None,
asset_ct=None,
only_generic: Optional[bool] = None,
log: Optional[Callable] = None,
):
"""
Crée les opérations d'un template pour une intervention.
Args:
only_generic : True → uniquement génériques ; False → uniquement équipement ;
None → toutes (en respectant is_generic).
asset_obj / asset_ct : requis quand only_generic=False.
"""
items = OperationTemplateItem.objects.filter(template=template).order_by('order')
# Dédoublonnage génériques
existing_generic = set(
Operation.objects.filter(
intervention=intervention,
template_group=template.name,
is_generic=True,
).values_list('name_fr', flat=True)
)
# Dédoublonnage équipement
existing_equipment = set()
if asset_obj is not None and asset_ct is not None:
existing_equipment = set(
Operation.objects.filter(
intervention=intervention,
template_group=template.name,
asset_content_type=asset_ct,
asset_object_id=asset_obj.pk,
is_generic=False,
).values_list('name_fr', flat=True)
)
ops = []
for item in items:
if only_generic is True and not item.is_generic:
continue
if only_generic is False and item.is_generic:
continue
if item.is_generic:
if item.name_fr in existing_generic:
continue
else:
if item.name_fr in existing_equipment:
continue
op = Operation(
intervention=intervention,
name_fr=item.name_fr,
name_nl=item.name_nl,
status_set=item.status_set,
measurement_definition=item.measurement_definition,
order=item.order,
template_group=template.name,
requires_photo=item.requires_photo,
is_generic=item.is_generic,
)
if not item.is_generic and asset_obj is not None and asset_ct is not None:
op.asset_content_type = asset_ct
op.asset_object_id = asset_obj.pk
ops.append(op)
if ops:
Operation.objects.bulk_create(ops)
if log:
scope = "génériques" if only_generic else ("équipement" if only_generic is False else "")
log(f" → {len(ops)} opération(s) {scope} créée(s) depuis '{template.name}'")
return ops
# ---------------------------------------------------------------------------
# Helpers — thématique / localisation
# ---------------------------------------------------------------------------
def resolve_location_model(thematic_code: str):
"""
Retourne la classe modèle de localisation correspondant au code thématique.
Lève ValueError si le code est inconnu.
"""
entry = THEMATIC_LOCATION_MODELS.get(thematic_code)
if entry is None:
raise ValueError(
f"Thématique inconnue : '{thematic_code}'. "
f"Valeurs acceptées : {', '.join(THEMATIC_LOCATION_MODELS.keys())}."
)
module_path, class_name = entry
import importlib
mod = importlib.import_module(f"assets.models.{module_path.split('.')[-1]}")
return getattr(mod, class_name)
def _build_title(location, template) -> str:
loc_code = getattr(location, 'code', '') or ''
loc_name = location.get_name() if hasattr(location, 'get_name') else ''
tmpl_name = template.name or ''
if loc_code and loc_name:
return f"{loc_code} - {tmpl_name} - {loc_name}"
if loc_code:
return f"{loc_code} - {tmpl_name}"
if loc_name:
return f"{tmpl_name} - {loc_name}"
return tmpl_name
# ---------------------------------------------------------------------------
# Lecture Excel
# ---------------------------------------------------------------------------
def cell_is_checked(value) -> bool:
if value is None:
return False
return str(value).strip().lower() in CHECKED_VALUES
def cell_value(ws, row: int, col: int):
try:
return ws.cell(row=row, column=col).value
except Exception:
return None
# ---------------------------------------------------------------------------
# Fonction principale
# ---------------------------------------------------------------------------
class ImportResult:
"""Résultat de l'import."""
def __init__(self):
self.created: int = 0
self.skipped: int = 0
self.warnings: list[str] = []
self.errors: list[str] = []
self.dry_run: bool = False
def process_worksheet(
ws,
user=None,
expected_begin=None,
expected_end=None,
status: str = 'in_preparation',
dry_run: bool = False,
log: Optional[Callable] = None,
) -> ImportResult:
"""
Traite une feuille Excel et crée les interventions préventives.
Args:
ws : feuille openpyxl (openpyxl.worksheet.worksheet.Worksheet)
user : instance User (created_by) ou None
expected_begin: datetime aware ou None
expected_end : datetime aware ou None
status : code statut des interventions à créer
dry_run : si True, aucune écriture en base
log : callable(message) pour les logs (optionnel)
Returns:
ImportResult
"""
result = ImportResult()
result.dry_run = dry_run
def _log(msg):
if log:
log(msg)
# --- Lecture des métadonnées ---
thematic_code = cell_value(ws, 1, 2) # B1
contract_code = cell_value(ws, 2, 2) # B2
order_code = cell_value(ws, 3, 2) # B3
if not thematic_code:
result.errors.append("Cellule B1 vide — code thématique manquant.")
return result
thematic_code = str(thematic_code).strip()
_log(f"Thématique : {thematic_code}")
# --- Résolution des objets DB ---
thematic = Thematic.objects.filter(code=thematic_code).first()
if thematic is None:
result.errors.append(f"Thématique '{thematic_code}' introuvable en base.")
return result
contract = None
if contract_code:
contract = Contract.objects.filter(contract_number=str(contract_code).strip()).first()
if contract is None:
result.warnings.append(
f"Contrat '{contract_code}' introuvable — interventions créées sans contrat."
)
order = None
if order_code:
qs = ContractOrder.objects.filter(order_code=str(order_code).strip())
if contract:
qs = qs.filter(contract=contract)
order = qs.first()
if order is None:
result.warnings.append(
f"Commande '{order_code}' introuvable — interventions créées sans commande."
)
try:
LocationModel = resolve_location_model(thematic_code)
except ValueError as e:
result.errors.append(str(e))
return result
location_ct = ContentType.objects.get_for_model(LocationModel)
# --- Ligne 5 : codes de localisations ---
location_codes = [] # [(col_index, code)]
for col in range(2, ws.max_column + 1):
val = cell_value(ws, 5, col)
if val is not None and str(val).strip():
location_codes.append((col, str(val).strip()))
if not location_codes:
result.errors.append("Aucun code de localisation trouvé en ligne 5 (à partir de B5).")
return result
# --- Colonne A à partir de la ligne 6 : codes de templates ---
template_codes = [] # [(row_index, code)]
for row in range(6, ws.max_row + 1):
val = cell_value(ws, row, 1)
if val is not None and str(val).strip():
template_codes.append((row, str(val).strip()))
if not template_codes:
result.errors.append("Aucun code de template trouvé en colonne A (à partir de A6).")
return result
_log(f"Localisations : {len(location_codes)} | Templates : {len(template_codes)}")
# --- Pré-chargement ---
all_loc_codes = [c for _, c in location_codes]
all_tmpl_codes = [c for _, c in template_codes]
locations_map = {
loc.code: loc
for loc in LocationModel.objects.filter(code__in=all_loc_codes)
}
templates_map = {
t.code: t
for t in OperationTemplate.objects.prefetch_related(
'items', 'categories', 'asset_model_filters'
).filter(code__in=all_tmpl_codes)
}
for code in all_loc_codes:
if code not in locations_map:
result.warnings.append(f"Localisation '{code}' introuvable — ignorée.")
for code in all_tmpl_codes:
if code not in templates_map:
result.warnings.append(f"Template '{code}' introuvable — ignoré.")
# --- Parcours et création ---
now = timezone.now()
for row_idx, tmpl_code in template_codes:
template = templates_map.get(tmpl_code)
if template is None:
continue
templates_with_filters = _get_templates_with_equipment_filters([template])
for col_idx, loc_code in location_codes:
if not cell_is_checked(cell_value(ws, row_idx, col_idx)):
result.skipped += 1
continue
location = locations_map.get(loc_code)
if location is None:
result.skipped += 1
continue
_log(f" ✔ template='{tmpl_code}' × localisation='{loc_code}'")
if dry_run:
result.created += 1
continue
# --- Création de l'intervention ---
title = _build_title(location, template)
geom = _build_geom(location)
lon = getattr(location, 'lon', None)
lat = getattr(location, 'lat', None)
intervention = Intervention(
title=title,
status=status,
status_order=STATUS_ORDERS.get(status, 1),
contract=contract,
order=order,
assigned_provider=contract.company if contract else None,
maintain_type="preventive",
type="care",
created_by=user,
thematic=thematic,
location_code=getattr(location, 'code', None) or None,
geom=geom,
)
if lon is not None:
intervention.lon = lon
if lat is not None:
intervention.lat = lat
if expected_begin:
intervention.expected_begin_time = expected_begin
if expected_end:
intervention.expected_end_time = expected_end
intervention.save()
# Lien template d'opérations
intervention.equipment_operation_templates.add(template)
# Timelines
InterventionTimeLine.objects.create(
intervention=intervention,
event_user=user,
event_time=now,
event_type='creation',
from_status=None,
to_status=intervention.status,
)
if expected_begin:
InterventionPlanificationTimeLine.objects.create(
intervention=intervention,
event_user=user,
event_type='expected_begin',
event_time=expected_begin,
event_description="Début prévisionnel",
)
if expected_end:
InterventionPlanificationTimeLine.objects.create(
intervention=intervention,
event_user=user,
event_type='expected_end',
event_time=expected_end,
event_description="Fin prévisionnelle",
)
# Lien localisation
InterventionLocation.objects.get_or_create(
intervention=intervention,
content_type=location_ct,
object_id=location.pk,
)
# --- Opérations génériques ---
_add_operations_from_template(
intervention, template, only_generic=True, log=log
)
# --- Opérations équipement (assets enfants de la localisation) ---
if templates_with_filters:
ct_cache = {}
for child_asset in _extract_assets_from_location(location):
asset_class = child_asset.__class__
if asset_class not in ct_cache:
ct_cache[asset_class] = ContentType.objects.get_for_model(asset_class)
ct_asset = ct_cache[asset_class]
applicable = [
t for t in templates_with_filters
if _asset_matches_template_filters(child_asset, t, allow_unfiltered=False)
]
if not applicable:
continue
InterventionAsset.objects.get_or_create(
intervention=intervention,
content_type=ct_asset,
object_id=child_asset.pk,
)
for t in applicable:
_add_operations_from_template(
intervention, t,
asset_obj=child_asset, asset_ct=ct_asset,
only_generic=False, log=log,
)
result.created += 1
return result