feat: add support for TrafficLightProgramming management and textarea fields in generic asset templates

This commit is contained in:
kdeterme 2026-08-19 11:30:42 +02:00
parent 80cbb80d06
commit 94cb7148a0
10 changed files with 638 additions and 18 deletions

View file

@ -0,0 +1,22 @@
# Generated by Django 6.0.7 on 2026-08-19 09:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0021_sync_asset_parent_data'),
]
operations = [
migrations.AlterModelOptions(
name='trafficlightprogramming',
options={'ordering': ['-commissioning_date'], 'verbose_name': 'Programmation', 'verbose_name_plural': 'Programmations'},
),
migrations.AlterField(
model_name='trafficlightprogramming',
name='index',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Index'),
),
]

View file

@ -0,0 +1,52 @@
from django.db import migrations
def create_programming_category(apps, schema_editor):
Thematic = apps.get_model('common', 'Thematic')
AssetCategory = apps.get_model('assets', 'AssetCategory')
ContentType = apps.get_model('contenttypes', 'ContentType')
try:
thematic = Thematic.objects.get(code='trafficlights')
except Thematic.DoesNotExist:
thematic, _ = Thematic.objects.get_or_create(
code='trafficlights',
defaults={
'name_fr': 'Feux de signalisation',
'name_nl': 'Verkeerslichten',
'description': 'Gestion des carrefours à feux et équipements associés',
'icon': 'bi-stoplights',
'color': '#ffc107',
}
)
category, _cat_created = AssetCategory.objects.get_or_create(
code='TL_PROGRAMMING',
defaults={
'thematic': thematic,
'name_fr': 'Programmation',
'name_nl': 'Programmatie',
}
)
# Link ContentType for TrafficLightProgramming
try:
prog_ct = ContentType.objects.get(app_label='assets', model='trafficlightprogramming')
category.allowed_models.add(prog_ct)
except Exception:
pass
def remove_programming_category(apps, schema_editor):
AssetCategory = apps.get_model('assets', 'AssetCategory')
AssetCategory.objects.filter(code='TL_PROGRAMMING').delete()
class Migration(migrations.Migration):
dependencies = [
('assets', '0022_alter_trafficlightprogramming_options_and_more'),
]
operations = [
migrations.RunPython(create_programming_category, remove_programming_category),
]

View file

@ -97,6 +97,9 @@ class TrafficLightIntersection(AbstractLocation):
# Retrieve associated radars # Retrieve associated radars
radars = self.radars.filter(status='active').order_by('code') radars = self.radars.filter(status='active').order_by('code')
# Retrieve associated programmations
programmations = self.programmations.filter(status='active').order_by('code')
return { return {
"poles": all_poles, "poles": all_poles,
"cables": cables, "cables": cables,
@ -105,6 +108,7 @@ class TrafficLightIntersection(AbstractLocation):
"detectors": detectors, "detectors": detectors,
"electrical_cabinets": electrical_cabinets, "electrical_cabinets": electrical_cabinets,
"radars": radars, "radars": radars,
"programmations": programmations,
} }
def get_all_assets(self): def get_all_assets(self):
@ -132,6 +136,9 @@ class TrafficLightIntersection(AbstractLocation):
# Retrieve associated radars # Retrieve associated radars
radars = self.radars.all().order_by('code') radars = self.radars.all().order_by('code')
# Retrieve associated programmations
programmations = self.programmations.all().order_by('code')
return { return {
"poles": all_poles, "poles": all_poles,
"cables": cables, "cables": cables,
@ -140,6 +147,7 @@ class TrafficLightIntersection(AbstractLocation):
"detectors": detectors, "detectors": detectors,
"electrical_cabinets": electrical_cabinets, "electrical_cabinets": electrical_cabinets,
"radars": radars, "radars": radars,
"programmations": programmations,
} }
def get_direct_child_assets(self): def get_direct_child_assets(self):
@ -167,6 +175,9 @@ class TrafficLightIntersection(AbstractLocation):
# Retrieve associated radars # Retrieve associated radars
radars = self.radars.all().order_by('code') radars = self.radars.all().order_by('code')
# Retrieve associated programmations
programmations = self.programmations.all().order_by('code')
return { return {
"poles": all_poles, "poles": all_poles,
"cables": cables, "cables": cables,
@ -175,6 +186,7 @@ class TrafficLightIntersection(AbstractLocation):
"detectors": detectors, "detectors": detectors,
"electrical_cabinets": electrical_cabinets, "electrical_cabinets": electrical_cabinets,
"radars": radars, "radars": radars,
"programmations": programmations,
} }
def get_controller_contract(self): def get_controller_contract(self):
@ -709,19 +721,21 @@ class TrafficLightAccessory(TrafficLightAbstractGeoAsset):
# Treffic Light Programmation # Traffic Light Programmation
class TrafficLightProgramming(TrafficLightAbstractAsset): class TrafficLightProgramming(TrafficLightAbstractAsset):
intersection = models.ForeignKey(TrafficLightIntersection, on_delete=models.CASCADE, related_name="programmations") intersection = models.ForeignKey(TrafficLightIntersection, on_delete=models.CASCADE, related_name="programmations")
commissioning_date = models.DateTimeField(null=True, blank=True, verbose_name=_("Date de mise en service")) commissioning_date = models.DateTimeField(null=True, blank=True, verbose_name=_("Date de mise en service"))
uncommissioning_date = models.DateTimeField(null=True, blank=True, verbose_name=_("Date de mise hors service")) uncommissioning_date = models.DateTimeField(null=True, blank=True, verbose_name=_("Date de mise hors service"))
plan = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Plan")) plan = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Plan"))
index = models.CharField(max_length=100, verbose_name=_("Index")) index = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Index"))
info = models.TextField(null=True, blank=True, verbose_name=_("Informations")) info = models.TextField(null=True, blank=True, verbose_name=_("Informations"))
def __str__(self): def __str__(self):
return f"{self.index} - {self.intersection.code}" return f"{self.index or self.code} - {self.intersection.code}"
class Meta: class Meta:
verbose_name = _("Programmation")
verbose_name_plural = _("Programmations")
ordering = ['-commissioning_date'] ordering = ['-commissioning_date']

