feat: add support for TrafficLightProgramming management and textarea fields in generic asset templates
This commit is contained in:
parent
80cbb80d06
commit
94cb7148a0
10 changed files with 638 additions and 18 deletions
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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']
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -462,6 +462,8 @@
|
|||
<input type="number" step="any" class="form-control form-control-sm" value="{{ field.input_value }}">
|
||||
{% elif field.field_type == 'date' %}
|
||||
<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 %}
|
||||
<input type="text" class="form-control form-control-sm" value="{{ field.input_value }}">
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -850,7 +850,12 @@
|
|||
<div class="card-header bg-light d-flex align-items-center gap-2 py-2">
|
||||
<i class="bi bi-kanban text-secondary"></i>
|
||||
<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>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for prog in prog_list %}
|
||||
|
|
@ -876,7 +881,12 @@
|
|||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
|
@ -1025,6 +1035,76 @@
|
|||
</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 %}
|
||||
|
||||
<!-- Georeferenced Plans Selection Modal -->
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock script %}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<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-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/assets/', trafficlights.trafficlights_assets, name='trafficlights_assets'),
|
||||
path('trafficlights/assets/geojson/', trafficlights.trafficlights_assets_geojson, name='trafficlights_assets_geojson'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue