feat: add functionality to create interventions directly from asset views and update asset model content type lookups.
This commit is contained in:
parent
286c3e07f2
commit
6b42824d4e
8 changed files with 264 additions and 10 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue