feat: add functionality to create interventions directly from asset views and update asset model content type lookups.

This commit is contained in:
kdeterme 2026-08-07 11:48:58 +02:00
parent 286c3e07f2
commit 6b42824d4e
8 changed files with 264 additions and 10 deletions

View file

@ -133,7 +133,7 @@ class ParkingSpot(AbstractGeoAsset):
ordering = ['code', 'name_fr']
def get_absolute_url(self):
return reverse('assets:sign_assets_detail', kwargs={'asset_model': 'parking_spot', 'asset_id': self.id})
return reverse('assets:parking_assets_detail', kwargs={'asset_model': 'parking_spot', 'asset_id': self.id})
def __str__(self):
type_label = self.get_spot_type_display()

View file

@ -9,6 +9,11 @@
<span class="badge bg-secondary rounded-pill ms-1">{{ interventions|length }}</span>
{% endif %}
</h5>
{% if can_add_intervention %}
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#createInterventionForAssetModal">
<i class="bi bi-plus-lg me-1"></i>{% translate "Créer une intervention" %}
</button>
{% endif %}
</div>
<div class="card-body p-0">
{% if interventions %}
@ -37,6 +42,13 @@
<a href="{% url 'interventions:intervention_detail' intervention.id %}" class="text-reset text-decoration-none">
{{ intervention.title }}
</a>
{% if intervention.maintain_type == 'corrective' %}
<span class="badge bg-danger ms-1" title="{% translate 'Maintenance corrective' %}">{% translate "Corrective" %}</span>
{% elif intervention.maintain_type == 'preventive' %}
<span class="badge bg-warning text-dark ms-1" title="{% translate 'Maintenance préventive' %}">{% translate "Préventive" %}</span>
{% elif intervention.maintain_type == 'ameliorative' %}
<span class="badge bg-success ms-1" title="{% translate 'Maintenance améliorative' %}">{% translate "Améliorative" %}</span>
{% endif %}
</td>
<td>
{% if intervention.symptom %}
@ -87,3 +99,135 @@
{% endif %}
</div>
</div>
{% if can_add_intervention %}
<!-- Modal de création d'une intervention depuis l'asset -->
<div class="modal fade" id="createInterventionForAssetModal" tabindex="-1" aria-labelledby="createInterventionForAssetModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title" id="createInterventionForAssetModalLabel">
<i class="bi bi-wrench-adjustable me-2"></i>{% translate "Nouvelle intervention" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<form id="createInterventionForAssetForm" action="{% url 'assets:create_intervention_for_asset' asset_model asset.id %}" method="post">
{% csrf_token %}
<div class="modal-body">
<div id="createInterventionAlert" class="alert alert-danger d-none mb-3"></div>
<div class="mb-3">
<label class="form-label fw-semibold required">{% translate "Titre" %}</label>
<input type="text" name="title" class="form-control" placeholder="{% translate 'Titre de l\'intervention' %}" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Symptôme" %}</label>
<select name="symptom_id" class="form-select">
<option value="">{% translate "-- Sélectionner un symptôme --" %}</option>
{% for s in symptoms %}
<option value="{{ s.id }}">{{ s.get_name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold d-block">{% translate "Type de maintenance" %}</label>
<div class="btn-group w-100" role="group" aria-label="{% translate 'Type de maintenance' %}">
<input type="radio" class="btn-check" name="maintain_type" id="maintain_type_corrective" value="corrective" checked>
<label class="btn btn-outline-danger d-flex align-items-center justify-content-center gap-1 py-2" for="maintain_type_corrective">
<i class="bi bi-wrench"></i>
<span>{% translate "Corrective" %}</span>
</label>
<input type="radio" class="btn-check" name="maintain_type" id="maintain_type_preventive" value="preventive">
<label class="btn btn-outline-warning text-dark d-flex align-items-center justify-content-center gap-1 py-2" for="maintain_type_preventive">
<i class="bi bi-shield-check"></i>
<span>{% translate "Préventive" %}</span>
</label>
<input type="radio" class="btn-check" name="maintain_type" id="maintain_type_ameliorative" value="ameliorative">
<label class="btn btn-outline-success d-flex align-items-center justify-content-center gap-1 py-2" for="maintain_type_ameliorative">
<i class="bi bi-arrow-up-circle"></i>
<span>{% translate "Améliorative" %}</span>
</label>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Priorité" %}</label>
<select name="priority" class="form-select">
<option value="1">{% translate "Urgent" %}</option>
<option value="2">{% translate "Élevé" %}</option>
<option value="3" selected>{% translate "Normal" %}</option>
<option value="4">{% translate "Bas" %}</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Description / Remarques" %}</label>
<textarea name="init_description" class="form-control" rows="3" placeholder="{% translate 'Détails ou remarques initiales...' %}"></textarea>
</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-primary" id="submitCreateInterventionBtn">
<span class="spinner-border spinner-border-sm me-1 d-none" id="submitCreateInterventionSpinner" role="status"></span>
<i class="bi bi-check-circle me-1" id="submitCreateInterventionIcon"></i>{% translate "Créer" %}
</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function() {
const form = document.getElementById('createInterventionForAssetForm');
if (!form) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
const alertDiv = document.getElementById('createInterventionAlert');
const submitBtn = document.getElementById('submitCreateInterventionBtn');
const spinner = document.getElementById('submitCreateInterventionSpinner');
const icon = document.getElementById('submitCreateInterventionIcon');
alertDiv.classList.add('d-none');
alertDiv.textContent = '';
submitBtn.disabled = true;
if (spinner) spinner.classList.remove('d-none');
if (icon) icon.classList.add('d-none');
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success && data.redirect_url) {
window.location.href = data.redirect_url;
} else {
alertDiv.textContent = data.error || '{% translate "Une erreur est survenue." %}';
alertDiv.classList.remove('d-none');
submitBtn.disabled = false;
if (spinner) spinner.classList.add('d-none');
if (icon) icon.classList.remove('d-none');
}
})
.catch(err => {
alertDiv.textContent = '{% translate "Erreur réseau lors de la création." %}';
alertDiv.classList.remove('d-none');
submitBtn.disabled = false;
if (spinner) spinner.classList.add('d-none');
if (icon) icon.classList.remove('d-none');
});
});
})();
</script>
{% endif %}

