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

2378 lines
90 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.

# --------------------
# Asset Editing Views
# --------------------
import json
import os
from django.http import JsonResponse, Http404
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.views.decorators.http import require_GET, require_POST
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.geos import Point, GEOSGeometry
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext as _
from assets.permissions import (
can_edit_asset,
get_allowed_update_fields_for_asset,
get_allowed_actions_for_asset,
get_allowed_actions_for_thematic,
get_user_asset_thematic_access,
)
from assets.models import AssetCategory, ASSET_STATUS_CHOICES
from assets.models.core import ASSET_VALIDATION_STATUS_CHOICES
from common.models import Thematic
from interventions.models import Intervention, InterventionAsset
from assets.views.locations import ASSETS_LOCATION_REGISTRY
# -----------------------------------------------------------------------
# Asset Document API
# -----------------------------------------------------------------------
@require_POST
def asset_add_document(request, asset_model, asset_id):
"""
Upload a new file, create a ManagedDocument + DocumentVersion and attach
it to the asset via DocumentAttachment.
Expects multipart/form-data with:
- file : the file to upload
- title : document title (optional, defaults to filename)
"""
asset, model_class, content_type = _get_asset_by_ct(asset_model, asset_id)
if not can_edit_asset(request.user, asset):
return JsonResponse(
{'success': False, 'error': _("Permission refusée")}, status=403
)
uploaded_file = request.FILES.get('file')
if not uploaded_file:
return JsonResponse(
{'success': False, 'error': _("Aucun fichier fourni")}, status=400
)
title = request.POST.get('title', '').strip()
if not title:
title = os.path.splitext(uploaded_file.name)[0]
from documents.models import ManagedDocument, DocumentVersion, DocumentAttachment
tag_ids = request.POST.getlist('tag_ids')
with transaction.atomic():
doc = ManagedDocument.objects.create(
title=title,
created_by=request.user,
)
if tag_ids:
from documents.models import DocumentTag
valid_tags = DocumentTag.objects.filter(pk__in=tag_ids)
doc.tags.set(valid_tags)
version = DocumentVersion(
document=doc,
uploaded_by=request.user,
version_number=1,
)
version.file.save(uploaded_file.name, uploaded_file, save=True)
DocumentAttachment.objects.create(
document=doc,
content_type=content_type,
object_id=asset.pk,
attached_by=request.user,
)
return JsonResponse({'success': True})
@require_GET
def asset_document_list_html(request, asset_model, asset_id):
"""
Returns the rendered document-viewer HTML for the #documents-list div.
Used after AJAX uploads to refresh the thumbnail grid without a full page reload.
"""
from django.http import HttpResponse
from django.template.loader import render_to_string
from documents.models import DocumentAttachment
asset, _model_class, content_type = _get_asset_by_ct(asset_model, asset_id)
documents = DocumentAttachment.objects.select_related(
"document",
"document__current_version",
).filter(
content_type=content_type,
object_id=asset.pk,
).order_by("-attached_at")
html = render_to_string(
"assets/components/_documents_inner.html",
{"documents": documents},
request=request,
)
return HttpResponse(html)
@require_POST
def asset_delete_document(request, asset_model, asset_id, document_id):
"""
Detach (and optionally delete) a document from an asset.
Only removes the DocumentAttachment; the ManagedDocument itself is preserved
unless it has no other attachments, in which case it is deleted too.
"""
asset, model_class, content_type = _get_asset_by_ct(asset_model, asset_id)
if not can_edit_asset(request.user, asset):
return JsonResponse(
{'success': False, 'error': _("Permission refusée")}, status=403
)
from documents.models import DocumentAttachment
try:
attachment = DocumentAttachment.objects.get(
document_id=document_id,
content_type=content_type,
object_id=asset.pk,
)
except DocumentAttachment.DoesNotExist:
return JsonResponse(
{'success': False, 'error': _("Document non trouvé")}, status=404
)
with transaction.atomic():
doc = attachment.document
attachment.delete()
# Delete the document itself if it is now orphaned
if not doc.attachments.exists():
doc.delete()
return JsonResponse({'success': True})
# Constants for code generation
MAX_REPLACEMENT_ATTEMPTS = 100
MAX_CODE_GENERATION_ATTEMPTS = 1000
def _get_asset_by_ct(content_type_model, asset_id):
"""Get asset by content type model name and id."""
try:
content_type = ContentType.objects.get(app_label='assets', model=content_type_model.lower())
model_class = content_type.model_class()
return get_object_or_404(model_class, pk=asset_id), model_class, content_type
except ContentType.DoesNotExist:
raise Http404(_("Type d'asset inconnu"))
def _get_model_class_for_asset(asset_model_name):
"""Get the model class for an asset type."""
try:
content_type = ContentType.objects.get(app_label='assets', model=asset_model_name.lower())
return content_type.model_class()
except ContentType.DoesNotExist:
return None
def _update_lon_lat_from_geom(asset, geom):
"""Update lon/lat fields from geometry (in SRID 3812)."""
# Clone and transform to WGS84 for lon/lat
geom_wgs84 = geom.transform(4326, clone=True)
if geom_wgs84.geom_type == 'Point':
asset.lon = geom_wgs84.x
asset.lat = geom_wgs84.y
else:
# Use centroid for non-point geometries
centroid = geom_wgs84.centroid
asset.lon = centroid.x
asset.lat = centroid.y
def _assign_default_position(new_asset, location, thematic_code=None):
"""
Assign default geometry, lon, lat, and geojson to the asset from its location/hierarchy.
"""
from assets.models import AbstractGeoAsset
if not isinstance(new_asset, AbstractGeoAsset):
return
# Check if the asset already has a geometry/coordinates assigned
if new_asset.geom is not None or (new_asset.lon is not None and new_asset.lat is not None):
# Already has coordinates/geometry, update geojson if needed
if new_asset.geom is not None and not new_asset.geojson:
try:
geom_4326 = new_asset.geom.transform(4326, clone=True)
new_asset.geojson = geom_4326.geojson
except Exception:
pass
return
if not location:
return
# Resolve geom and coordinates by walking up the hierarchy
geom, lon, lat = None, None, None
# Try the location itself
if hasattr(location, 'geom') and location.geom is not None:
geom = location.geom
if hasattr(location, 'lon') and location.lon is not None:
lon = location.lon
if hasattr(location, 'lat') and location.lat is not None:
lat = location.lat
# Walk up parent locations if any
current = location
while (geom is None or lon is None or lat is None) and getattr(current, 'parent_location', None) is not None:
current = current.parent_location
if geom is None and hasattr(current, 'geom'):
geom = getattr(current, 'geom', None)
if lon is None and hasattr(current, 'lon'):
lon = getattr(current, 'lon', None)
if lat is None and hasattr(current, 'lat'):
lat = getattr(current, 'lat', None)
# Fall back to structure's main location if still None (for structures thematic)
if (geom is None or lon is None or lat is None) and hasattr(location, 'structure') and location.structure:
main_loc = getattr(location.structure, 'main_location', None)
if main_loc:
if geom is None and hasattr(main_loc, 'geom'):
geom = getattr(main_loc, 'geom', None)
if lon is None and hasattr(main_loc, 'lon'):
lon = getattr(main_loc, 'lon', None)
if lat is None and hasattr(main_loc, 'lat'):
lat = getattr(main_loc, 'lat', None)
# Assign resolved values
if geom is not None:
try:
if geom.geom_type == 'Point':
new_asset.geom = geom
else:
new_asset.geom = geom.centroid
except Exception:
pass
if lon is not None:
new_asset.lon = lon
if lat is not None:
new_asset.lat = lat
# Ensure lon/lat are set if geom is set, and vice versa
if new_asset.geom is not None and (new_asset.lon is None or new_asset.lat is None):
try:
_update_lon_lat_from_geom(new_asset, new_asset.geom)
except Exception:
pass
# Populate geojson
if new_asset.geom is not None:
try:
geom_4326 = new_asset.geom.transform(4326, clone=True)
new_asset.geojson = geom_4326.geojson
except Exception:
pass
def _update_asset_field(asset, field_name, new_value, model_class):
"""
Update a single field on an asset.
Returns (success, error_message).
"""
if not hasattr(asset, field_name):
return False, _("Champ inconnu: %(field)s") % {"field": field_name}
try:
# Handle special field types
if field_name in ('installation_date', 'uninstallation_date', 'last_inspection_date'):
# Date field
if new_value:
from django.utils.dateparse import parse_datetime
parsed = parse_datetime(new_value)
if not parsed:
from datetime import datetime
try:
parsed = datetime.strptime(new_value, "%Y-%m-%d")
except ValueError:
return False, _("Format de date invalide pour %(field)s") % {"field": field_name}
setattr(asset, field_name, parsed)
else:
setattr(asset, field_name, None)
elif field_name == 'model':
# ForeignKey to model
if new_value:
# Get the model's ForeignKey field to find the related model class
field = model_class._meta.get_field('model')
related_model = field.related_model
try:
related_obj = related_model.objects.get(pk=int(new_value))
asset.model = related_obj
except related_model.DoesNotExist:
return False, _("Modèle non trouvé")
else:
asset.model = None
elif field_name == 'category':
# ForeignKey to category
if new_value:
try:
category = AssetCategory.objects.get(pk=int(new_value))
asset.category = category
except AssetCategory.DoesNotExist:
return False, _("Catégorie non trouvée")
else:
asset.category = None
elif field_name in ('location_id', 'intersection_id'):
# ForeignKey location/intersection — derive the actual field name from the suffix
fk_field_name = field_name[:-3] # 'location' or 'intersection'
if not hasattr(asset, fk_field_name):
# Field doesn't exist on this asset type — silently skip for compatibility
return True, None
if new_value:
try:
field = model_class._meta.get_field(fk_field_name)
related_model = field.related_model
related_obj = related_model.objects.get(pk=int(new_value))
setattr(asset, fk_field_name, related_obj)
except Exception:
return False, _("Localisation non trouvée")
else:
setattr(asset, fk_field_name, None)
elif field_name == 'status':
# Validate status
if new_value in dict(ASSET_STATUS_CHOICES):
asset.status = new_value
else:
return False, _("Statut invalide: %(status)s") % {"status": new_value}
elif field_name == 'validation_status':
# Validate validation status
if new_value in dict(ASSET_VALIDATION_STATUS_CHOICES) or new_value in (None, ''):
asset.validation_status = new_value if new_value else None
else:
return False, _("Statut de validation invalide: %(status)s") % {"status": new_value}
elif field_name in ('lon', 'lat'):
# Float field with geometry update
if new_value:
try:
float_value = float(new_value)
setattr(asset, field_name, float_value)
# Update geometry if both lon and lat are set
if hasattr(asset, 'geom') and asset.lon is not None and asset.lat is not None:
pt = Point(asset.lon, asset.lat, srid=4326)
pt.transform(3812)
asset.geom = pt
except ValueError:
return False, _("Valeur numérique invalide pour %(field)s") % {"field": field_name}
else:
setattr(asset, field_name, None)
elif field_name == 'geom':
# Geometry field - expects GeoJSON
if new_value:
try:
if isinstance(new_value, str):
geom_data = json.loads(new_value)
else:
geom_data = new_value
# Handle GeoJSON Feature or Geometry
if geom_data.get('type') == 'Feature':
geom_json = json.dumps(geom_data.get('geometry', {}))
else:
geom_json = json.dumps(geom_data)
geom = GEOSGeometry(geom_json, srid=4326)
geom.transform(3812)
asset.geom = geom
# Update lon/lat from geometry
if hasattr(asset, 'lon') and hasattr(asset, 'lat'):
_update_lon_lat_from_geom(asset, geom)
except Exception as e:
return False, _("Géométrie invalide: %(error)s") % {"error": str(e)}
else:
asset.geom = None
elif field_name in ('warranty_duration',):
# Integer field
if new_value:
try:
asset.warranty_duration = int(new_value)
except ValueError:
return False, _("Valeur numérique invalide pour %(field)s") % {"field": field_name}
else:
setattr(asset, field_name, None)
else:
# Use field introspection for proper type coercion
from django.db.models import (FloatField, DecimalField, IntegerField,
BooleanField, DateField, DateTimeField)
try:
field_obj = asset._meta.get_field(field_name)
except Exception:
field_obj = None
if field_obj and isinstance(field_obj, (FloatField, DecimalField)):
if new_value not in (None, ''):
try:
setattr(asset, field_name, float(str(new_value).replace(',', '.')))
except (ValueError, TypeError):
return False, _("Valeur numérique invalide pour %(field)s") % {"field": field_name}
else:
setattr(asset, field_name, None)
elif field_obj and isinstance(field_obj, IntegerField):
if new_value not in (None, ''):
try:
setattr(asset, field_name, int(new_value))
except (ValueError, TypeError):
return False, _("Valeur numérique invalide pour %(field)s") % {"field": field_name}
else:
setattr(asset, field_name, None)
elif field_obj and isinstance(field_obj, BooleanField):
if new_value in (None, '') and getattr(field_obj, 'null', False):
setattr(asset, field_name, None)
else:
setattr(asset, field_name, new_value in (True, 'true', 'True', '1', 'on'))
elif field_obj and isinstance(field_obj, (DateTimeField, DateField)):
if new_value:
from django.utils.dateparse import parse_datetime, parse_date
parsed = parse_datetime(str(new_value)) or parse_date(str(new_value))
if not parsed:
from datetime import datetime
try:
parsed = datetime.strptime(str(new_value), "%Y-%m-%d")
except ValueError:
return False, _("Format de date invalide pour %(field)s") % {"field": field_name}
setattr(asset, field_name, parsed)
else:
setattr(asset, field_name, None)
else:
# Char / Text field or fallback
setattr(asset, field_name, new_value if new_value else None)
return True, None
except Exception as e:
return False, str(e)
@require_POST
def update_asset(request, asset_model, asset_id):
"""
Update asset fields via AJAX.
Expects JSON body with field:value pairs.
"""
asset, model_class, content_type = _get_asset_by_ct(asset_model, asset_id)
if not can_edit_asset(request.user, asset):
return JsonResponse({
'success': False,
'error': _("Vous n'avez pas la permission de modifier cet asset")
}, status=403)
allowed_fields = get_allowed_update_fields_for_asset(request.user, asset)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': _("Format JSON invalide")
}, status=400)
updated_fields = []
errors = []
with transaction.atomic():
for field_name, new_value in data.items():
if field_name not in allowed_fields:
errors.append(_("Champ non modifiable: %(field)s") % {"field": field_name})
continue
success, error = _update_asset_field(asset, field_name, new_value, model_class)
if success:
updated_fields.append(field_name)
else:
errors.append(error)
if updated_fields:
# If the user is not internal, automatically set status to 'to_be_approved'
# (unless the asset is already archived/removed, or the user explicitly set the status)
if 'status' not in updated_fields and asset.status not in ('removed',):
try:
from common.models import UserConfig
user_config = UserConfig.objects.get(user=request.user)
if not user_config.is_intern:
asset.status = 'to_be_approved'
updated_fields.append('status')
except UserConfig.DoesNotExist:
pass
# Set the user for history tracking
asset._history_user = request.user
asset.save()
if errors and not updated_fields:
return JsonResponse({
'success': False,
'errors': errors,
'message': _("Aucun champ mis à jour")
}, status=400)
return JsonResponse({
'success': True,
'updated_fields': updated_fields,
'errors': errors,
'message': _("%(count)s champ(s) mis à jour") % {"count": len(updated_fields)}
})
@require_POST
def archive_asset(request, asset_model, asset_id):
"""
Archive an asset (set status to 'removed').
"""
asset, model_class, content_type = _get_asset_by_ct(asset_model, asset_id)
if not can_edit_asset(request.user, asset):
return JsonResponse({
'success': False,
'error': _("Vous n'avez pas la permission de modifier cet asset")
}, status=403)
allowed_actions = get_allowed_actions_for_asset(request.user, asset)
if 'archive' not in allowed_actions:
return JsonResponse({
'success': False,
'error': _("Vous n'avez pas la permission d'archiver cet asset")
}, status=403)
with transaction.atomic():
asset.status = 'removed'
asset.uninstallation_date = timezone.now()
asset.save(update_fields=['status', 'uninstallation_date'])
return JsonResponse({
'success': True,
'message': _("Asset archivé avec succès")
})
@require_POST
def replace_asset(request, asset_model, asset_id):
"""
Replace an asset: archive the old one and optionally create a new one.
Expects JSON body with:
- copy_attributes: boolean - whether to copy attributes from old asset
- new_model_id: int - ID of the new asset model (optional, defaults to same)
- new_data: dict - additional data for the new asset
"""
asset, model_class, content_type = _get_asset_by_ct(asset_model, asset_id)
if not can_edit_asset(request.user, asset):
return JsonResponse({
'success': False,
'error': _("Vous n'avez pas la permission de modifier cet asset")
}, status=403)
allowed_actions = get_allowed_actions_for_asset(request.user, asset)
if 'replace' not in allowed_actions:
return JsonResponse({
'success': False,
'error': _("Vous n'avez pas la permission de remplacer cet asset")
}, status=403)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': _("Format JSON invalide")
}, status=400)
copy_attributes = data.get('copy_attributes', True)
new_model_id = data.get('new_model_id')
new_data = data.get('new_data', {})
intervention_id = data.get('intervention_id') # ID de l'intervention si appelé depuis une intervention
with transaction.atomic():
# Create new asset first (before archiving old one)
new_asset = model_class()
# Copy attributes if requested
if copy_attributes:
for field in model_class._meta.get_fields():
if field.auto_created or field.name in ('id', 'code', 'replaced_by'):
continue
if hasattr(asset, field.name):
setattr(new_asset, field.name, getattr(asset, field.name))
# Set default values for new asset
new_asset.status = 'active'
new_asset.installation_date = timezone.now()
new_asset.uninstallation_date = None
# Override with new data
for field_name, value in new_data.items():
if hasattr(new_asset, field_name):
setattr(new_asset, field_name, value)
# Handle new model if specified
if new_model_id and hasattr(new_asset, 'model'):
try:
model_field = model_class._meta.get_field('model')
related_model = model_field.related_model
new_asset.model = related_model.objects.get(pk=int(new_model_id))
except Exception:
pass
# Generate new code for old asset
new_asset.code = asset.code
asset.code = _generate_archive_asset_code(asset)
# Save new asset first
new_asset.save()
# Archive old asset and create the replacement link
asset.status = 'removed'
asset.uninstallation_date = timezone.now()
asset.replaced_by = new_asset # Create the replacement link
asset.save(update_fields=['code', 'status', 'uninstallation_date', 'replaced_by'])
# Si appelé depuis une intervention, créer le lien avec le nouvel asset
if intervention_id:
try:
from interventions.models import Intervention, InterventionAsset
from interventions.views import _create_equipment_operations_for_assets
intervention = Intervention.objects.get(pk=intervention_id)
InterventionAsset.objects.create(
intervention=intervention,
content_type=content_type,
object_id=new_asset.id
)
# Créer les opérations d'équipement pour le nouvel asset
# basées sur les templates d'opération de l'intervention
_create_equipment_operations_for_assets(
intervention,
[(content_type, new_asset)]
)
except Exception as e:
# Log mais ne fait pas échouer le remplacement
pass
return JsonResponse({
'success': True,
'message': _("Asset remplacé avec succès"),
'old_asset_id': asset.id,
'new_asset_id': new_asset.id,
'new_asset_code': new_asset.code
})
def _generate_archive_asset_code(old_asset):
"""Generate a new unique code for an asset."""
base_code = old_asset.code
# new_code = base_code + "YYYY-MM-DD" format
new_code = f"{base_code}_{timezone.now().strftime('%Y%m%d')}"
# get location from old_asset
location = None
for field_name in ASSETS_LOCATION_REGISTRY:
if hasattr(old_asset, field_name):
location = getattr(old_asset, field_name, None)
if location is not None:
break
# Ensure uniqueness for archived asset codes in this location
if location and hasattr(location, 'get_direct_child_assets'):
existing_codes = set()
assets_dict = location.get_direct_child_assets()
for key, queryset in assets_dict.items():
for asset in queryset:
existing_codes.add(asset.code)
if new_code not in existing_codes:
return new_code
# Try appending a counter
for i in range(1, MAX_REPLACEMENT_ATTEMPTS):
candidate_code = f"{new_code}-{i:02d}"
if candidate_code not in existing_codes:
return candidate_code
return new_code
@require_POST
def create_asset(request, thematic_code, location_type, location_id):
"""
Create a new asset at a location.
Expects JSON body with:
- asset_type: string - the asset model name (e.g., 'trafficlightpole')
- model_id: int - ID of the asset model
- category_id: int - ID of the category
- data: dict - additional data for the asset
"""
# Get thematic and check permission
try:
thematic = Thematic.objects.get(code=thematic_code)
except Thematic.DoesNotExist:
return JsonResponse({
'success': False,
'error': _("Thématique non trouvée")
}, status=404)
allowed_actions = get_allowed_actions_for_thematic(request.user, thematic)
if 'create' not in allowed_actions:
return JsonResponse({
'success': False,
'error': _("Vous n'avez pas la permission de créer des assets")
}, status=403)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': _("Format JSON invalide")
}, status=400)
asset_type = data.get('asset_type')
model_id = data.get('model_id')
category_id = data.get('category_id')
asset_data = data.get('data', {})
# Convert model_id and category_id to int if they are strings
if model_id and str(model_id).strip():
try:
model_id = int(model_id)
except (ValueError, TypeError):
model_id = None
else:
model_id = None
if category_id and str(category_id).strip():
try:
category_id = int(category_id)
except (ValueError, TypeError):
category_id = None
else:
category_id = None
# Try to resolve category to deduce asset_type
category = None
if category_id:
try:
category = AssetCategory.objects.get(pk=category_id)
except AssetCategory.DoesNotExist:
pass
# Deduce asset_type from category if not specified
if not asset_type and category:
allowed_cts = category.allowed_models.all()
if allowed_cts.exists():
asset_type = allowed_cts.first().model
else:
classes = _get_asset_classes_for_category(thematic_code, category)
if classes and classes[0]:
asset_type = classes[0]._meta.model_name
if not asset_type:
return JsonResponse({
'success': False,
'error': _("Type d'asset requis")
}, status=400)
# Get asset model class
model_class = _get_model_class_for_asset(asset_type)
if not model_class:
return JsonResponse({
'success': False,
'error': _("Type d'asset invalide: %(type)s") % {"type": asset_type}
}, status=400)
# Get location
try:
location_ct = ContentType.objects.get(app_label='assets', model=location_type.lower())
location_model = location_ct.model_class()
location = get_object_or_404(location_model, pk=location_id)
except ContentType.DoesNotExist:
return JsonResponse({
'success': False,
'error': _("Type de localisation invalide")
}, status=400)
with transaction.atomic():
new_asset = model_class()
# Get location model name for comparison
location_model_name = location_model._meta.model_name.lower()
# Find the ForeignKey field that points to the location model
location_set = False
for field in model_class._meta.get_fields():
if hasattr(field, 'related_model') and field.related_model:
related_model_name = field.related_model._meta.model_name.lower()
if related_model_name == location_model_name:
# This is a ForeignKey to the location model
setattr(new_asset, field.name, location)
location_set = True
break
if not location_set:
return JsonResponse({
'success': False,
'error': _("Impossible de lier l'asset à cette localisation")
}, status=400)
# Set model if specified or required
if hasattr(model_class, '_meta'):
try:
model_field = model_class._meta.get_field('model')
is_model_required = not model_field.null and not model_field.blank
if model_id:
related_model = model_field.related_model
new_asset.model = related_model.objects.get(pk=model_id)
elif is_model_required:
return JsonResponse({
'success': False,
'error': _("Le modèle d'asset est requis pour ce type d'asset")
}, status=400)
except model_class._meta.get_field('model').related_model.DoesNotExist:
return JsonResponse({
'success': False,
'error': _("Modèle d'asset non trouvé")
}, status=400)
except Exception:
# No 'model' field on this asset type, that's OK
pass
# Set category if specified
if category_id:
try:
new_asset.category = AssetCategory.objects.get(pk=int(category_id))
except AssetCategory.DoesNotExist:
pass
# Set default values
new_asset.status = 'active'
new_asset.validation_status = 'to_be_validated'
new_asset.installation_date = timezone.now()
# Generate code
new_asset.code = _generate_asset_code(model_class, location)
# Set additional data
for field_name, value in asset_data.items():
if hasattr(new_asset, field_name) and field_name not in ('id', 'code'):
setattr(new_asset, field_name, value)
# Assign position from location / hierarchy
_assign_default_position(new_asset, location, thematic_code)
new_asset.save()
return JsonResponse({
'success': True,
'message': _("Asset créé avec succès"),
'asset_id': new_asset.id,
'asset_code': new_asset.code
})
def _generate_asset_code(model_class, location):
"""Generate a unique code for a new asset."""
location_code = getattr(location, 'code', 'AST')
# Get count of existing assets at this location
model_name = model_class._meta.model_name.upper()[:3]
base_code = f"{location_code}-{model_name}"
# Find next available number
for i in range(1, MAX_CODE_GENERATION_ATTEMPTS):
new_code = f"{base_code}-{i:03d}"
if not model_class.objects.filter(code=new_code).exists():
return new_code
# Fallback
import time
return f"{base_code}-{int(time.time())}"
@require_POST
def bulk_archive_assets(request, thematic_code, location_type, location_id):
"""
Archive all active assets at a location.
"""
# Get thematic and check permission
try:
thematic = Thematic.objects.get(code=thematic_code)
except Thematic.DoesNotExist:
return JsonResponse({
'success': False,
'error': _("Thématique non trouvée")
}, status=404)
allowed_actions = get_allowed_actions_for_thematic(request.user, thematic)
if 'bulk_archive' not in allowed_actions:
return JsonResponse({
'success': False,
'error': _("Vous n'avez pas la permission d'archiver en masse")
}, status=403)
# Get location
try:
location_ct = ContentType.objects.get(app_label='assets', model=location_type.lower())
location_model = location_ct.model_class()
location = get_object_or_404(location_model, pk=location_id)
except ContentType.DoesNotExist:
return JsonResponse({
'success': False,
'error': _("Type de localisation invalide")
}, status=400)
# Get all active assets for this location
if not hasattr(location, 'get_active_assets'):
return JsonResponse({
'success': False,
'error': _("Cette localisation ne supporte pas la gestion des assets")
}, status=400)
archived_count = 0
now = timezone.now()
with transaction.atomic():
assets_dict = location.get_active_assets()
# Flatten all assets into a single list
assets_to_archive = []
if isinstance(assets_dict, dict):
for val in assets_dict.values():
if isinstance(val, (list, tuple)) or hasattr(val, '__iter__'):
assets_to_archive.extend(val)
else:
assets_to_archive.append(val)
elif isinstance(assets_dict, (list, tuple)) or hasattr(assets_dict, '__iter__'):
assets_to_archive.extend(assets_dict)
else:
assets_to_archive.append(assets_dict)
# Archive each asset individually to trigger signals and history logs
for asset in assets_to_archive:
if hasattr(asset, 'status') and asset.status == 'active':
asset._history_user = request.user
asset.status = 'removed'
asset.uninstallation_date = now
asset.save(update_fields=['status', 'uninstallation_date'])
archived_count += 1
return JsonResponse({
'success': True,
'message': _("%(count)s asset(s) archivé(s)") % {"count": archived_count},
'archived_count': archived_count
})
def _has_field(model_class, field_name):
try:
model_class._meta.get_field(field_name)
return True
except Exception:
return False
@require_GET
def get_child_asset_form_fields(request, model_name):
"""
Return field descriptors for building a dynamic creation form for a child asset.
Excludes system-managed fields and the parent FK (field_on_child).
Pre-fills FK fields where the value can be inferred from the parent asset context.
Query params: field_on_child, parent_model, parent_id
"""
from django.db.models import (
ForeignKey, CharField, TextField, IntegerField,
FloatField, DecimalField, BooleanField, DateTimeField, DateField,
)
field_on_child = request.GET.get('field_on_child', '')
parent_model_name = request.GET.get('parent_model', '')
parent_id_str = request.GET.get('parent_id', '')
child_model_class = None
try:
cts = ContentType.objects.filter(model=model_name.lower())
if not cts.exists():
return JsonResponse({'fields': []})
cts_sorted = sorted(cts, key=lambda ct: (0 if ct.app_label == 'assets' else 1))
for ct in cts_sorted:
mc = ct.model_class()
if mc is not None:
child_model_class = mc
break
except Exception:
return JsonResponse({'fields': []})
if child_model_class is None:
return JsonResponse({'fields': []})
# Fields always managed by the system — never shown in the creation form
SYSTEM_FIELDS = {
'id', 'code', 'status', 'installation_date', 'uninstallation_date',
'serial_number', 'warranty_duration', 'last_inspection_date',
'last_preventive_date', 'last_corrective_date', 'last_survey_date',
'replaced_by', 'marked_as_removed_date', 'marked_as_removed_by',
'obsolescence', 'note', 'category', 'name_fr', 'name_nl',
'lon', 'lat', 'geom', 'geojson', 'brand',
}
# Load parent asset for context-based pre-filling
parent_asset = None
if parent_model_name and parent_id_str:
try:
parent_cts = ContentType.objects.filter(model=parent_model_name.lower())
parent_cts_sorted = sorted(parent_cts, key=lambda ct: (0 if ct.app_label == 'assets' else 1))
for pct in parent_cts_sorted:
pmc = pct.model_class()
if pmc is not None:
parent_asset = pmc.objects.get(pk=int(parent_id_str))
break
except Exception:
pass
def _prefill_for_fk(field):
"""Return the raw PK to pre-select for a child FK field, or None."""
if parent_asset is None:
return None
# Strategy 1: same field name on parent (e.g. child.intersection → parent.intersection_id)
val = getattr(parent_asset, field.attname, None)
if val is not None:
return val
# Strategy 2: any FK on parent that points to the same related model
for pf in type(parent_asset)._meta.get_fields():
if (hasattr(pf, 'attname') and hasattr(pf, 'related_model')
and pf.related_model == field.related_model):
val = getattr(parent_asset, pf.attname, None)
if val is not None:
return val
return None
fields_out = []
for field in child_model_class._meta.get_fields():
if not hasattr(field, 'column'):
continue # skip reverse relations / M2M
fname = field.name
if fname in SYSTEM_FIELDS or fname == field_on_child:
continue
required = not getattr(field, 'null', True) and not getattr(field, 'blank', True)
verbose = str(getattr(field, 'verbose_name', fname)).capitalize()
if isinstance(field, ForeignKey):
order_field = 'code' if _has_field(field.related_model, 'code') else 'pk'
prefill_pk = _prefill_for_fk(field)
def _obj_label(obj):
lbl = obj.code if hasattr(obj, 'code') else str(obj)
if hasattr(obj, 'get_name'):
n = obj.get_name().strip()
if n and n != lbl:
lbl += ' — ' + n
return lbl
choices = []
seen_ids = set()
for obj in field.related_model.objects.order_by(order_field)[:500]:
choices.append({'id': obj.pk, 'label': _obj_label(obj)})
seen_ids.add(obj.pk)
# Always include the pre-filled object, even if beyond the :500 limit
if prefill_pk is not None and prefill_pk not in seen_ids:
try:
prefill_obj = field.related_model.objects.get(pk=prefill_pk)
choices.insert(0, {'id': prefill_obj.pk, 'label': _obj_label(prefill_obj)})
except field.related_model.DoesNotExist:
pass
fields_out.append({
'name': field.attname, # e.g. "intersection_id"
'label': verbose,
'type': 'fk',
'required': required,
'choices': choices,
'prefill': prefill_pk,
})
elif getattr(field, 'choices', None):
fields_out.append({
'name': fname,
'label': verbose,
'type': 'choice',
'required': required,
'choices': [{'id': str(k), 'label': str(v)} for k, v in field.choices],
'prefill': None,
})
elif isinstance(field, BooleanField):
fields_out.append({
'name': fname,
'label': verbose,
'type': 'boolean',
'required': False,
'choices': [],
'prefill': field.default if field.has_default() else False,
})
elif isinstance(field, DateTimeField):
fields_out.append({
'name': fname,
'label': verbose,
'type': 'datetime',
'required': required,
'choices': [],
'prefill': None,
})
elif isinstance(field, (IntegerField, FloatField, DecimalField)):
fields_out.append({
'name': fname,
'label': verbose,
'type': 'number',
'required': required,
'choices': [],
'prefill': None,
})
elif isinstance(field, (CharField, TextField)):
fields_out.append({
'name': fname,
'label': verbose,
'type': 'text',
'required': required,
'choices': [],
'prefill': None,
})
# GeometryField, ArrayField, etc. are intentionally skipped
return JsonResponse({'fields': fields_out})
@require_GET
def get_asset_models_for_category(request, thematic_code, category_id):
"""
Get asset models available for a category.
Asset models are like TrafficLightPoleModel, TrafficLightControllerModel, etc.
"""
from django.apps import apps
from assets.models.core import AbstractAssetModel
try:
category = AssetCategory.objects.get(pk=category_id)
except AssetCategory.DoesNotExist:
return JsonResponse({'models': []})
# Find all concrete model classes that inherit from AbstractAssetModel
# and query for instances that have the given category
models_data = []
for model in apps.get_models():
# Skip abstract models
if model._meta.abstract:
continue
# Check if this model has a category field and inherits from AbstractAssetModel
try:
if not issubclass(model, AbstractAssetModel):
continue
except TypeError:
continue
# Check if the model has a category field
if not hasattr(model, 'category'):
continue
# Query for model instances with this category
try:
model_objects = model.objects.filter(category=category)
for obj in model_objects:
models_data.append({
'id': obj.id,
'code': obj.code,
'name': obj.get_name() if hasattr(obj, 'get_name') else str(obj),
'model_type': model._meta.model_name
})
except Exception:
# Skip models that don't have a proper category FK
continue
return JsonResponse({'models': models_data})
def _is_model_required(model_class):
if not model_class or not hasattr(model_class, '_meta'):
return False
try:
field = model_class._meta.get_field('model')
return field.is_relation and not field.null and not field.blank
except Exception:
return False
@require_GET
def get_asset_categories_for_thematic(request, thematic_code):
"""
Get asset categories for a thematic.
"""
try:
thematic = Thematic.objects.get(code=thematic_code)
except Thematic.DoesNotExist:
return JsonResponse({'categories': []})
categories = AssetCategory.objects.filter(thematic=thematic).order_by('name_fr')
categories_data = []
for cat in categories:
allowed_cts = list(cat.allowed_models.all())
# Fallback to CATEGORY_ASSET_MAPPING if empty
if not allowed_cts:
asset_class, _ = _get_asset_classes_for_category(thematic_code, cat)
if asset_class:
from django.contrib.contenttypes.models import ContentType
try:
ct = ContentType.objects.get_for_model(asset_class)
allowed_cts = [ct]
except Exception:
pass
allowed_models_data = []
for ct in allowed_cts:
model_class = ct.model_class()
if model_class:
allowed_models_data.append({
'model_name': ct.model,
'verbose_name': str(model_class._meta.verbose_name).capitalize(),
'is_model_required': _is_model_required(model_class)
})
categories_data.append({
'id': cat.id,
'code': cat.code,
'name': cat.get_name(),
'allowed_models': allowed_models_data
})
return JsonResponse({'categories': categories_data})
# Mapping of thematic codes to default asset model classes
# For thematics with multiple asset types, we use category code to determine the right class
THEMATIC_ASSET_MAPPING = {
'structures': 'StructureGeoAsset',
'trafficlights': 'TrafficLightPole', # Default, overridden by category
'publiclighting': 'PublicLightingPole', # Default, overridden by category
'nature': 'NatureGeoAsset',
'artworks': 'Fountain', # or Monument depending on category
'controlcenters': 'ControlCenterGeoAsset',
'its': 'ITSGeoAsset',
'sign': 'SignGeoAsset',
'roads': 'RoadGeoAsset',
}
# Mapping of category codes to (asset_class, asset_model_class) tuples
# This allows different asset types per category within a thematic
CATEGORY_ASSET_MAPPING = {
# Traffic Lights categories
'trafficlights_pole': ('TrafficLightPole', 'TrafficLightPoleModel'),
'trafficlights_cable': ('TrafficLightCable', 'TrafficLightCableModel'),
'trafficlights_lantern': ('TrafficLightLantern', 'TrafficLightLanternModel'),
'trafficlights_controller': ('TrafficLightController', 'TrafficLightControllerModel'),
'trafficlights_detector': ('TrafficLightDetector', 'TrafficLightDetectorModel'),
'trafficlights_cabinet': ('TrafficLightElectricalCabinet', 'TrafficLightElectricalCabinetModel'),
# Alias with simpler names
'pole': ('TrafficLightPole', 'TrafficLightPoleModel'),
'TL_POTEAU': ('TrafficLightPole', 'TrafficLightPoleModel'),
'cable': ('TrafficLightCable', 'TrafficLightCableModel'),
'TL_CABLE': ('TrafficLightCable', 'TrafficLightCableModel'),
'lantern': ('TrafficLightLantern', 'TrafficLightLanternModel'),
'TL_LANTERNE': ('TrafficLightLantern', 'TrafficLightLanternModel'),
'controller': ('TrafficLightController', 'TrafficLightControllerModel'),
'TL_REGELAAR': ('TrafficLightController', 'TrafficLightControllerModel'),
'detector': ('TrafficLightDetector', 'TrafficLightDetectorModel'),
'cabinet': ('TrafficLightElectricalCabinet', 'TrafficLightElectricalCabinetModel'),
'electrical_cabinet': ('TrafficLightElectricalCabinet', 'TrafficLightElectricalCabinetModel'),
'TL_ARMOIRE': ('TrafficLightElectricalCabinet', 'TrafficLightElectricalCabinetModel'),
'TL_RADAR_PREVENTIF': ('TrafficLightRadar', 'TrafficLightRadarModel'),
'TL_RADAR_POLE': ('TrafficLightRadarPole', 'TrafficLightRadarPoleModel'),
'TL_DETEC_BP': ('TrafficLightDetector', 'TrafficLightDetectorModel'),
'TL_DETEC_RADAR': ('TrafficLightDetector', 'TrafficLightDetectorModel'),
'TL_DETEC_VIDEO': ('TrafficLightDetector', 'TrafficLightDetectorModel'),
'TL_DETEC_OTHER': ('TrafficLightDetector', 'TrafficLightDetectorModel'),
'TL_BORNIER': ('TrafficLightAccessory', 'TrafficLightAccessoryModel'),
'TL_DIVERS': ('TrafficLightAccessory', 'TrafficLightAccessoryModel'),
# Public Lighting categories
'publiclighting_pole': ('PublicLightingPole', 'PublicLightingPoleModel'),
'LI_poles': ('PublicLightingPole', 'PublicLightingPoleModel'),
'publiclighting_luminaire': ('PublicLightingLuminaire', 'PublicLightingLuminaireModel'),
'LI_lightfixtures': ('PublicLightingLightFixture', 'PublicLightingLightFixtureModel'),
}
def _get_asset_classes_for_category(thematic_code, category):
"""
Get the appropriate asset class and model class based on the thematic and category.
Returns (asset_class, asset_model_class) tuple.
"""
if category:
allowed_cts = category.allowed_models.all()
if allowed_cts.exists():
ct = allowed_cts.first()
asset_class = ct.model_class()
if asset_class:
import importlib
model_class_name = f"{asset_class.__name__}Model"
try:
module = importlib.import_module(asset_class.__module__)
asset_model_class = getattr(module, model_class_name, None)
except Exception:
asset_model_class = None
return asset_class, asset_model_class
category_code = category.code if category and category.code else None
# First, try category-specific mapping (case-insensitive)
if category_code:
lower_map = {k.lower(): v for k, v in CATEGORY_ASSET_MAPPING.items()}
# Try with thematic prefix
prefixed_code = f"{thematic_code}_{category_code}".lower()
if prefixed_code in lower_map:
return _import_asset_classes(thematic_code, lower_map[prefixed_code])
# Try category code directly
if category_code.lower() in lower_map:
return _import_asset_classes(thematic_code, lower_map[category_code.lower()])
# Fallback to thematic-level default
return _get_asset_model_class_for_thematic(thematic_code)
def _import_asset_classes(thematic_code, class_names):
"""Import asset classes dynamically based on thematic and class names."""
asset_class_name, model_class_name = class_names
# Map thematics to their modules
thematic_module_map = {
'structures': 'assets.models.structures',
'trafficlights': 'assets.models.trafficlights',
'publiclighting': 'assets.models.publiclighting',
'nature': 'assets.models.nature',
'controlcenters': 'assets.models.controlcenters',
'its': 'assets.models.its',
'artworks': 'assets.models.artworks',
'sign': 'assets.models.sign',
'roads': 'assets.models.roads',
}
module_name = thematic_module_map.get(thematic_code)
if not module_name:
return None, None
try:
import importlib
module = importlib.import_module(module_name)
asset_class = getattr(module, asset_class_name, None)
model_class = getattr(module, model_class_name, None)
return asset_class, model_class
except (ImportError, AttributeError):
return None, None
def _get_asset_model_class_for_thematic(thematic_code):
"""Get the appropriate asset model class for a thematic."""
from django.apps import apps
# Direct imports for known thematics
if thematic_code == 'structures':
from assets.models.structures import StructureGeoAsset, StructureAssetModel
return StructureGeoAsset, StructureAssetModel
elif thematic_code == 'trafficlights':
from assets.models.trafficlights import TrafficLightPole, TrafficLightPoleModel
return TrafficLightPole, TrafficLightPoleModel
elif thematic_code == 'publiclighting':
from assets.models.publiclighting import PublicLightingPole, PublicLightingPoleModel
return PublicLightingPole, PublicLightingPoleModel
elif thematic_code == 'nature':
from assets.models.nature import NatureGreenSurface, NatureAssetModel
return NatureGreenSurface, NatureAssetModel
elif thematic_code == 'controlcenters':
from assets.models.controlcenters import ControlCenterGeoAsset, ControlCenterAssetModel
return ControlCenterGeoAsset, ControlCenterAssetModel
elif thematic_code == 'its':
from assets.models.its import ITSGeoAsset, ITSAssetModel
return ITSGeoAsset, ITSAssetModel
return None, None
def _get_asset_model_instance(thematic_code, model_id, category=None):
"""Get the asset model instance for a given thematic, model ID, and optionally category."""
# Try category-specific lookup first
if category:
_, asset_model_class = _get_asset_classes_for_category(thematic_code, category)
else:
_, asset_model_class = _get_asset_model_class_for_thematic(thematic_code)
if asset_model_class and model_id:
try:
return asset_model_class.objects.get(pk=model_id)
except asset_model_class.DoesNotExist:
return None
return None
@require_POST
def create_asset_api(request):
"""
API endpoint to create a new asset and optionally link it to an intervention.
Expects JSON body with:
- thematic_code: string - the thematic code (e.g., 'structures')
- category_id: int - ID of the category
- model_id: int - ID of the asset model
- code: string - unique code for the asset
- name_fr: string (optional) - French name
- name_nl: string (optional) - Dutch name
- location_id: int (optional) - ID of the location
- structure_id: int (optional) - ID of the structure (for structures thematic)
- intervention_id: int (optional) - ID of the intervention to link to
"""
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': _("Format JSON invalide")
}, status=400)
thematic_code = data.get('thematic_code')
category_id = data.get('category_id')
model_id = data.get('model_id')
code = data.get('code', '').strip()
name_fr = data.get('name_fr', '').strip()
name_nl = data.get('name_nl', '').strip()
location_id = data.get('location_id')
structure_id = data.get('structure_id')
intervention_id = data.get('intervention_id')
# Convert string IDs to integers
try:
if category_id:
category_id = int(category_id)
if model_id:
model_id = int(model_id)
if location_id:
location_id = int(location_id)
if structure_id:
structure_id = int(structure_id)
if intervention_id:
intervention_id = int(intervention_id)
except (ValueError, TypeError):
return JsonResponse({
'success': False,
'error': _("Format d'ID invalide")
}, status=400)
# Validate required fields
if not thematic_code:
return JsonResponse({
'success': False,
'error': _("Thématique requise")
}, status=400)
if not category_id:
return JsonResponse({
'success': False,
'error': _("Catégorie requise")
}, status=400)
if not code:
return JsonResponse({
'success': False,
'error': _("Code requis")
}, status=400)
# Get thematic and check permission
try:
thematic = Thematic.objects.get(code=thematic_code)
except Thematic.DoesNotExist:
return JsonResponse({
'success': False,
'error': _("Thématique non trouvée")
}, status=404)
allowed_actions = get_allowed_actions_for_thematic(request.user, thematic)
if 'create' not in allowed_actions:
return JsonResponse({
'success': False,
'error': _("Vous n'avez pas la permission de créer des équipements")
}, status=403)
# Get category first (needed to determine the correct asset class)
try:
category = AssetCategory.objects.get(pk=category_id)
except AssetCategory.DoesNotExist:
return JsonResponse({
'success': False,
'error': _("Catégorie non trouvée")
}, status=400)
# Get the appropriate asset class based on thematic AND category
asset_class, asset_model_class = _get_asset_classes_for_category(thematic_code, category)
if not asset_class:
# Fallback to thematic-level default
asset_class, asset_model_class = _get_asset_model_class_for_thematic(thematic_code)
if not asset_class:
return JsonResponse({
'success': False,
'error': _("Type d'asset non supporté pour cette thématique/catégorie")
}, status=400)
# Check if code already exists
if asset_class.objects.filter(code=code).exists():
return JsonResponse({
'success': False,
'error': _("Un équipement avec ce code existe déjà")
}, status=400)
# Get asset model using category-aware function
asset_model_instance = None
if model_id:
asset_model_instance = _get_asset_model_instance(thematic_code, model_id, category)
if not asset_model_instance:
return JsonResponse({
'success': False,
'error': _("Modèle d'équipement non trouvé")
}, status=400)
elif _is_model_required(asset_class):
return JsonResponse({
'success': False,
'error': _("Modèle requis")
}, status=400)
# If creating from an intervention without explicit location, get the first location from intervention
intervention = None
if intervention_id:
try:
intervention = Intervention.objects.get(pk=intervention_id)
# If no location_id specified, try to get first location from intervention
if not location_id and not structure_id:
from interventions.models import InterventionLocation
first_location_link = intervention.locations.first()
if first_location_link:
# Set location_id or structure_id based on what we find
location_id = first_location_link.object_id
# We'll use the location's content type to determine what to set
except Intervention.DoesNotExist:
intervention = None
with transaction.atomic():
# Create the new asset
new_asset = asset_class()
new_asset.code = code
# Only set name fields if the model has them
if hasattr(new_asset, 'name_fr'):
new_asset.name_fr = name_fr if name_fr else None
if hasattr(new_asset, 'name_nl'):
new_asset.name_nl = name_nl if name_nl else None
# Only set category if the model has it
if hasattr(new_asset, 'category'):
new_asset.category = category
new_asset.model = asset_model_instance
new_asset.status = 'active'
new_asset.validation_status = 'to_be_validated'
new_asset.installation_date = timezone.now()
# Handle location based on thematic
if thematic_code == 'structures':
if location_id:
from assets.models.structures import StructureLocation
try:
location = StructureLocation.objects.get(pk=location_id)
new_asset.location = location
if not structure_id and getattr(location, 'structure', None):
new_asset.structure = location.structure
except StructureLocation.DoesNotExist:
pass
# Handle structure (for structures)
if structure_id:
from assets.models.structures import Structure
try:
structure = Structure.objects.get(pk=structure_id)
new_asset.structure = structure
except Structure.DoesNotExist:
pass
# Handle intersection (for trafficlights)
if thematic_code == 'trafficlights':
intersection_id = data.get('intersection_id') or location_id
if not intersection_id:
return JsonResponse({
'success': False,
'error': _("Une localisation est requise pour créer cet asset.")
}, status=400)
from assets.models.trafficlights import TrafficLightIntersection
try:
intersection = TrafficLightIntersection.objects.get(pk=intersection_id)
new_asset.intersection = intersection
except TrafficLightIntersection.DoesNotExist:
return JsonResponse({
'success': False,
'error': _("Localisation introuvable")
}, status=400)
# Handle pole (for trafficlight lanterns and detectors)
pole_id = data.get('pole_id')
if pole_id:
from assets.models.trafficlights import TrafficLightPole
try:
pole = TrafficLightPole.objects.get(pk=pole_id)
if hasattr(new_asset, 'pole'):
new_asset.pole = pole
except TrafficLightPole.DoesNotExist:
pass
# Assign position from location / hierarchy
location = getattr(new_asset, 'location', None) or getattr(new_asset, 'intersection', None)
_assign_default_position(new_asset, location, thematic_code)
new_asset.save()
# Link to intervention if specified
intervention_linked = False
operations_created = 0
if intervention:
try:
content_type = ContentType.objects.get_for_model(asset_class)
# Create the link
InterventionAsset.objects.create(
intervention=intervention,
content_type=content_type,
object_id=new_asset.id
)
intervention_linked = True
# For preventive interventions, don't create operations automatically
# Let the user choose which templates to apply via the modal
# This is handled by the JavaScript frontend
except Exception as e:
# Log the error but don't fail the asset creation
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error linking asset to intervention or creating operations: {str(e)}", exc_info=True)
# Prepare response message
success_message = _("Équipement créé avec succès")
if intervention_linked and operations_created > 0:
success_message += _(" • %(count)d opération(s) ajoutée(s)") % {'count': operations_created}
response_data = {
'success': True,
'message': success_message,
'asset_id': new_asset.id,
'asset_code': new_asset.code,
'intervention_linked': intervention_linked,
'operations_created': operations_created,
'needs_template_selection': intervention_linked and intervention and intervention.maintain_type == 'preventive'
}
# Add redirect URL
if intervention_id and intervention_linked:
response_data['redirect_url'] = reverse('interventions:intervention_detail', kwargs={'intervention_id': intervention_id})
elif hasattr(new_asset, 'get_absolute_url'):
response_data['redirect_url'] = new_asset.get_absolute_url()
return JsonResponse(response_data)
@require_GET
def get_available_locations_for_asset(request, thematic_code, asset_id):
"""
Get available locations for an asset based on its thematic.
Used for changing the location of an asset in edit mode.
"""
from django.contrib.contenttypes.models import ContentType
query = (request.GET.get('q') or '').strip()
try:
limit = int(request.GET.get('limit', 100))
except (TypeError, ValueError):
limit = 100
limit = max(1, min(limit, 500))
# Get the base asset class for the thematic to find all possible content types
asset_class, _ = _get_asset_model_class_for_thematic(thematic_code)
if not asset_class:
return JsonResponse({'locations': []})
# Try to find the asset in any of the possible concrete classes
asset = None
if thematic_code == 'structures':
from assets.models.structures import StructureGeoAsset
try:
asset = StructureGeoAsset.objects.get(pk=asset_id)
except StructureGeoAsset.DoesNotExist:
return JsonResponse({'locations': []})
elif thematic_code == 'trafficlights':
from assets.models.trafficlights import (
TrafficLightPole, TrafficLightCable, TrafficLightLantern,
TrafficLightController, TrafficLightDetector, TrafficLightElectricalCabinet
)
# Try each possible class
for cls in [TrafficLightPole, TrafficLightCable, TrafficLightLantern,
TrafficLightController, TrafficLightDetector, TrafficLightElectricalCabinet]:
try:
asset = cls.objects.get(pk=asset_id)
break
except cls.DoesNotExist:
continue
if not asset:
return JsonResponse({'locations': []})
else:
# For other thematics, try the base class
try:
asset = asset_class.objects.get(pk=asset_id)
except asset_class.DoesNotExist:
return JsonResponse({'locations': []})
locations = []
has_more = False
if thematic_code == 'structures':
from assets.models.structures import StructureLocation
# Get locations from the same structure when available; otherwise
# fall back to all structure locations so unlinked assets can be associated.
structure = getattr(asset, 'structure', None)
if not structure and getattr(asset, 'location', None):
structure = getattr(asset.location, 'structure', None)
if structure:
structure_locations_qs = StructureLocation.objects.filter(structure=structure)
else:
structure_locations_qs = StructureLocation.objects.all()
if query:
structure_locations_qs = structure_locations_qs.filter(
Q(code__icontains=query) |
Q(name_fr__icontains=query) |
Q(name_nl__icontains=query)
)
structure_locations = list(structure_locations_qs.order_by('code')[:limit + 1])
has_more = len(structure_locations) > limit
for loc in structure_locations[:limit]:
locations.append({
'id': loc.id,
'code': loc.code,
'name': loc.get_name() if hasattr(loc, 'get_name') else str(loc)
})
elif thematic_code == 'trafficlights':
from assets.models.trafficlights import TrafficLightIntersection
# Get all intersections (or filtered if necessary)
intersections_qs = TrafficLightIntersection.objects.all()
if query:
intersections_qs = intersections_qs.filter(
Q(code__icontains=query) |
Q(name_fr__icontains=query) |
Q(name_nl__icontains=query)
)
intersections = list(intersections_qs.order_by('code')[:limit + 1])
has_more = len(intersections) > limit
for intersection in intersections[:limit]:
locations.append({
'id': intersection.id,
'code': intersection.code,
'name': intersection.get_name() if hasattr(intersection, 'get_name') else str(intersection)
})
# Add similar logic for other thematics as needed
return JsonResponse({'locations': locations, 'has_more': has_more})
# ---------------------------------------------------------------------------
# Linked Assets API
# ---------------------------------------------------------------------------
@require_GET
def get_linked_asset_types(request, asset_model, asset_id):
"""
Returns metadata about the child asset types linkable to this asset
(reverse FK relations) and the parent FK fields it has.
Used to populate the "link / create asset" modal.
"""
from django.db.models import ManyToOneRel, ForeignKey
from assets.models import AbstractAsset
asset, model_class, _ct = _get_asset_by_ct(asset_model, asset_id)
if not can_edit_asset(request.user, asset):
return JsonResponse({'success': False, 'error': _("Permission refusée")}, status=403)
EXCLUDED_CHILD_FIELDS = {'replaced_by'}
EXCLUDED_PARENT_FIELDS = {
'model', 'category', 'replaced_by', 'marked_as_removed_by',
'controller_maintenance_contract',
}
# Child asset types (reverse FK relations)
child_types = []
for rel in type(asset)._meta.get_fields():
if not isinstance(rel, ManyToOneRel):
continue
related_model = rel.related_model
try:
if not issubclass(related_model, AbstractAsset):
continue
except TypeError:
continue
if rel.field.name in EXCLUDED_CHILD_FIELDS and related_model == type(asset):
continue
child_types.append({
'model_name': related_model._meta.model_name,
'verbose_name': str(related_model._meta.verbose_name).capitalize(),
'verbose_name_plural': str(related_model._meta.verbose_name_plural).capitalize(),
'field_on_child': rel.field.name,
})
# Parent FK fields on this asset
parent_types = []
for field in type(asset)._meta.get_fields():
if not isinstance(field, ForeignKey):
continue
if field.name in EXCLUDED_PARENT_FIELDS:
continue
related_model = field.related_model
try:
if not issubclass(related_model, AbstractAsset):
continue
except TypeError:
continue
current_parent = getattr(asset, field.name, None)
parent_types.append({
'field_name': field.name,
'model_name': related_model._meta.model_name,
'verbose_name': str(field.verbose_name).capitalize(),
'current_id': current_parent.pk if current_parent else None,
'current_code': current_parent.code if current_parent else None,
})
return JsonResponse({
'success': True,
'child_types': child_types,
'parent_types': parent_types,
})
@require_GET
def search_assets_by_model(request, model_name):
"""
Autocomplete search for assets of a given model type.
Query params:
q – search string (matches code / name_fr / name_nl)
limit – max results (default 10, max 50)
field_on_child – FK field name on the child model (used to optionallyfilter: only unlinked or already linked to parent_id)
parent_id – current parent ID
"""
from assets.models import AbstractAsset
from django.db.models import Q
model_class = None
try:
cts = ContentType.objects.filter(model=model_name.lower())
if not cts.exists():
return JsonResponse({'results': []})
# Prefer the ContentType from the 'assets' app, then fall back to any matching one
cts_sorted = sorted(cts, key=lambda ct: (0 if ct.app_label == 'assets' else 1))
for ct in cts_sorted:
mc = ct.model_class()
try:
if mc is not None and issubclass(mc, AbstractAsset):
model_class = mc
break
except TypeError:
continue
except Exception:
return JsonResponse({'results': []})
if model_class is None:
return JsonResponse({'results': []})
q = request.GET.get('q', '').strip()
limit = min(int(request.GET.get('limit', 10)), 50)
field_on_child = request.GET.get('field_on_child', '')
parent_id = request.GET.get('parent_id', '')
qs = model_class.objects.all()
if q:
qs = qs.filter(
Q(code__icontains=q) | Q(name_fr__icontains=q) | Q(name_nl__icontains=q)
)
# Show: assets where FK is NULL OR already linked to this parent
if field_on_child and parent_id:
try:
pid = int(parent_id)
qs = qs.filter(
Q(**{f'{field_on_child}__isnull': True}) |
Q(**{field_on_child: pid})
)
except (ValueError, TypeError):
pass
qs = qs[:limit]
results = []
for a in qs:
results.append({
'id': a.pk,
'code': a.code,
'name': getattr(a, 'name_fr', None) or getattr(a, 'name_nl', None) or '',
'status': getattr(a, 'status', ''),
})
return JsonResponse({'results': results})
@require_POST
def link_child_asset(request, parent_model, parent_id):
"""
Link an existing child asset to this parent by setting the FK on the child.
Body JSON: {child_model, child_id, field_on_child}
"""
from assets.models import AbstractAsset
parent_asset, parent_model_class, _ct = _get_asset_by_ct(parent_model, parent_id)
if not can_edit_asset(request.user, parent_asset):
return JsonResponse({'success': False, 'error': _("Permission refusée")}, status=403)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400)
child_model_name = data.get('child_model', '')
child_id = data.get('child_id')
field_on_child = data.get('field_on_child', '')
try:
child_ct = ContentType.objects.get(app_label='assets', model=child_model_name.lower())
child_model_class = child_ct.model_class()
except ContentType.DoesNotExist:
return JsonResponse({'success': False, 'error': _("Type d'asset inconnu")}, status=400)
child_asset = get_object_or_404(child_model_class, pk=child_id)
# Validate that field_on_child is a FK pointing to parent's model
try:
field_obj = child_model_class._meta.get_field(field_on_child)
if field_obj.related_model != parent_model_class:
return JsonResponse({'success': False, 'error': _("Champ de liaison invalide")}, status=400)
except Exception:
return JsonResponse({'success': False, 'error': _("Champ de liaison inconnu")}, status=400)
with transaction.atomic():
setattr(child_asset, field_on_child, parent_asset)
child_asset._history_user = request.user
child_asset.save()
child_url = child_asset.get_absolute_url() if hasattr(child_asset, 'get_absolute_url') else None
return JsonResponse({
'success': True,
'message': _("Asset lié avec succès"),
'child_id': child_asset.pk,
'child_code': child_asset.code,
'child_name': child_asset.get_name() if hasattr(child_asset, 'get_name') else '',
'child_url': child_url,
'child_model_name': child_model_name,
'field_on_child': field_on_child,
})
@require_POST
def unlink_child_asset(request, parent_model, parent_id):
"""
Unlink a child asset from this parent (clear the FK on the child).
Body JSON: {child_model, child_id, field_on_child}
"""
parent_asset, parent_model_class, _ct = _get_asset_by_ct(parent_model, parent_id)
if not can_edit_asset(request.user, parent_asset):
return JsonResponse({'success': False, 'error': _("Permission refusée")}, status=403)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400)
child_model_name = data.get('child_model', '')
child_id = data.get('child_id')
field_on_child = data.get('field_on_child', '')
try:
child_ct = ContentType.objects.get(app_label='assets', model=child_model_name.lower())
child_model_class = child_ct.model_class()
except ContentType.DoesNotExist:
return JsonResponse({'success': False, 'error': _("Type d'asset inconnu")}, status=400)
child_asset = get_object_or_404(child_model_class, pk=child_id)
# Verify child currently points to this parent
current_parent_id = getattr(child_asset, field_on_child + '_id', None)
if current_parent_id != parent_asset.pk:
return JsonResponse(
{'success': False, 'error': _("Cet asset n'est pas lié à ce parent")}, status=400
)
# Validate field is nullable
try:
field_obj = child_model_class._meta.get_field(field_on_child)
if not field_obj.null:
return JsonResponse(
{'success': False, 'error': _("Ce champ est obligatoire et ne peut pas être vidé")},
status=400
)
except Exception:
return JsonResponse({'success': False, 'error': _("Champ de liaison inconnu")}, status=400)
with transaction.atomic():
setattr(child_asset, field_on_child, None)
child_asset._history_user = request.user
child_asset.save()
return JsonResponse({'success': True, 'message': _("Asset délié avec succès")})
@require_POST
def set_parent_asset(request, asset_model, asset_id):
"""
Set (or clear) a parent FK field on the current asset.
Body JSON: {field_name, parent_model, parent_id}
- If parent_id is null/empty → clears the field (sets it to None).
- Otherwise → sets the FK to the given parent asset.
"""
from assets.models import AbstractAsset
asset, model_class, _ct = _get_asset_by_ct(asset_model, asset_id)
if not can_edit_asset(request.user, asset):
return JsonResponse({'success': False, 'error': _("Permission refusée")}, status=403)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400)
field_name = data.get('field_name', '')
parent_model_name = data.get('parent_model', '')
new_parent_id = data.get('parent_id') # may be None to clear
# Validate that field_name is indeed a FK on this asset pointing to an AbstractAsset subclass
try:
from django.db.models import ForeignKey
field_obj = model_class._meta.get_field(field_name)
if not isinstance(field_obj, ForeignKey):
return JsonResponse({'success': False, 'error': _("Champ de liaison invalide")}, status=400)
related_model = field_obj.related_model
if not issubclass(related_model, AbstractAsset):
return JsonResponse({'success': False, 'error': _("Champ de liaison invalide")}, status=400)
except Exception:
return JsonResponse({'success': False, 'error': _("Champ de liaison inconnu")}, status=400)
if new_parent_id:
# Resolve parent asset
try:
parent_ct = ContentType.objects.get(app_label='assets', model=parent_model_name.lower())
parent_model_class = parent_ct.model_class()
except ContentType.DoesNotExist:
return JsonResponse({'success': False, 'error': _("Type d'asset inconnu")}, status=400)
# Make sure the field actually points to this parent model
if field_obj.related_model != parent_model_class:
return JsonResponse({'success': False, 'error': _("Champ de liaison invalide")}, status=400)
parent_asset = get_object_or_404(parent_model_class, pk=new_parent_id)
with transaction.atomic():
setattr(asset, field_name, parent_asset)
asset._history_user = request.user
asset.save()
parent_url = parent_asset.get_absolute_url() if hasattr(parent_asset, 'get_absolute_url') else None
return JsonResponse({
'success': True,
'message': _("Parent lié avec succès"),
'parent_id': parent_asset.pk,
'parent_code': parent_asset.code,
'parent_name': parent_asset.get_name() if hasattr(parent_asset, 'get_name') else '',
'parent_url': parent_url,
'field_name': field_name,
})
else:
# Clear the field
if not field_obj.null:
return JsonResponse(
{'success': False, 'error': _("Ce champ est obligatoire et ne peut pas être vidé")},
status=400
)
with transaction.atomic():
setattr(asset, field_name, None)
asset._history_user = request.user
asset.save()
return JsonResponse({'success': True, 'message': _("Parent délié avec succès"), 'field_name': field_name})
@require_POST
def create_and_link_child_asset(request, parent_model, parent_id):
"""
Create a new child asset and link it to this parent.
Body JSON: {child_model, field_on_child, category_id (opt), model_id (opt), data (opt)}
"""
parent_asset, parent_model_class, _ct = _get_asset_by_ct(parent_model, parent_id)
if not can_edit_asset(request.user, parent_asset):
return JsonResponse({'success': False, 'error': _("Permission refusée")}, status=403)
# Check create permission at thematic level
thematic = None
if hasattr(parent_asset, 'category') and parent_asset.category and parent_asset.category.thematic:
thematic = parent_asset.category.thematic
elif (hasattr(parent_asset, 'model') and parent_asset.model
and getattr(parent_asset.model, 'category', None)
and parent_asset.model.category.thematic):
thematic = parent_asset.model.category.thematic
if thematic:
thematic_actions = get_allowed_actions_for_thematic(request.user, thematic)
if 'create' not in thematic_actions:
return JsonResponse(
{'success': False, 'error': _("Vous n'avez pas la permission de créer des assets")},
status=403
)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400)
child_model_name = data.get('child_model', '')
field_on_child = data.get('field_on_child', '')
category_id = data.get('category_id')
model_id = data.get('model_id')
extra_data = data.get('data', {})
try:
child_ct = ContentType.objects.get(app_label='assets', model=child_model_name.lower())
child_model_class = child_ct.model_class()
except ContentType.DoesNotExist:
return JsonResponse({'success': False, 'error': _("Type d'asset inconnu")}, status=400)
# Validate field
try:
field_obj = child_model_class._meta.get_field(field_on_child)
if field_obj.related_model != parent_model_class:
return JsonResponse({'success': False, 'error': _("Champ de liaison invalide")}, status=400)
except Exception:
return JsonResponse({'success': False, 'error': _("Champ de liaison inconnu")}, status=400)
from django.db import IntegrityError as DBIntegrityError
from django.db.models import ForeignKey as DjangoForeignKey
with transaction.atomic():
new_child = child_model_class()
# Set parent FK
setattr(new_child, field_on_child, parent_asset)
# Defaults set before extra_data so user values can override if needed
new_child.status = 'active'
new_child.installation_date = timezone.now()
# Set category if provided at top level (backward compat)
if category_id:
try:
new_child.category_id = int(category_id)
except (ValueError, TypeError):
pass
# Apply all extra form data (includes model_id, intersection_id, etc.)
# field_name_id fields (FK raw IDs) are set directly to avoid DB lookups
SKIP_KEYS = {'id', 'code'}
for k, v in extra_data.items():
if k in SKIP_KEYS or v is None or v == '':
continue
if not hasattr(new_child, k):
continue
# Coerce FK _id values to int
if k.endswith('_id'):
try:
v = int(v)
except (ValueError, TypeError):
continue
setattr(new_child, k, v)
# Backward-compat: top-level model_id (used by old frontend)
if model_id and not extra_data.get('model_id'):
try:
mf = child_model_class._meta.get_field('model')
new_child.model_id = int(model_id)
except Exception:
pass
# Generate code using parent as "location"
new_child.code = _generate_asset_code(child_model_class, parent_asset)
new_child._history_user = request.user
try:
new_child.save()
except DBIntegrityError as exc:
# Parse the violated column from the DB error to give a user-friendly message
import re as _re
m = _re.search(r'colonne\s+[«\"]([\w]+)[»\"]', str(exc)) \
or _re.search(r'column\s+"([\w]+)"', str(exc))
if m:
col = m.group(1)
# Try to get verbose name from the model field
try:
bad_field = child_model_class._meta.get_field(col.removesuffix('_id'))
col = str(getattr(bad_field, 'verbose_name', col)).capitalize()
except Exception:
pass
msg = _("Le champ « %(field)s » est obligatoire.") % {'field': col}
else:
msg = _("Impossible de créer l'asset : un champ obligatoire est manquant.")
return JsonResponse({'success': False, 'error': msg}, status=400)
child_url = new_child.get_absolute_url() if hasattr(new_child, 'get_absolute_url') else None
return JsonResponse({
'success': True,
'message': _("Asset créé et lié avec succès"),
'child_id': new_child.pk,
'child_code': new_child.code,
'child_name': new_child.get_name() if hasattr(new_child, 'get_name') else '',
'child_url': child_url,
'child_model_name': child_model_name,
'field_on_child': field_on_child,
})
@require_POST
def bulk_edit_assets(request):
"""
Bulk update status or validation_status of assets.
Expects JSON body:
{
"assets": [{"model": "...", "object_id": 123}, ...],
"field_name": "status" | "validation_status",
"new_value": "..."
}
"""
from django.db import transaction
from assets.permissions import can_edit_asset, can_validate_asset
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400)
assets_data = data.get('assets', [])
field_name = data.get('field_name')
new_value = data.get('new_value')
if field_name not in ('status', 'validation_status'):
return JsonResponse({'success': False, 'error': _("Champ non modifiable en masse")}, status=400)
updated_count = 0
skipped_count = 0
errors = []
with transaction.atomic():
for item in assets_data:
model_name = item.get('model')
object_id = item.get('object_id')
if not model_name or not object_id:
continue
try:
ct = ContentType.objects.get(app_label='assets', model=model_name)
asset = ct.get_object_for_this_type(pk=object_id)
except Exception:
skipped_count += 1
continue
# Permission check
if field_name == 'status':
has_perm = can_edit_asset(request.user, asset)
elif field_name == 'validation_status':
has_perm = can_validate_asset(request.user, asset)
else:
has_perm = False
if not has_perm:
skipped_count += 1
continue
success, error = _update_asset_field(asset, field_name, new_value, ct.model_class())
if success:
asset._history_user = request.user
asset.save(update_fields=[field_name])
updated_count += 1
else:
errors.append(error)
skipped_count += 1
return JsonResponse({
'success': True,
'updated_count': updated_count,
'skipped_count': skipped_count,
'errors': errors,
'message': _("%(updated)s équipement(s) mis à jour, %(skipped)s ignoré(s).") % {
'updated': updated_count,
'skipped': skipped_count
}
})