loko/streetup/controls/models.py
2026-07-22 14:48:40 +02:00

364 lines
No EOL
15 KiB
Python

from django.contrib.gis.db import models
from django.utils.translation import get_language, gettext_lazy as _
from django.db.models import Max
from django.conf import settings
from django.urls import reverse
from PIL import Image
from decimal import Decimal, ROUND_HALF_UP
from common.models import Thematic, Document
from common.storages import PrivateMediaStorage
private_storage = PrivateMediaStorage()
from projects.models import Project, ProjectPhase
from interventions.models import Intervention
from assets.models import AssetCategory
from contracts.models import Contract, ContractPost
CONTROL_STATUS_CHOICES = {
'in_progress': _('En cours'),
'information': _('Information'),
'to_be_processed': _('A traiter'),
'processed': _('Traité'),
'validated': _('Validé'),
'canceled': _('Annulé'),
}
CONTROL_TYPE_CHOICES = {
'control': _('Contrôle'),
'site_report': _('Rapport de chantier'),
'statement': _('Constat'),
'meeting_report': _('Rapport de réunion'),
}
VISIBILITY_CHOICES = {
'private': _('Privé'),
'intern': _('Interne'),
'all': _('Interne et externe'),
}
class Control(models.Model):
control_code = models.CharField(max_length=100, unique=True, verbose_name=_("Code du contrôle"))
description = models.TextField(null=True, blank=True, verbose_name=_("Description"))
status = models.CharField(choices=CONTROL_STATUS_CHOICES, default='in_progress', verbose_name=_("Statut"))
control_type = models.CharField(choices=CONTROL_TYPE_CHOICES, default='control', verbose_name=_("Type de contrôle"))
thematics = models.ManyToManyField(Thematic, related_name='controls', blank=True, verbose_name=_("Thématiques"))
categories = models.ManyToManyField(AssetCategory, related_name='controls', blank=True, verbose_name=_("Catégories"))
project = models.ForeignKey(Project, on_delete=models.CASCADE, null=True, blank=True, related_name='controls', verbose_name=_("Projet"))
phase = models.ForeignKey(ProjectPhase, null=True, on_delete=models.CASCADE, blank=True, related_name='controls', verbose_name=_("Phase"))
interventions = models.ManyToManyField(Intervention, related_name='related_controls', blank=True, verbose_name=_("Interventions"))
location = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Localisation"))
geom = models.PointField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie"))
geojson = models.JSONField(null=True, blank=True, verbose_name=_("GeoJSON"))
lon = models.FloatField(null=True, blank=True, verbose_name=_("Longitude"))
lat = models.FloatField(null=True, blank=True, verbose_name=_("Latitude"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
control_author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="controls", null=True, blank=True)
visibility = models.CharField(choices=VISIBILITY_CHOICES, default='intern', verbose_name=_("Visibilité"))
shared_edition = models.BooleanField(default=False, verbose_name=_("Edition partagée"))
def __str__(self):
return self.control_code
def get_absolute_url(self):
"""
Returns the URL to access a specific project instance.
"""
return reverse('controls:controls_detail', args=[str(self.id)])
def save(self, *args, **kwargs):
"""
Sauvegarde le Control et génère automatiquement la géométrie à partir des coordonnées lon/lat.
Le code de contrôle est généré automatiquement (CTRL-XXXX) si non défini.
"""
# Auto-générer le code de contrôle si non défini
if not self.control_code:
existing_codes = Control.objects.filter(
control_code__regex=r'^CTRL-\d+$'
).values_list('control_code', flat=True)
max_num = 0
for code in existing_codes:
try:
num = int(code.split('-')[1])
if num > max_num:
max_num = num
except (IndexError, ValueError):
pass
self.control_code = f"CTRL-{max_num + 1:04d}"
# Générer la géométrie si lon et lat sont fournis
if self.lon is not None and self.lat is not None:
from django.contrib.gis.geos import Point
self.geom = Point(self.lon, self.lat, srid=4326).transform(3812, clone=True)
super().save(*args, **kwargs)
class Meta:
ordering = ["-updated_at"]
class Category(models.Model):
thematic = models.ForeignKey(Thematic, on_delete=models.SET_NULL, null=True, blank=True)
cat_code = models.CharField(max_length=100)
name_fr = models.CharField(max_length=255, verbose_name="Name FR")
name_nl = models.CharField(max_length=255, verbose_name="Name NL")
def get_name(self):
lang = get_language()
return getattr(self, f"name_{lang}", self.name_fr) # Fallback to name_fr
def __str__(self):
return f"{self.get_name()}"
class Meta:
ordering = ["cat_code"]
CONTROL_CHECK_STATUS_CHOICES = {
'ok': 'OK',
'nok': 'NOK',
'na': 'N/A',
'nv': 'Not verified',
}
class ControlCheck(models.Model):
control = models.ForeignKey(Control, on_delete=models.CASCADE, related_name="checks")
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="checks")
status = models.CharField(choices=CONTROL_CHECK_STATUS_CHOICES, default='nv')
DOC_TYPE_CHOICES = {
'as_built': _('Plan as-built'),
'techical_sheets': _('Fiches techniques'),
}
class DocumentCheck(models.Model):
control = models.ForeignKey(Control, on_delete=models.CASCADE, related_name="document_checks")
doc_type = models.CharField(choices=DOC_TYPE_CHOICES, default='as_built')
status = models.CharField(choices=CONTROL_CHECK_STATUS_CHOICES, default='nv')
class CheckList(models.Model):
thematic = models.ForeignKey(Thematic, on_delete=models.SET_NULL, null=True, blank=True)
checklist_code = models.CharField(max_length=100)
name_fr = models.CharField(max_length=255, verbose_name="Name FR")
name_nl = models.CharField(max_length=255, verbose_name="Name NL")
def get_name(self):
lang = get_language()
return getattr(self, f"name_{lang}", self.name_fr) # Fallback to name_fr
def __str__(self):
return f"{self.get_name()}"
class Meta:
ordering = ["checklist_code"]
class CheckListControlCheck(models.Model):
checklist = models.ForeignKey(CheckList, on_delete=models.CASCADE, related_name="checks")
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="checklists_checks")
class CheckListDocumentCheck(models.Model):
checklist = models.ForeignKey(CheckList, on_delete=models.CASCADE, related_name="document_checks")
doc_type = models.CharField(choices=DOC_TYPE_CHOICES, default='as_built')
REPORT_STATUS_CHOICES = {
'active': _('Actif'),
'validated': _('Validé'),
'archived': _('Archivé'),
'canceled': _('Annulé'),
}
class ControlReport(models.Model):
control = models.ForeignKey(Control, on_delete=models.CASCADE, related_name="reports")
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True)
status = models.CharField(choices=REPORT_STATUS_CHOICES, default='active')
document = models.FileField(upload_to="control_reports/", storage=private_storage, verbose_name="Document", max_length=255)
class Meta:
ordering = ["-created_at"]
class ControlReferencePlan(models.Model):
control = models.ForeignKey(Control, on_delete=models.CASCADE, related_name="reference_plans")
name = models.CharField(max_length=255)
plan = models.FileField(upload_to="control_plans/", storage=private_storage, verbose_name="Plan", max_length=255)
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True)
uploaded_at = models.DateTimeField(auto_now=True)
max_x = models.FloatField(null=True, blank=True)
max_y = models.FloatField(null=True, blank=True)
def save(self, *args, **kwargs):
# Ouvrir l'image pour récupérer ses dimensions
if self.plan: # Vérifier si une image est bien attachée
with Image.open(self.plan) as img:
width, height = img.size
# Calculer max_x et max_y en fonction des dimensions
if width > height:
# Largeur > hauteur : max_x = 1, max_y est ajusté
self.max_x = 1.0
self.max_y = height / width
else:
# Hauteur > largeur : max_y = 1, max_x est ajusté
self.max_y = 1.0
self.max_x = width / height
# Appeler la méthode save() de la classe parente
super().save(*args, **kwargs)
class Meta:
ordering = ["control", "name"]
REMARK_STATUS_CHOICES = {
'information': _('Info'),
'to_be_processed': _('NOK'),
'processed': _('Traité'),
'validated': _('OK'),
'canceled': _('Annulé'),
}
class Remark(models.Model):
control = models.ForeignKey(Control, on_delete=models.CASCADE, related_name="remarks")
description = models.TextField()
internal = models.BooleanField(default=False)
status = models.CharField(choices=REMARK_STATUS_CHOICES, default='to_be_processed')
archived = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="controls_remarks", null=True, blank=True)
remark_number = models.PositiveIntegerField(null=True, blank=True, editable=False)
reference_plan = models.ForeignKey(ControlReferencePlan, null=True, blank=True, on_delete=models.SET_NULL, related_name="remarks")
lon = models.FloatField(null=True,blank=True)
lat = models.FloatField(null=True,blank=True)
x = models.FloatField(null=True,blank=True) # Coordonnée x relative au plan
y = models.FloatField(null=True,blank=True) # Coordonnée y relative au plan
def save(self, *args, **kwargs):
if self.remark_number is None:
last_number = Remark.objects.filter(control=self.control).aggregate(Max("remark_number"))["remark_number__max"]
self.remark_number = 1 if last_number is None else last_number + 1
super().save(*args, **kwargs)
def __str__(self):
return _("Remarque %(num)s pour %(control)s") % {
"num": self.remark_number,
"control": self.control,
}
class Meta:
ordering = ["control", "remark_number"]
class RemarkHistory(models.Model):
remark = models.ForeignKey(Remark, on_delete=models.CASCADE, related_name="history")
description = models.TextField(blank=True, null=True)
status = models.CharField(choices=REMARK_STATUS_CHOICES, default='to_be_processed')
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True)
class Meta:
ordering = ["created_at"]
def __str__(self):
return f"History entry for Remark {self.remark.remark_number} ({self.created_at.strftime('%d/%m/%Y')})"
class RemarkImage(Document):
remark = models.ForeignKey(Remark, on_delete=models.CASCADE, related_name="images")
history_entry = models.ForeignKey(RemarkHistory, on_delete=models.CASCADE, related_name="images", null=True, blank=True)
file = models.FileField(upload_to="controls/remarks/", storage=private_storage, verbose_name="Fichier", max_length=255)
thumbnail = models.ImageField(upload_to="controls/remarks/", storage=private_storage, null=True, blank=True, max_length=255)
class Meta:
ordering = ["id"]
class RemarkTag(models.Model):
remark = models.ForeignKey(Remark, on_delete=models.CASCADE, related_name="remark_tags")
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="remark_tags")
class ControlReportTemplate(models.Model):
control = models.ForeignKey(Control, on_delete=models.CASCADE, related_name="templates")
name = models.CharField(max_length=255, default="Template standard")
orientation = models.CharField(max_length=10, choices=[("portrait", "Portrait"), ("landscape", "Paysage")], default="portrait")
header_notes = models.TextField(null=True, blank=True)
footer_notes = models.TextField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="controls_templates", null=True, blank=True)
class ControlQuantity(models.Model):
"""Ligne de quantité ajoutée dans une remarque, potentiellement liée à un marché/poste."""
remark = models.ForeignKey(Remark, on_delete=models.CASCADE, related_name="quantities")
# Références marché (facultatives)
contract = models.ForeignKey(Contract, on_delete=models.SET_NULL, null=True, blank=True, related_name="control_quantities")
contract_post = models.ForeignKey(ContractPost, on_delete=models.SET_NULL, null=True, blank=True, related_name="control_quantities")
# Description libre si pas de marché sélectionné (ou pour override)
description = models.CharField(max_length=255, blank=True, null=True)
# Décomposition de la quantité
q1 = models.DecimalField(max_digits=12, decimal_places=2)
q2 = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) # ex: 3 m
q3 = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) # ex: 1 m²
# Résultat calculé
computed_qty = models.DecimalField(max_digits=14, decimal_places=2, editable=False, default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at", "id"]
def __str__(self):
label = self.get_label()
return f"{label} — {self.computed_qty}".strip()
def get_label(self) -> str:
"""Libellé prioritaire: poste de marché > description libre."""
if self.contract_post:
# tu peux ajuster selon tes champs (code + description)
return f"{self.contract_post.code} — {self.contract_post.description_fr[:60]}"
return self.description or ""
def _q(self, x):
return Decimal(x) if x is not None else None
def compute(self):
qty = Decimal("0")
q1 = self.q1
q2 = self.q2
q3 = self.q3
if q1 is not None and q2 is not None and q3 is not None:
qty = (q1 * q2 * q3)
elif q1 is not None and q2 is not None:
qty = (q1 * q2)
else:
qty = q1
qty = qty.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return qty
def save(self, *args, **kwargs):
self.computed_qty = self.compute()
super().save(*args, **kwargs)