2459 lines
93 KiB
Python
2459 lines
93 KiB
Python
# --------------------
|
||
# 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
|
||
}
|
||
})
|
||
|
||
|
||
@require_POST
|
||
def create_intervention_for_asset(request, asset_model, asset_id):
|
||
"""
|
||
Create a new intervention directly linked to an asset.
|
||
Requires user to have intervention creation permission for the asset's thematic.
|
||
"""
|
||
if not request.user.is_authenticated:
|
||
return JsonResponse({'success': False, 'error': _("Non authentifié.")}, status=401)
|
||
|
||
asset, model_class, content_type = _get_asset_by_ct(asset_model, asset_id)
|
||
|
||
thematic = None
|
||
if hasattr(asset, 'category') and asset.category and asset.category.thematic:
|
||
thematic = asset.category.thematic
|
||
|
||
from interventions.permissions import can_add_intervention
|
||
if not can_add_intervention(request.user, thematic):
|
||
return JsonResponse({'success': False, 'error': _("Permission refusée pour créer une intervention.")}, status=403)
|
||
|
||
title = request.POST.get('title', '').strip()
|
||
if not title:
|
||
return JsonResponse({'success': False, 'error': _("Le titre est obligatoire.")}, status=400)
|
||
|
||
symptom_id = request.POST.get('symptom_id')
|
||
maintain_type = request.POST.get('maintain_type', 'corrective')
|
||
priority = request.POST.get('priority', '3')
|
||
init_description = request.POST.get('init_description', '').strip()
|
||
|
||
from interventions.models import Intervention, InterventionAsset, Symptom, InterventionLocation
|
||
|
||
symptom = None
|
||
if symptom_id:
|
||
symptom = Symptom.objects.filter(pk=symptom_id, is_active=True).first()
|
||
|
||
geom = getattr(asset, 'geom', None)
|
||
if geom is not None:
|
||
from django.contrib.gis.geos import Polygon, MultiPolygon
|
||
if isinstance(geom, Polygon):
|
||
geom = MultiPolygon(geom)
|
||
elif not isinstance(geom, MultiPolygon):
|
||
geom = None
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
intervention = Intervention.objects.create(
|
||
title=title,
|
||
thematic=thematic,
|
||
symptom=symptom,
|
||
maintain_type=maintain_type,
|
||
priority=priority,
|
||
init_description=init_description,
|
||
status='in_preparation',
|
||
created_by=request.user,
|
||
geom=geom,
|
||
)
|
||
InterventionAsset.objects.create(
|
||
intervention=intervention,
|
||
content_type=content_type,
|
||
object_id=asset.pk
|
||
)
|
||
loc_obj = getattr(asset, 'street', None) or getattr(asset, 'location', None) or getattr(asset, 'intersection', None)
|
||
if loc_obj:
|
||
loc_ct = ContentType.objects.get_for_model(loc_obj)
|
||
InterventionLocation.objects.get_or_create(
|
||
intervention=intervention,
|
||
content_type=loc_ct,
|
||
object_id=loc_obj.pk
|
||
)
|
||
|
||
redirect_url = reverse('interventions:intervention_detail', args=[intervention.id])
|
||
return JsonResponse({
|
||
'success': True,
|
||
'redirect_url': redirect_url,
|
||
'intervention_id': intervention.id,
|
||
'intervention_code': intervention.code,
|
||
})
|
||
except Exception as e:
|
||
return JsonResponse({'success': False, 'error': str(e)}, status=500)
|
||
|
||
|