View file

@ -438,19 +438,23 @@ def warranty_remaining_months(asset):
return remaining
@register.inclusion_tag('assets/components/asset_interventions.html')
def asset_interventions(asset):
@register.inclusion_tag('assets/components/asset_interventions.html', takes_context=True)
def asset_interventions(context, asset):
"""
Display all interventions associated with an asset.
Also provides context for adding a new intervention directly from the asset page.
"""
if asset is None or not hasattr(asset, 'pk') or asset.pk is None:
return {
'asset': asset,
'interventions': [],
'can_add_intervention': False,
'symptoms': [],
}
from django.contrib.contenttypes.models import ContentType
from interventions.models import Intervention, InterventionAsset, STATUS_COLORS, STATUS_COLORS_DARK_TEXT
from interventions.models import Intervention, InterventionAsset, Symptom, STATUS_COLORS, STATUS_COLORS_DARK_TEXT
from interventions.permissions import can_add_intervention
asset_ct = ContentType.objects.get_for_model(asset)
intervention_ids = InterventionAsset.objects.filter(
@ -464,10 +468,31 @@ def asset_interventions(asset):
'symptom', 'contract', 'assigned_provider', 'assigned_team', 'created_by'
).order_by('-creation_time'))
thematic = None
if hasattr(asset, 'category') and asset.category and asset.category.thematic:
thematic = asset.category.thematic
request = context.get('request')
user = request.user if request else None
can_add = False
symptoms = []
if user and user.is_authenticated:
can_add = can_add_intervention(user, thematic)
if can_add:
if thematic:
symptoms = list(Symptom.objects.filter(thematic=thematic, is_active=True).order_by('order_number', 'name_fr'))
else:
symptoms = list(Symptom.objects.filter(is_active=True).order_by('name_fr'))
return {
'asset': asset,
'asset_model': asset_ct.model,
'interventions': interventions,
'can_add_intervention': can_add,
'symptoms': symptoms,
'status_colors': STATUS_COLORS,
'status_colors_dark_text': STATUS_COLORS_DARK_TEXT,
}

