# -------------------- # 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 } })