View file

@ -462,6 +462,8 @@
<input type="number" step="any" class="form-control form-control-sm" value="{{ field.input_value }}"> <input type="number" step="any" class="form-control form-control-sm" value="{{ field.input_value }}">
{% elif field.field_type == 'date' %} {% elif field.field_type == 'date' %}
<input type="date" class="form-control form-control-sm" value="{{ field.input_value }}"> <input type="date" class="form-control form-control-sm" value="{{ field.input_value }}">
{% elif field.field_type == 'textarea' %}
<textarea class="form-control form-control-sm" rows="3">{{ field.input_value }}</textarea>
{% else %} {% else %}
<input type="text" class="form-control form-control-sm" value="{{ field.input_value }}"> <input type="text" class="form-control form-control-sm" value="{{ field.input_value }}">
{% endif %} {% endif %}

View file

@ -850,7 +850,12 @@
<div class="card-header bg-light d-flex align-items-center gap-2 py-2"> <div class="card-header bg-light d-flex align-items-center gap-2 py-2">
<i class="bi bi-kanban text-secondary"></i> <i class="bi bi-kanban text-secondary"></i>
<span class="fw-semibold">{% translate "Programmations" %}</span> <span class="fw-semibold">{% translate "Programmations" %}</span>
<span class="badge bg-secondary ms-auto">{{ prog_list|length }}</span> <span class="badge bg-secondary">{{ prog_list|length }}</span>
{% if 'create' in allowed_location_actions %}
<button type="button" class="btn btn-sm btn-outline-primary ms-auto" data-bs-toggle="modal" data-bs-target="#addProgrammingModal" title="{% translate 'Ajouter une programmation' %}">
<i class="bi bi-plus-circle me-1"></i>{% translate "Ajouter" %}
</button>
{% endif %}
</div> </div>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
{% for prog in prog_list %} {% for prog in prog_list %}
@ -876,7 +881,12 @@
{% else %} {% else %}
<div class="text-center text-muted py-4"> <div class="text-center text-muted py-4">
<i class="bi bi-kanban fs-3 opacity-50 d-block mb-2"></i> <i class="bi bi-kanban fs-3 opacity-50 d-block mb-2"></i>
{% translate "Aucune programmation" %} <p class="mb-2">{% translate "Aucune programmation" %}</p>
{% if 'create' in allowed_location_actions %}
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addProgrammingModal">
<i class="bi bi-plus-circle me-1"></i>{% translate "Ajouter une programmation" %}
</button>
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
@ -1025,6 +1035,76 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Add Programming Modal -->
<div class="modal fade" id="addProgrammingModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-kanban me-2 text-primary"></i>{% translate "Ajouter une programmation" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addProgrammingForm" enctype="multipart/form-data" method="POST" action="{% url 'assets:trafficlights_add_programming' intersection.id %}">
{% csrf_token %}
<div class="modal-body">
<div id="addProgrammingAlert" class="alert d-none" role="alert"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">{% translate "Code" %}</label>
<input type="text" name="code" class="form-control" placeholder="{% translate 'Laisser vide pour auto-génération' %}">
<small class="text-muted">{% translate "Ex: " %}{{ intersection.code }}-PRG-001</small>
</div>
<div class="col-md-6">
<label class="form-label">{% translate "Index" %}</label>
<input type="text" name="index" class="form-control" value="{{ intersection.current_index|default:'' }}" placeholder="Ex: 01">
</div>
<div class="col-md-6">
<label class="form-label">{% translate "Plan" %}</label>
<input type="text" name="plan" class="form-control" placeholder="Ex: Plan 1A">
</div>
<div class="col-md-6">
<label class="form-label">{% translate "Statut" %}</label>
<select name="status" class="form-select">
<option value="active" selected>{% translate "Actif" %}</option>
<option value="in_preparation">{% translate "En préparation" %}</option>
<option value="inactive">{% translate "Inactif" %}</option>
<option value="removed">{% translate "Retiré" %}</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">{% translate "Date de mise en service" %}</label>
<input type="date" name="commissioning_date" class="form-control">
</div>
<div class="col-md-6">
<label class="form-label">{% translate "Date de mise hors service" %}</label>
<input type="date" name="uncommissioning_date" class="form-control">
</div>
<div class="col-12">
<label class="form-label">{% translate "Fichier de programmation" %}</label>
<input type="file" name="file" class="form-control">
<small class="text-muted">{% translate "Optionnel. Fichier joint à la programmation." %}</small>
</div>
<div class="col-12">
<label class="form-label">{% translate "Informations" %}</label>
<textarea name="info" rows="3" class="form-control" placeholder="{% translate 'Notes ou informations complémentaires...' %}"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-primary" id="addProgrammingSubmitBtn">
<i class="bi bi-plus-circle me-1"></i>{% translate "Créer la programmation" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %} {% endif %}
<!-- Georeferenced Plans Selection Modal --> <!-- Georeferenced Plans Selection Modal -->
@ -1385,5 +1465,55 @@
: 'bi bi-chevron-expand small'; : 'bi bi-chevron-expand small';
}); });
})(); })();
// Handle Add Programming form submission
(function () {
const form = document.getElementById('addProgrammingForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
const alertEl = document.getElementById('addProgrammingAlert');
const submitBtn = document.getElementById('addProgrammingSubmitBtn');
alertEl.className = 'alert d-none';
if (submitBtn) submitBtn.disabled = true;
const formData = new FormData(form);
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}';
fetch(form.action, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
})
.then(r => r.json().then(data => ({ status: r.status, ok: r.ok, data })))
.then(({ ok, data }) => {
if (ok && data.success) {
alertEl.textContent = data.message || "{% translate 'Programmation ajoutée avec succès.' %}";
alertEl.className = 'alert alert-success';
setTimeout(() => {
if (data.redirect_url) {
window.location.href = data.redirect_url;
} else {
window.location.reload();
}
}, 1000);
} else {
alertEl.textContent = data.error || "{% translate 'Erreur lors de la création de la programmation.' %}";
alertEl.className = 'alert alert-danger';
if (submitBtn) submitBtn.disabled = false;
}
})
.catch(err => {
console.error('Error creating programming:', err);
alertEl.textContent = "{% translate 'Une erreur inattendue est survenue.' %}";
alertEl.className = 'alert alert-danger';
if (submitBtn) submitBtn.disabled = false;
});
});
})();
</script> </script>
{% endblock script %} {% endblock script %}

