364 lines
No EOL
15 KiB
Python
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) |