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")