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

427 lines
17 KiB
Python

from django.contrib.gis.db import models
from django.db.models import Q
from django.urls import reverse
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django.utils.text import slugify
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from datetime import datetime
from common.models import Thematic, Document
from common.storages import PrivateMediaStorage
from contracts.models import Contract, ContractPost
from interventions.models import Intervention
private_storage = PrivateMediaStorage()
class ProjectTag(models.Model):
name_fr = models.CharField(max_length=100, unique=True, verbose_name=_("Nom"))
name_nl = models.CharField(max_length=100, unique=True, null=True, blank=True, verbose_name=_("Nom (NL)"))
slug = models.SlugField(max_length=120, unique=True)
order = models.PositiveSmallIntegerField(default=0)
@property
def name(self):
from django.utils.translation import get_language
lang = get_language()
if lang and lang.startswith("nl") and self.name_nl:
return self.name_nl
return self.name_fr
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.name_fr)
slug = base_slug
counter = 1
while ProjectTag.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
super().save(*args, **kwargs)
def __str__(self):
return self.name_fr
class Meta:
ordering = ["order", "name_fr"]
verbose_name = _("Tag de projet")
verbose_name_plural = _("Tags de projet")
STATUS_CHOICES = {
'in_preparation': _('En préparation'),
'planned': _('Planifié'),
'in_progress': _('En cours'),
'finished': _('Terminé'),
'invoiced': _('Comptabilisé'),
'closed': _('Clôturé'),
'canceled': _('Annulé'),
}
# MAINTAIN_CHOICES = {
# 'corrective': 'Maintenance corrective',
# 'preventive': 'Maintenance préventive',
# 'improved': 'Maintenance améliorative'
# }
class Project(models.Model):
code = models.CharField(max_length=50, unique=True, blank=True)
name = models.CharField(max_length=255, verbose_name=_("Nom"))
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
start_date = models.DateField(blank=True, null=True, verbose_name=_("Date de début"))
end_date = models.DateField(blank=True, null=True, verbose_name=_("Date de fin"))
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_projects')
pmo_number = models.IntegerField(blank=True, null=True, verbose_name=_("Numéro PMO"))
osiris_number = models.IntegerField(blank=True, null=True, verbose_name=_("Numéro OSIRIS"))
status = models.CharField(choices=STATUS_CHOICES, default='in_preparation', verbose_name=_("Statut"))
# maintain_type = models.CharField(choices=MAINTAIN_CHOICES, default='improved')
thematics = models.ManyToManyField(Thematic, blank=True, related_name="projects", verbose_name=_("Thématiques"))
tags = models.ManyToManyField(ProjectTag, blank=True, related_name="projects", verbose_name=_("Tags"))
templates = models.ManyToManyField('ProjectTemplate', blank=True, related_name="projects", verbose_name=_("Templates"))
geom = models.PolygonField(srid=3812, null=True,blank=True)
strict_access = models.BooleanField(
default=False,
verbose_name=_("Accès strict"),
help_text=_(
"Si coché, seuls les utilisateurs et équipes explicitement mentionnés "
"peuvent accéder à ce projet. Les permissions par thématique ne s'appliquent pas."
),
)
def __str__(self):
return f"{self.code} - {self.name}"
def save(self, *args, **kwargs):
if not self.code: # Generate code only if not defined
next_number = ProjectSequence.get_next_number()
self.code = f"P{str(next_number).zfill(5)}" # Prefix "P" followed by the number
super().save(*args, **kwargs)
@classmethod
def get_active_projects(cls):
"""
Returns a queryset of projects that are neither canceled, archived, closed nor invoiced.
"""
return cls.objects.exclude(status__in=['canceled', 'archived', 'closed', 'invoiced'])
def get_absolute_url(self):
"""
Returns the URL to access a specific project instance.
"""
return reverse('projects:projects_detail', args=[str(self.id)])
class Meta:
ordering = ["-code"]
class ProjectSequence(models.Model):
current_number = models.PositiveIntegerField(default=0)
@staticmethod
def get_next_number():
# Atomically retrieve and increment the number
from django.db import transaction
with transaction.atomic():
sequence, is_created = ProjectSequence.objects.get_or_create(id=1) # Fixed ID for a single sequence
sequence.current_number += 1
sequence.save()
return sequence.current_number
class ProjectPhase(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="phases")
name = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True)
start_date = models.DateField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True)
contract = models.ForeignKey(Contract, on_delete=models.SET_NULL, null=True, blank=True, related_name="projects_phases")
def __str__(self):
return f"{self.project.name} - {self.name}"
class ProjectIntervention(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="interventions")
intervention = models.ForeignKey(Intervention, on_delete=models.CASCADE)
phase = models.ForeignKey(ProjectPhase, on_delete=models.CASCADE, related_name="interventions", blank=True, null=True)
row_number = models.IntegerField(blank=True, null=True)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.project and self.intervention:
if self.intervention.pmo_number != self.project.pmo_number:
self.intervention.pmo_number = self.project.pmo_number
self.intervention.save(update_fields=['pmo_number'])
def project_document_upload_path(instance, filename):
# On récupère l'année et le mois actuels
now = timezone.now()
year = now.strftime("%Y")
month = now.strftime("%m")
# On construit le chemin avec l'ID de l'intervention (attention : doit être déjà enregistrée)
project_id = instance.project.id if instance.project else "unknown"
return f"project_documents/{year}/{project_id}/{filename}"
class ProjectDocument(Document):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="documents", verbose_name="Projet associé")
file = models.FileField(upload_to=project_document_upload_path, verbose_name="Fichier", max_length=255, storage=private_storage)
thumbnail = models.ImageField(upload_to=project_document_upload_path, null=True, blank=True, max_length=255, storage=private_storage)
upload_date = models.DateTimeField(auto_now_add=True, verbose_name="Date d'upload", null=True, blank=True)
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="uploaded_project_documents", null=True, blank=True)
class ProjectAsset(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="assets")
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
asset = GenericForeignKey('content_type', 'object_id')
def __str__(self):
return f"{self.project.name} - {self.asset_type} (ID: {self.asset_id})"
# ─── Project Access Control ───────────────────────────────────────────────────
class ProjectTeamAccess(models.Model):
"""Accès d'une équipe (CompanyTeam) à un projet avec contrôle fin lecture/écriture."""
project = models.ForeignKey(
Project, on_delete=models.CASCADE,
related_name="team_accesses",
verbose_name=_("Projet"),
)
team = models.ForeignKey(
'contracts.CompanyTeam', on_delete=models.CASCADE,
related_name="project_accesses",
verbose_name=_("Équipe"),
)
can_view = models.BooleanField(
default=False,
verbose_name=_("Peut consulter"),
help_text=_("L'équipe peut voir ce projet."),
)
can_edit = models.BooleanField(
default=False,
verbose_name=_("Peut éditer"),
help_text=_("L'équipe peut modifier ce projet."),
)
class Meta:
unique_together = ('project', 'team')
verbose_name = _("Accès équipe au projet")
verbose_name_plural = _("Accès équipes aux projets")
def __str__(self):
return f"{self.team.name} → {self.project.code}"
class ProjectUserAccess(models.Model):
"""Accès direct d'un utilisateur à un projet avec contrôle fin lecture/écriture."""
project = models.ForeignKey(
Project, on_delete=models.CASCADE,
related_name="user_accesses",
verbose_name=_("Projet"),
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="project_direct_accesses",
verbose_name=_("Utilisateur"),
)
can_view = models.BooleanField(
default=False,
verbose_name=_("Peut consulter"),
help_text=_("L'utilisateur peut voir ce projet."),
)
can_edit = models.BooleanField(
default=False,
verbose_name=_("Peut éditer"),
help_text=_("L'utilisateur peut modifier ce projet."),
)
class Meta:
unique_together = ('project', 'user')
verbose_name = _("Accès direct utilisateur au projet")
verbose_name_plural = _("Accès directs utilisateurs aux projets")
def __str__(self):
return f"{self.user.username} → {self.project.code}"
# ─── Project Template System ──────────────────────────────────────────────────
ATTRIBUTE_TYPE_CHOICES = {
'text': _('Texte'),
'number': _('Nombre'),
'boolean': _('Booléen (Oui/Non)'),
'choice': _('Sélection'),
'choice_multiple': _('Sélection multiple'),
'date': _('Date'),
}
class ProjectAttributeChoiceSet(models.Model):
name_fr = models.CharField(max_length=100, verbose_name=_("Nom (FR)"))
name_nl = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Nom (NL)"))
@property
def name(self):
from django.utils.translation import get_language
lang = get_language()
if lang and lang.startswith("nl") and self.name_nl:
return self.name_nl
return self.name_fr
def __str__(self):
return self.name_fr
class Meta:
ordering = ["name_fr"]
verbose_name = _("Ensemble de choix")
verbose_name_plural = _("Ensembles de choix")
class ProjectAttributeChoice(models.Model):
choice_set = models.ForeignKey(ProjectAttributeChoiceSet, on_delete=models.CASCADE, related_name="choices")
value = models.CharField(max_length=100, verbose_name=_("Valeur (clé)"))
label_fr = models.CharField(max_length=200, verbose_name=_("Label (FR)"))
label_nl = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Label (NL)"))
order = models.PositiveSmallIntegerField(default=0, verbose_name=_("Ordre"))
@property
def label(self):
from django.utils.translation import get_language
lang = get_language()
if lang and lang.startswith("nl") and self.label_nl:
return self.label_nl
return self.label_fr
def __str__(self):
return f"{self.label_fr} ({self.value})"
class Meta:
ordering = ["order", "label_fr"]
unique_together = [["choice_set", "value"]]
verbose_name = _("Choix")
verbose_name_plural = _("Choix")
class ProjectTemplate(models.Model):
name_fr = models.CharField(max_length=200, verbose_name=_("Nom (FR)"))
name_nl = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Nom (NL)"))
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
is_active = models.BooleanField(default=True, verbose_name=_("Actif"))
@property
def name(self):
from django.utils.translation import get_language
lang = get_language()
if lang and lang.startswith("nl") and self.name_nl:
return self.name_nl
return self.name_fr
def __str__(self):
return self.name_fr
class Meta:
ordering = ["name_fr"]
verbose_name = _("Template de projet")
verbose_name_plural = _("Templates de projet")
class ProjectTemplateAttribute(models.Model):
template = models.ForeignKey(ProjectTemplate, on_delete=models.CASCADE, related_name="attributes")
name_fr = models.CharField(max_length=200, verbose_name=_("Nom (FR)"))
name_nl = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Nom (NL)"))
help_text_fr = models.CharField(max_length=500, blank=True, null=True, verbose_name=_("Texte d'aide (FR)"))
help_text_nl = models.CharField(max_length=500, blank=True, null=True, verbose_name=_("Texte d'aide (NL)"))
attribute_type = models.CharField(max_length=20, choices=ATTRIBUTE_TYPE_CHOICES, verbose_name=_("Type"))
choice_set = models.ForeignKey(
ProjectAttributeChoiceSet, on_delete=models.SET_NULL,
null=True, blank=True, related_name="template_attributes",
verbose_name=_("Ensemble de choix")
)
is_required = models.BooleanField(default=False, verbose_name=_("Obligatoire"))
order = models.PositiveSmallIntegerField(default=0, verbose_name=_("Ordre"))
@property
def name(self):
from django.utils.translation import get_language
lang = get_language()
if lang and lang.startswith("nl") and self.name_nl:
return self.name_nl
return self.name_fr
@property
def help_text(self):
from django.utils.translation import get_language
lang = get_language()
if lang and lang.startswith("nl") and self.help_text_nl:
return self.help_text_nl
return self.help_text_fr or ""
def __str__(self):
return f"{self.template.name_fr} — {self.name_fr}"
class Meta:
ordering = ["order", "name_fr"]
verbose_name = _("Attribut de template")
verbose_name_plural = _("Attributs de template")
class ProjectAttributeValue(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="attribute_values")
attribute = models.ForeignKey(ProjectTemplateAttribute, on_delete=models.CASCADE, related_name="values")
value_text = models.TextField(blank=True, null=True)
value_number = models.DecimalField(max_digits=20, decimal_places=6, blank=True, null=True)
value_boolean = models.BooleanField(null=True, blank=True)
value_choice = models.CharField(max_length=100, blank=True, null=True)
value_choices = models.JSONField(blank=True, null=True)
value_date = models.DateField(blank=True, null=True)
def display_value(self):
t = self.attribute.attribute_type
if t == 'boolean':
if self.value_boolean is True:
return _("Oui")
elif self.value_boolean is False:
return _("Non")
return "—"
elif t == 'text':
return self.value_text or "—"
elif t == 'number':
if self.value_number is None:
return "—"
n = self.value_number
return str(int(n)) if n % 1 == 0 else f"{n:.2f}"
elif t == 'date':
return self.value_date.strftime("%d/%m/%Y") if self.value_date else "—"
elif t == 'choice':
if self.value_choice and self.attribute.choice_set:
choice = self.attribute.choice_set.choices.filter(value=self.value_choice).first()
return choice.label if choice else self.value_choice
return self.value_choice or "—"
elif t == 'choice_multiple':
if self.value_choices and self.attribute.choice_set:
choices = self.attribute.choice_set.choices.filter(value__in=self.value_choices)
return ", ".join(c.label for c in choices) if choices.exists() else "—"
return ", ".join(self.value_choices) if self.value_choices else "—"
return "—"
class Meta:
unique_together = [["project", "attribute"]]
verbose_name = _("Valeur d'attribut")
verbose_name_plural = _("Valeurs d'attribut")