diff --git a/loko/assets/migrations/0022_alter_trafficlightprogramming_options_and_more.py b/loko/assets/migrations/0022_alter_trafficlightprogramming_options_and_more.py
new file mode 100644
index 0000000..e5f12aa
--- /dev/null
+++ b/loko/assets/migrations/0022_alter_trafficlightprogramming_options_and_more.py
@@ -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'),
+ ),
+ ]
diff --git a/loko/assets/migrations/0023_add_trafficlight_programming_category.py b/loko/assets/migrations/0023_add_trafficlight_programming_category.py
new file mode 100644
index 0000000..ebfd6ea
--- /dev/null
+++ b/loko/assets/migrations/0023_add_trafficlight_programming_category.py
@@ -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),
+ ]
diff --git a/loko/assets/models/trafficlights.py b/loko/assets/models/trafficlights.py
index 61819c2..97d3ef8 100644
--- a/loko/assets/models/trafficlights.py
+++ b/loko/assets/models/trafficlights.py
@@ -97,6 +97,9 @@ class TrafficLightIntersection(AbstractLocation):
# Retrieve associated radars
radars = self.radars.filter(status='active').order_by('code')
+ # Retrieve associated programmations
+ programmations = self.programmations.filter(status='active').order_by('code')
+
return {
"poles": all_poles,
"cables": cables,
@@ -105,6 +108,7 @@ class TrafficLightIntersection(AbstractLocation):
"detectors": detectors,
"electrical_cabinets": electrical_cabinets,
"radars": radars,
+ "programmations": programmations,
}
def get_all_assets(self):
@@ -132,6 +136,9 @@ class TrafficLightIntersection(AbstractLocation):
# Retrieve associated radars
radars = self.radars.all().order_by('code')
+ # Retrieve associated programmations
+ programmations = self.programmations.all().order_by('code')
+
return {
"poles": all_poles,
"cables": cables,
@@ -140,6 +147,7 @@ class TrafficLightIntersection(AbstractLocation):
"detectors": detectors,
"electrical_cabinets": electrical_cabinets,
"radars": radars,
+ "programmations": programmations,
}
def get_direct_child_assets(self):
@@ -167,6 +175,9 @@ class TrafficLightIntersection(AbstractLocation):
# Retrieve associated radars
radars = self.radars.all().order_by('code')
+ # Retrieve associated programmations
+ programmations = self.programmations.all().order_by('code')
+
return {
"poles": all_poles,
"cables": cables,
@@ -175,6 +186,7 @@ class TrafficLightIntersection(AbstractLocation):
"detectors": detectors,
"electrical_cabinets": electrical_cabinets,
"radars": radars,
+ "programmations": programmations,
}
def get_controller_contract(self):
@@ -709,19 +721,21 @@ class TrafficLightAccessory(TrafficLightAbstractGeoAsset):
-# Treffic Light Programmation
+# Traffic Light Programmation
class TrafficLightProgramming(TrafficLightAbstractAsset):
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"))
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"))
- 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"))
def __str__(self):
- return f"{self.index} - {self.intersection.code}"
+ return f"{self.index or self.code} - {self.intersection.code}"
class Meta:
+ verbose_name = _("Programmation")
+ verbose_name_plural = _("Programmations")
ordering = ['-commissioning_date']
diff --git a/loko/assets/templates/assets/detail_generic.html b/loko/assets/templates/assets/detail_generic.html
index bf030bb..4b43268 100644
--- a/loko/assets/templates/assets/detail_generic.html
+++ b/loko/assets/templates/assets/detail_generic.html
@@ -462,6 +462,8 @@
{% elif field.field_type == 'date' %}
+ {% elif field.field_type == 'textarea' %}
+
{% else %}
{% endif %}
diff --git a/loko/assets/templates/assets/trafficlights_intersection_detail.html b/loko/assets/templates/assets/trafficlights_intersection_detail.html
index cf65ce4..ceb3a81 100644
--- a/loko/assets/templates/assets/trafficlights_intersection_detail.html
+++ b/loko/assets/templates/assets/trafficlights_intersection_detail.html
@@ -850,7 +850,12 @@
{% for prog in prog_list %}
@@ -876,7 +881,12 @@
{% else %}
- {% translate "Aucune programmation" %}
+
{% translate "Aucune programmation" %}
+ {% if 'create' in allowed_location_actions %}
+
+ {% endif %}
{% endif %}
{% endwith %}
@@ -1025,6 +1035,76 @@
+
+
+
{% endif %}
@@ -1385,5 +1465,55 @@
: '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;
+ });
+ });
+ })();
{% endblock script %}
diff --git a/loko/assets/templatetags/asset_tags.py b/loko/assets/templatetags/asset_tags.py
index d597706..7c15ac2 100644
--- a/loko/assets/templatetags/asset_tags.py
+++ b/loko/assets/templatetags/asset_tags.py
@@ -100,7 +100,7 @@ def get_asset_specific_fields(asset):
from django.contrib.gis.db.models import GeometryField
from django.db.models import (ForeignKey, OneToOneField, ManyToManyField, AutoField,
BooleanField, FloatField, DecimalField, IntegerField,
- DateField, DateTimeField)
+ DateField, DateTimeField, TextField)
from django.utils.translation import gettext_lazy as _
from assets.models import AbstractAsset, AbstractGeoAsset
@@ -136,6 +136,10 @@ def get_asset_specific_fields(asset):
field_type = 'date'
display_value = value.strftime('%d/%m/%Y') 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:
field_type = 'select'
choices_dict = dict(field.choices)
diff --git a/loko/assets/tests.py b/loko/assets/tests.py
index 19f58d2..a152f54 100644
--- a/loko/assets/tests.py
+++ b/loko/assets/tests.py
@@ -2,6 +2,7 @@ from django.test import TestCase, tag
from django.contrib.auth.models import User
from django.contrib.gis.geos import MultiPolygon, Polygon
from django.urls import reverse
+from django.contrib.contenttypes.models import ContentType
import json
import os
from unittest import skipUnless
@@ -3333,4 +3334,276 @@ class TrafficLightIntersectionControllerModelTest(TestCase):
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)
+
+
+
diff --git a/loko/assets/urls.py b/loko/assets/urls.py
index c783aa8..f57d12a 100644
--- a/loko/assets/urls.py
+++ b/loko/assets/urls.py
@@ -86,6 +86,7 @@ urlpatterns = [
path('trafficlights/intersections//document-config/', trafficlights.get_document_config, name='trafficlights_intersections_document_config'),
path('trafficlights/intersections//', trafficlights.trafficlights_intersections_get_by_code, name='trafficlights_interstrafficlights_intersections_get_by_codeections_detail'),
path('trafficlights/intersections//add-document/', trafficlights.trafficlights_intersections_add_document, name='trafficlights_intersections_add_document'),
+ path('trafficlights/intersections//add-programming/', trafficlights.trafficlights_add_programming, name='trafficlights_add_programming'),
path('trafficlights/intersections//delete-document/', trafficlights.trafficlights_intersections_delete_document, name='trafficlights_intersections_delete_document'),
path('trafficlights/assets/', trafficlights.trafficlights_assets, name='trafficlights_assets'),
path('trafficlights/assets/geojson/', trafficlights.trafficlights_assets_geojson, name='trafficlights_assets_geojson'),
diff --git a/loko/assets/views/asset_editing.py b/loko/assets/views/asset_editing.py
index 57099dc..000e3ef 100644
--- a/loko/assets/views/asset_editing.py
+++ b/loko/assets/views/asset_editing.py
@@ -457,6 +457,13 @@ def _update_asset_field(asset, field_name, new_value, model_class):
parsed = datetime.strptime(str(new_value), "%Y-%m-%d")
except ValueError:
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)
else:
setattr(asset, field_name, None)
@@ -858,12 +865,13 @@ def create_asset(request, thematic_code, location_type, location_id):
'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:
+ except Exception as e:
+ # Check if it was a DoesNotExist on model_id
+ if model_id and 'DoesNotExist' in e.__class__.__name__:
+ return JsonResponse({
+ 'success': False,
+ 'error': _("Modèle d'asset non trouvé")
+ }, status=400)
# No 'model' field on this asset type, that's OK
pass
@@ -896,6 +904,7 @@ def create_asset(request, thematic_code, location_type, location_id):
'success': True,
'message': _("Asset créé avec succès"),
'asset_id': new_asset.id,
+ 'asset_type': model_class._meta.model_name,
'asset_code': new_asset.code
})
@@ -905,7 +914,10 @@ def _generate_asset_code(model_class, location):
location_code = getattr(location, 'code', 'AST')
# 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}"
# Find next available number
@@ -1369,6 +1381,14 @@ CATEGORY_ASSET_MAPPING = {
'TL_DETEC_OTHER': ('TrafficLightDetector', 'TrafficLightDetectorModel'),
'TL_BORNIER': ('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
'publiclighting_pole': ('PublicLightingPole', 'PublicLightingPoleModel'),
'LI_poles': ('PublicLightingPole', 'PublicLightingPoleModel'),
@@ -1441,7 +1461,7 @@ def _import_asset_classes(thematic_code, class_names):
import importlib
module = importlib.import_module(module_name)
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
except (ImportError, AttributeError):
return None, None
@@ -1652,7 +1672,8 @@ def create_asset_api(request):
if hasattr(new_asset, '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.validation_status = 'to_be_validated'
new_asset.installation_date = timezone.now()
@@ -1709,6 +1730,13 @@ def create_asset_api(request):
except TrafficLightPole.DoesNotExist:
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
location = getattr(new_asset, 'location', None) or getattr(new_asset, 'intersection', None)
_assign_default_position(new_asset, location, thematic_code)
@@ -1749,6 +1777,7 @@ def create_asset_api(request):
'success': True,
'message': success_message,
'asset_id': new_asset.id,
+ 'asset_type': asset_class._meta.model_name,
'asset_code': new_asset.code,
'intervention_linked': intervention_linked,
'operations_created': operations_created,
@@ -1797,11 +1826,13 @@ def get_available_locations_for_asset(request, thematic_code, asset_id):
elif thematic_code == 'trafficlights':
from assets.models.trafficlights import (
TrafficLightPole, TrafficLightCable, TrafficLightLantern,
- TrafficLightController, TrafficLightDetector, TrafficLightElectricalCabinet
+ TrafficLightController, TrafficLightDetector, TrafficLightElectricalCabinet,
+ TrafficLightProgramming, TrafficLightRadar, TrafficLightRadarPole, TrafficLightAccessory
)
# Try each possible class
for cls in [TrafficLightPole, TrafficLightCable, TrafficLightLantern,
- TrafficLightController, TrafficLightDetector, TrafficLightElectricalCabinet]:
+ TrafficLightController, TrafficLightDetector, TrafficLightElectricalCabinet,
+ TrafficLightProgramming, TrafficLightRadar, TrafficLightRadarPole, TrafficLightAccessory]:
try:
asset = cls.objects.get(pk=asset_id)
break
diff --git a/loko/assets/views/trafficlights.py b/loko/assets/views/trafficlights.py
index 29aea0c..2cb9d7a 100644
--- a/loko/assets/views/trafficlights.py
+++ b/loko/assets/views/trafficlights.py
@@ -34,7 +34,7 @@ from common.models import Thematic, UserConfig, UserContractAccess, UserThematic
from common.utils import check_thematic_access
from interventions.models import InterventionLocation
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.forms import IntersectionDocumentForm
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)
+@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')
def fetch_document_history(request):
intersection_id = request.GET.get('intersection_id')