View file

@ -42,6 +42,7 @@ urlpatterns = [
path('<str:asset_model>/<int:asset_id>/update/', asset_editing.update_asset, name='update_asset'),
path('<str:asset_model>/<int:asset_id>/archive/', asset_editing.archive_asset, name='archive_asset'),
path('<str:asset_model>/<int:asset_id>/replace/', asset_editing.replace_asset, name='replace_asset'),
path('<str:asset_model>/<int:asset_id>/create-intervention/', asset_editing.create_intervention_for_asset, name='create_intervention_for_asset'),
path('<str:asset_model>/<int:asset_id>/documents/add/', asset_editing.asset_add_document, name='asset_add_document'),
path('<str:asset_model>/<int:asset_id>/documents/<int:document_id>/delete/', asset_editing.asset_delete_document, name='asset_delete_document'),
path('<str:asset_model>/<int:asset_id>/documents/', asset_editing.asset_document_list_html, name='asset_document_list_html'),

View file

@ -2376,3 +2376,84 @@ def bulk_edit_assets(request):
}
})
@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)

View file

@ -244,10 +244,11 @@ def locations_geojson(request):
def mobile_asset_detail(request, thematic_code, asset_model, asset_id):
"""Mobile-friendly asset detail using the generic template with the mobile base."""
try:
content_type = ContentType.objects.get(app_label='assets', model=asset_model)
model_name = asset_model.lower().replace('_', '')
content_type = ContentType.objects.get(app_label='assets', model=model_name)
model_class = content_type.model_class()
except ContentType.DoesNotExist:
raise Http404(_("Modèle %(asset_model)s inconnu.") % (asset_model))
raise Http404(_("Modèle %(asset_model)s inconnu.") % {'asset_model': asset_model})
asset = get_object_or_404(model_class, id=asset_id)

View file

@ -775,10 +775,11 @@ def sign_streets_detail(request, street_id):
def sign_assets_detail(request, asset_model, asset_id):
try:
# Récupérer le ContentType pour le modèle fourni (spécifier l'app_label 'assets' pour éviter les conflits)
content_type = ContentType.objects.get(app_label='assets', model=asset_model)
model_name = asset_model.lower().replace('_', '')
content_type = ContentType.objects.get(app_label='assets', model=model_name)
model_class = content_type.model_class()
except ContentType.DoesNotExist:
raise Http404(_("Modèle %(asset_model)s inconnu.") % (asset_model))
raise Http404(_("Modèle %(asset_model)s inconnu.") % {'asset_model': asset_model})
# Récupérer l’objet asset
asset = get_object_or_404(model_class, id=asset_id)

View file

@ -647,10 +647,11 @@ def structures_locations_detail(request, location_id):
def structures_assets_detail(request, asset_model, asset_id):
try:
# Récupérer le ContentType pour le modèle fourni
content_type = ContentType.objects.get(model=asset_model)
model_name = asset_model.lower().replace('_', '')
content_type = ContentType.objects.get(app_label='assets', model=model_name)
model_class = content_type.model_class()
except ContentType.DoesNotExist:
raise Http404(_("Modèle %(asset_model)s inconnu.") % (asset_model))
raise Http404(_("Modèle %(asset_model)s inconnu.") % {'asset_model': asset_model})
# Récupérer l’objet asset
asset = get_object_or_404(model_class, id=asset_id)