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)