View file

@ -100,7 +100,7 @@ def get_asset_specific_fields(asset):
from django.contrib.gis.db.models import GeometryField from django.contrib.gis.db.models import GeometryField
from django.db.models import (ForeignKey, OneToOneField, ManyToManyField, AutoField, from django.db.models import (ForeignKey, OneToOneField, ManyToManyField, AutoField,
BooleanField, FloatField, DecimalField, IntegerField, BooleanField, FloatField, DecimalField, IntegerField,
DateField, DateTimeField) DateField, DateTimeField, TextField)
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from assets.models import AbstractAsset, AbstractGeoAsset from assets.models import AbstractAsset, AbstractGeoAsset
@ -136,6 +136,10 @@ def get_asset_specific_fields(asset):
field_type = 'date' field_type = 'date'
display_value = value.strftime('%d/%m/%Y') if value else '-' display_value = value.strftime('%d/%m/%Y') if value else '-'
input_value = value.strftime('%Y-%m-%d') if value else '' input_value = value.strftime('%Y-%m-%d') if value else ''
elif isinstance(field, TextField):
field_type = 'textarea'
display_value = str(value) if value is not None else '-'
input_value = str(value) if value is not None else ''
elif field.choices: elif field.choices:
field_type = 'select' field_type = 'select'
choices_dict = dict(field.choices) choices_dict = dict(field.choices)

View file

@ -2,6 +2,7 @@ from django.test import TestCase, tag
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.gis.geos import MultiPolygon, Polygon from django.contrib.gis.geos import MultiPolygon, Polygon
from django.urls import reverse from django.urls import reverse
from django.contrib.contenttypes.models import ContentType
import json import json
import os import os
from unittest import skipUnless from unittest import skipUnless
@ -3333,4 +3334,276 @@ class TrafficLightIntersectionControllerModelTest(TestCase):
self.assertIn("Siemens Sitraffic C1000 NL", content_nl) self.assertIn("Siemens Sitraffic C1000 NL", content_nl)
class TrafficLightProgrammingTest(TestCase):
def setUp(self):
from django.contrib.gis.geos import Point
from django.contrib.contenttypes.models import ContentType
from assets.models.trafficlights import TrafficLightProgramming
self.thematic, _ = Thematic.objects.get_or_create(
code="trafficlights",
defaults={
"name_fr": "Feux de signalisation",
"name_nl": "Verkeerslichten"
}
)
from common.models import Role
self.user = User.objects.create_user(username="proguser", password="password123")
self.config = UserConfig.objects.create(user=self.user, limit_assets_to_contracts=False)
admin_role, _ = Role.objects.get_or_create(name="admin")
self.config.roles.add(admin_role)
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic,
can_view_assets=True,
can_edit_assets=True
)
self.category, _ = AssetCategory.objects.get_or_create(
code="TL_PROGRAMMING",
defaults={
"name_fr": "Programmation",
"name_nl": "Programmatie",
"thematic": self.thematic
}
)
prog_ct = ContentType.objects.get_for_model(TrafficLightProgramming)
self.category.allowed_models.add(prog_ct)
self.intersection = TrafficLightIntersection.objects.create(
code="K999",
name_fr="Carrefour Test Prog",
geom=Point(648000, 670000, srid=3812),
lon=4.3508,
lat=50.8465,
current_index="01"
)
def test_programming_category_exists_and_linked(self):
from assets.models.trafficlights import TrafficLightProgramming
prog_ct = ContentType.objects.get_for_model(TrafficLightProgramming)
self.assertTrue(self.category.allowed_models.filter(id=prog_ct.id).exists())
def test_intersection_get_assets_includes_programmations(self):
from assets.models.trafficlights import TrafficLightProgramming
prog_active = TrafficLightProgramming.objects.create(
code="K999-PRG-001",
intersection=self.intersection,
status="active",
index="01",
plan="Plan A"
)
prog_inactive = TrafficLightProgramming.objects.create(
code="K999-PRG-002",
intersection=self.intersection,
status="removed",
index="00",
plan="Plan Old"
)
active_assets = self.intersection.get_active_assets()
all_assets = self.intersection.get_all_assets()
self.assertIn("programmations", active_assets)
self.assertIn("programmations", all_assets)
self.assertEqual(len(active_assets["programmations"]), 1)
self.assertEqual(active_assets["programmations"][0].id, prog_active.id)
self.assertEqual(len(all_assets["programmations"]), 2)
def test_create_programming_via_dedicated_endpoint(self):
from assets.models.trafficlights import TrafficLightProgramming
from django.core.files.uploadedfile import SimpleUploadedFile
self.client.force_login(self.user)
test_file = SimpleUploadedFile("prog_test.txt", b"programming binary data", content_type="text/plain")
response = self.client.post(
reverse("assets:trafficlights_add_programming", kwargs={"intersection_id": self.intersection.id}),
{
"code": "K999-PRG-NEW",
"index": "02",
"plan": "Plan 2B",
"commissioning_date": "2026-05-01",
"uncommissioning_date": "2026-12-31",
"status": "active",
"info": "Notes sur la programmation",
"file": test_file
},
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["success"])
self.assertEqual(data["asset_code"], "K999-PRG-NEW")
prog = TrafficLightProgramming.objects.get(code="K999-PRG-NEW")
self.assertEqual(prog.intersection, self.intersection)
self.assertEqual(prog.index, "02")
self.assertEqual(prog.plan, "Plan 2B")
self.assertEqual(prog.info, "Notes sur la programmation")
from django.utils import timezone
self.assertEqual(timezone.localtime(prog.commissioning_date).strftime("%Y-%m-%d"), "2026-05-01")
self.assertEqual(timezone.localtime(prog.uncommissioning_date).strftime("%Y-%m-%d"), "2026-12-31")
# Check document attached
doc = prog.documents.first()
self.assertIsNotNone(doc)
self.assertEqual(doc.document_type, "programming_file")
def test_create_programming_via_create_asset(self):
from assets.models.trafficlights import TrafficLightProgramming
self.client.force_login(self.user)
response = self.client.post(
f"/assets/trafficlights/trafficlightintersection/{self.intersection.id}/create-asset/",
data=json.dumps({
"category_id": self.category.id,
"asset_type": "trafficlightprogramming",
"data": {}
}),
content_type="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["success"])
self.assertEqual(data["asset_type"], "trafficlightprogramming")
self.assertTrue(data["asset_code"].startswith("K999-PRG-"))
created_prog = TrafficLightProgramming.objects.get(id=data["asset_id"])
self.assertEqual(created_prog.intersection, self.intersection)
self.assertEqual(created_prog.category, self.category)
def test_create_programming_via_create_asset_api(self):
from assets.models.trafficlights import TrafficLightProgramming
self.client.force_login(self.user)
response = self.client.post(
reverse("assets:create_asset_api"),
data=json.dumps({
"thematic_code": "trafficlights",
"category_id": self.category.id,
"location_type": "trafficlightintersection",
"location_id": self.intersection.id,
"asset_type": "trafficlightprogramming",
"code": "K999-PRG-API",
"data": {
"index": "05",
"plan": "Plan API",
"info": "Created from API"
}
}),
content_type="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["success"])
self.assertEqual(data["asset_type"], "trafficlightprogramming")
prog = TrafficLightProgramming.objects.get(code="K999-PRG-API")
self.assertEqual(prog.intersection, self.intersection)
self.assertEqual(prog.index, "05")
self.assertEqual(prog.plan, "Plan API")
self.assertEqual(prog.info, "Created from API")
def test_update_programming_fields_via_api(self):
from assets.models.trafficlights import TrafficLightProgramming
from django.utils import timezone
prog = TrafficLightProgramming.objects.create(
code="K999-PRG-UPD",
intersection=self.intersection,
status="in_preparation",
index="01",
plan="Plan Init",
info="Initial Info"
)
self.client.force_login(self.user)
# Update index, plan, info, status, and dates
updates = {
"index": "02",
"plan": "Plan Updated",
"info": "Updated Info Notes",
"status": "active",
"commissioning_date": "2026-06-15",
"uncommissioning_date": "2027-01-01"
}
response = self.client.post(
reverse("assets:update_asset", kwargs={
"asset_model": "trafficlightprogramming",
"asset_id": prog.id
}),
data=json.dumps(updates),
content_type="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 200, f"Failed updating programming: {response.content}")
data = response.json()
self.assertTrue(data["success"])
prog.refresh_from_db()
self.assertEqual(prog.index, "02")
self.assertEqual(prog.plan, "Plan Updated")
self.assertEqual(prog.info, "Updated Info Notes")
self.assertEqual(prog.status, "active")
self.assertEqual(timezone.localtime(prog.commissioning_date).strftime("%Y-%m-%d"), "2026-06-15")
self.assertEqual(timezone.localtime(prog.uncommissioning_date).strftime("%Y-%m-%d"), "2027-01-01")
def test_programming_detail_view_renders_cms_fields(self):
from assets.models.trafficlights import TrafficLightProgramming
prog = TrafficLightProgramming.objects.create(
code="K999-PRG-VIEW",
intersection=self.intersection,
status="active",
index="03",
plan="Plan Detail",
info="Detailed Programming Info"
)
self.client.force_login(self.user)
response = self.client.get(
reverse("assets:trafficlights_assets_detail", kwargs={
"asset_model": "trafficlightprogramming",
"asset_id": prog.id
})
)
self.assertEqual(response.status_code, 200)
content = response.content.decode("utf-8")
self.assertIn("K999-PRG-VIEW", content)
self.assertIn("Plan Detail", content)
self.assertIn("Detailed Programming Info", content)
self.assertIn("K999", content)
def test_intersection_detail_view_renders_programmations_tab(self):
from assets.models.trafficlights import TrafficLightProgramming
prog = TrafficLightProgramming.objects.create(
code="K999-PRG-TAB",
intersection=self.intersection,
status="active",
index="01",
plan="Plan Tab"
)
self.client.force_login(self.user)
response = self.client.get(
reverse("assets:trafficlights_intersections_detail", kwargs={
"intersection_id": self.intersection.id
})
)
self.assertEqual(response.status_code, 200)
content = response.content.decode("utf-8")
self.assertIn("K999-PRG-TAB", content)
self.assertIn("assets-programmation", content)
self.assertIn("addProgrammingModal", content)

View file

@ -86,6 +86,7 @@ urlpatterns = [
path('trafficlights/intersections/<int:intersection_id>/document-config/', trafficlights.get_document_config, name='trafficlights_intersections_document_config'), path('trafficlights/intersections/<int:intersection_id>/document-config/', trafficlights.get_document_config, name='trafficlights_intersections_document_config'),
path('trafficlights/intersections/<str:intersection_code>/', trafficlights.trafficlights_intersections_get_by_code, name='trafficlights_interstrafficlights_intersections_get_by_codeections_detail'), path('trafficlights/intersections/<str:intersection_code>/', trafficlights.trafficlights_intersections_get_by_code, name='trafficlights_interstrafficlights_intersections_get_by_codeections_detail'),
path('trafficlights/intersections/<int:intersection_id>/add-document/', trafficlights.trafficlights_intersections_add_document, name='trafficlights_intersections_add_document'), path('trafficlights/intersections/<int:intersection_id>/add-document/', trafficlights.trafficlights_intersections_add_document, name='trafficlights_intersections_add_document'),
path('trafficlights/intersections/<int:intersection_id>/add-programming/', trafficlights.trafficlights_add_programming, name='trafficlights_add_programming'),
path('trafficlights/intersections/<int:document_id>/delete-document/', trafficlights.trafficlights_intersections_delete_document, name='trafficlights_intersections_delete_document'), path('trafficlights/intersections/<int:document_id>/delete-document/', trafficlights.trafficlights_intersections_delete_document, name='trafficlights_intersections_delete_document'),
path('trafficlights/assets/', trafficlights.trafficlights_assets, name='trafficlights_assets'), path('trafficlights/assets/', trafficlights.trafficlights_assets, name='trafficlights_assets'),
path('trafficlights/assets/geojson/', trafficlights.trafficlights_assets_geojson, name='trafficlights_assets_geojson'), path('trafficlights/assets/geojson/', trafficlights.trafficlights_assets_geojson, name='trafficlights_assets_geojson'),

View file

@ -457,6 +457,13 @@ def _update_asset_field(asset, field_name, new_value, model_class):
parsed = datetime.strptime(str(new_value), "%Y-%m-%d") parsed = datetime.strptime(str(new_value), "%Y-%m-%d")
except ValueError: except ValueError:
return False, _("Format de date invalide pour %(field)s") % {"field": field_name} return False, _("Format de date invalide pour %(field)s") % {"field": field_name}
if isinstance(field_obj, DateTimeField):
from datetime import datetime, time
from django.utils.timezone import make_aware, get_current_timezone, is_aware
if not isinstance(parsed, datetime):
parsed = datetime.combine(parsed, time.min)
if not is_aware(parsed):
parsed = make_aware(parsed, get_current_timezone())
setattr(asset, field_name, parsed) setattr(asset, field_name, parsed)
else: else:
setattr(asset, field_name, None) setattr(asset, field_name, None)
@ -858,12 +865,13 @@ def create_asset(request, thematic_code, location_type, location_id):
'success': False, 'success': False,
'error': _("Le modèle d'asset est requis pour ce type d'asset") 'error': _("Le modèle d'asset est requis pour ce type d'asset")
}, status=400) }, status=400)
except model_class._meta.get_field('model').related_model.DoesNotExist: except Exception as e:
return JsonResponse({ # Check if it was a DoesNotExist on model_id
'success': False, if model_id and 'DoesNotExist' in e.__class__.__name__:
'error': _("Modèle d'asset non trouvé") return JsonResponse({
}, status=400) 'success': False,
except Exception: 'error': _("Modèle d'asset non trouvé")
}, status=400)
# No 'model' field on this asset type, that's OK # No 'model' field on this asset type, that's OK
pass pass
@ -896,6 +904,7 @@ def create_asset(request, thematic_code, location_type, location_id):
'success': True, 'success': True,
'message': _("Asset créé avec succès"), 'message': _("Asset créé avec succès"),
'asset_id': new_asset.id, 'asset_id': new_asset.id,
'asset_type': model_class._meta.model_name,
'asset_code': new_asset.code 'asset_code': new_asset.code
}) })
@ -905,7 +914,10 @@ def _generate_asset_code(model_class, location):
location_code = getattr(location, 'code', 'AST') location_code = getattr(location, 'code', 'AST')
# Get count of existing assets at this location # Get count of existing assets at this location
model_name = model_class._meta.model_name.upper()[:3] if model_class._meta.model_name.lower() == 'trafficlightprogramming':
model_name = 'PRG'
else:
model_name = model_class._meta.model_name.upper()[:3]
base_code = f"{location_code}-{model_name}" base_code = f"{location_code}-{model_name}"
# Find next available number # Find next available number
@ -1369,6 +1381,14 @@ CATEGORY_ASSET_MAPPING = {
'TL_DETEC_OTHER': ('TrafficLightDetector', 'TrafficLightDetectorModel'), 'TL_DETEC_OTHER': ('TrafficLightDetector', 'TrafficLightDetectorModel'),
'TL_BORNIER': ('TrafficLightAccessory', 'TrafficLightAccessoryModel'), 'TL_BORNIER': ('TrafficLightAccessory', 'TrafficLightAccessoryModel'),
'TL_DIVERS': ('TrafficLightAccessory', 'TrafficLightAccessoryModel'), 'TL_DIVERS': ('TrafficLightAccessory', 'TrafficLightAccessoryModel'),
'trafficlights_programming': ('TrafficLightProgramming', None),
'trafficlights_programmation': ('TrafficLightProgramming', None),
'trafficlights_tl_programming': ('TrafficLightProgramming', None),
'trafficlights_tl_programmation': ('TrafficLightProgramming', None),
'tl_programming': ('TrafficLightProgramming', None),
'tl_programmation': ('TrafficLightProgramming', None),
'programming': ('TrafficLightProgramming', None),
'programmation': ('TrafficLightProgramming', None),
# Public Lighting categories # Public Lighting categories
'publiclighting_pole': ('PublicLightingPole', 'PublicLightingPoleModel'), 'publiclighting_pole': ('PublicLightingPole', 'PublicLightingPoleModel'),
'LI_poles': ('PublicLightingPole', 'PublicLightingPoleModel'), 'LI_poles': ('PublicLightingPole', 'PublicLightingPoleModel'),
@ -1441,7 +1461,7 @@ def _import_asset_classes(thematic_code, class_names):
import importlib import importlib
module = importlib.import_module(module_name) module = importlib.import_module(module_name)
asset_class = getattr(module, asset_class_name, None) asset_class = getattr(module, asset_class_name, None)
model_class = getattr(module, model_class_name, None) model_class = getattr(module, model_class_name, None) if model_class_name else None
return asset_class, model_class return asset_class, model_class
except (ImportError, AttributeError): except (ImportError, AttributeError):
return None, None return None, None
@ -1652,7 +1672,8 @@ def create_asset_api(request):
if hasattr(new_asset, 'category'): if hasattr(new_asset, 'category'):
new_asset.category = category new_asset.category = category
new_asset.model = asset_model_instance if hasattr(new_asset, 'model'):
new_asset.model = asset_model_instance
new_asset.status = 'active' new_asset.status = 'active'
new_asset.validation_status = 'to_be_validated' new_asset.validation_status = 'to_be_validated'
new_asset.installation_date = timezone.now() new_asset.installation_date = timezone.now()
@ -1709,6 +1730,13 @@ def create_asset_api(request):
except TrafficLightPole.DoesNotExist: except TrafficLightPole.DoesNotExist:
pass pass
# Set additional data
extra_data = data.get('data', {})
if isinstance(extra_data, dict):
for field_name, value in extra_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 position from location / hierarchy
location = getattr(new_asset, 'location', None) or getattr(new_asset, 'intersection', None) location = getattr(new_asset, 'location', None) or getattr(new_asset, 'intersection', None)
_assign_default_position(new_asset, location, thematic_code) _assign_default_position(new_asset, location, thematic_code)
@ -1749,6 +1777,7 @@ def create_asset_api(request):
'success': True, 'success': True,
'message': success_message, 'message': success_message,
'asset_id': new_asset.id, 'asset_id': new_asset.id,
'asset_type': asset_class._meta.model_name,
'asset_code': new_asset.code, 'asset_code': new_asset.code,
'intervention_linked': intervention_linked, 'intervention_linked': intervention_linked,
'operations_created': operations_created, 'operations_created': operations_created,
@ -1797,11 +1826,13 @@ def get_available_locations_for_asset(request, thematic_code, asset_id):
elif thematic_code == 'trafficlights': elif thematic_code == 'trafficlights':
from assets.models.trafficlights import ( from assets.models.trafficlights import (
TrafficLightPole, TrafficLightCable, TrafficLightLantern, TrafficLightPole, TrafficLightCable, TrafficLightLantern,
TrafficLightController, TrafficLightDetector, TrafficLightElectricalCabinet TrafficLightController, TrafficLightDetector, TrafficLightElectricalCabinet,
TrafficLightProgramming, TrafficLightRadar, TrafficLightRadarPole, TrafficLightAccessory
) )
# Try each possible class # Try each possible class
for cls in [TrafficLightPole, TrafficLightCable, TrafficLightLantern, for cls in [TrafficLightPole, TrafficLightCable, TrafficLightLantern,
TrafficLightController, TrafficLightDetector, TrafficLightElectricalCabinet]: TrafficLightController, TrafficLightDetector, TrafficLightElectricalCabinet,
TrafficLightProgramming, TrafficLightRadar, TrafficLightRadarPole, TrafficLightAccessory]:
try: try:
asset = cls.objects.get(pk=asset_id) asset = cls.objects.get(pk=asset_id)
break break

View file

@ -34,7 +34,7 @@ from common.models import Thematic, UserConfig, UserContractAccess, UserThematic
from common.utils import check_thematic_access from common.utils import check_thematic_access
from interventions.models import InterventionLocation from interventions.models import InterventionLocation
from interventions.views.drafts import InterventionDraftManager from interventions.views.drafts import InterventionDraftManager
from assets.models import TrafficLightIntersection, TrafficLightIntersectionDocument, TrafficLightController, TrafficLightPole, TrafficLightCable, TrafficLightLantern, TrafficLightDetector, TrafficLightElectricalCabinet, TrafficLightRadar, TrafficLightRadarPole from assets.models import TrafficLightIntersection, TrafficLightIntersectionDocument, TrafficLightController, TrafficLightPole, TrafficLightCable, TrafficLightLantern, TrafficLightDetector, TrafficLightElectricalCabinet, TrafficLightRadar, TrafficLightRadarPole, TrafficLightProgramming
from assets.models import AssetCategory, TrafficLightCableModel, TrafficLightControllerModel, TrafficLightDetectorModel, TrafficLightLanternModel, TrafficLightPoleModel, TrafficLightElectricalCabinetModel, TrafficLightContract, TrafficLightRadarModel from assets.models import AssetCategory, TrafficLightCableModel, TrafficLightControllerModel, TrafficLightDetectorModel, TrafficLightLanternModel, TrafficLightPoleModel, TrafficLightElectricalCabinetModel, TrafficLightContract, TrafficLightRadarModel
from assets.forms import IntersectionDocumentForm from assets.forms import IntersectionDocumentForm
from assets.permissions import get_allowed_actions_for_thematic, can_edit_location_for_thematic from assets.permissions import get_allowed_actions_for_thematic, can_edit_location_for_thematic
@ -758,6 +758,97 @@ def trafficlights_intersections_delete_document(request, document_id):
return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection_id) return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection_id)
@require_POST
@check_thematic_access('trafficlights')
def trafficlights_add_programming(request, intersection_id):
intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id)
# Check create permission
thematic = Thematic.objects.get(code='trafficlights')
allowed_actions = get_allowed_actions_for_thematic(request.user, thematic)
if 'create' not in allowed_actions:
return JsonResponse({'success': False, 'error': _("Permission refusée")}, status=403)
from assets.views.asset_editing import _generate_asset_code
from django.db import transaction
from django.utils import timezone
code = request.POST.get('code', '').strip()
name_fr = request.POST.get('name_fr', '').strip()
name_nl = request.POST.get('name_nl', '').strip()
index = request.POST.get('index', '').strip()
plan = request.POST.get('plan', '').strip()
commissioning_date_str = request.POST.get('commissioning_date', '').strip()
uncommissioning_date_str = request.POST.get('uncommissioning_date', '').strip()
status = request.POST.get('status', 'active').strip() or 'active'
info = request.POST.get('info', '').strip()
with transaction.atomic():
category = AssetCategory.objects.filter(thematic=thematic, code__in=['TL_PROGRAMMING', 'trafficlights_programming']).first()
prog = TrafficLightProgramming(
intersection=intersection,
category=category,
status=status,
validation_status='to_be_validated',
name_fr=name_fr if name_fr else None,
name_nl=name_nl if name_nl else None,
index=index if index else None,
plan=plan if plan else None,
info=info if info else None,
)
if not code:
prog.code = _generate_asset_code(TrafficLightProgramming, intersection)
else:
if TrafficLightProgramming.objects.filter(code=code).exists():
return JsonResponse({'success': False, 'error': _("Un équipement avec ce code existe déjà")}, status=400)
prog.code = code
if commissioning_date_str:
from django.utils.dateparse import parse_datetime, parse_date
parsed = parse_datetime(commissioning_date_str) or parse_date(commissioning_date_str)
if parsed:
if not getattr(parsed, 'tzinfo', None) and hasattr(parsed, 'hour'):
parsed = make_aware(parsed, get_current_timezone())
prog.commissioning_date = parsed
prog.installation_date = parsed
else:
prog.installation_date = timezone.now()
if uncommissioning_date_str:
from django.utils.dateparse import parse_datetime, parse_date
parsed = parse_datetime(uncommissioning_date_str) or parse_date(uncommissioning_date_str)
if parsed:
if not getattr(parsed, 'tzinfo', None) and hasattr(parsed, 'hour'):
parsed = make_aware(parsed, get_current_timezone())
prog.uncommissioning_date = parsed
prog.save()
# Handle optional programming file upload
uploaded_file = request.FILES.get('file')
if uploaded_file:
doc = TrafficLightIntersectionDocument(
intersection=intersection,
programming=prog,
document_type='programming_file',
uploaded_by=request.user,
)
doc.file.save(uploaded_file.name, uploaded_file, save=True)
if request.headers.get('x-requested-with') != 'XMLHttpRequest' and request.content_type and 'multipart/form-data' in request.content_type:
return redirect(f"{reverse('assets:trafficlights_intersections_detail', kwargs={'intersection_id': intersection.id})}#assets-programmation")
return JsonResponse({
'success': True,
'message': _("Programmation ajoutée avec succès"),
'asset_id': prog.id,
'asset_code': prog.code,
'redirect_url': prog.get_absolute_url()
})
@check_thematic_access('trafficlights') @check_thematic_access('trafficlights')
def fetch_document_history(request): def fetch_document_history(request):
intersection_id = request.GET.get('intersection_id') intersection_id = request.GET.get('intersection_id')