loko/streetup/common/models.py

1217 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from django.contrib.gis.db import models
from django.contrib.auth.models import User
from django.utils.translation import get_language
from django.contrib.postgres.indexes import GinIndex, OpClass
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.db.models.functions import Lower
from django.conf import settings
import os
from PIL import Image, ExifTags
from pdf2image import convert_from_path
class LanguageManager(models.Manager):
def get_queryset(self):
lang = get_language()
if lang == 'nl':
return super().get_queryset().order_by('name_nl')
return super().get_queryset().order_by('name_fr')
class Thematic(models.Model):
code = models.CharField(max_length=100, unique=True, verbose_name="Code")
name_fr = models.CharField(max_length=255, verbose_name="Nom")
name_nl = models.CharField(max_length=255, verbose_name="Naam")
description = models.TextField(blank=True, null=True, verbose_name="Description")
icon = models.CharField(max_length=100, blank=True, null=True, verbose_name="Icône Bootstrap")
color = models.CharField(max_length=7, default="#000000", verbose_name="Couleur associée")
uses_occupations = models.BooleanField(default=False, verbose_name="Utilise les occupations")
include_in_daily_report = models.BooleanField(
default=False,
verbose_name="Inclure dans rapport journalier",
help_text="Si coché, cette thématique apparaît toujours dans le rapport journalier, même si le nombre d'interventions est 0.",
)
allow_planning_without_contract = models.BooleanField(
default=False,
verbose_name="Planification sans contrat autorisée",
help_text="Si coché, les interventions de cette thématique peuvent passer au statut 'À planifier' sans qu'un prestataire ou un contrat soit renseigné. Le prestataire et le contrat restent modifiables dans ce statut.",
)
@property
def name(self):
"""Renvoie le nom de la thématique dans la langue active."""
lang = get_language()
if lang == 'nl':
return self.name_nl
return self.name_fr
objects = LanguageManager()
def __str__(self):
return self.name
class Meta:
verbose_name = "Thématique"
verbose_name_plural = "Thématiques"
class Role(models.Model):
name = models.CharField(max_length=50, unique=True) # ex: 'manager', 'technician', 'admin'
def __str__(self):
return self.name
class AppView(models.Model):
"""
Defines available application views/modules.
This allows for a flexible permission system for different views.
"""
code = models.CharField(max_length=50, unique=True, verbose_name="Code")
name_fr = models.CharField(max_length=100, verbose_name="Nom")
name_nl = models.CharField(max_length=100, verbose_name="Naam")
icon = models.CharField(max_length=50, blank=True, null=True, verbose_name="Icône Bootstrap")
url_name = models.CharField(max_length=100, verbose_name="Nom de l'URL", help_text="Ex: 'home:index' ou 'sign:index'")
is_default = models.BooleanField(default=False, verbose_name="Vue par défaut", help_text="Vue affichée par défaut pour les nouveaux utilisateurs")
order = models.PositiveIntegerField(default=0, verbose_name="Ordre d'affichage")
@property
def name(self):
"""Renvoie le nom dans la langue active."""
lang = get_language()
if lang == 'nl':
return self.name_nl
return self.name_fr
def __str__(self):
return self.name
class Meta:
verbose_name = "Vue applicative"
verbose_name_plural = "Vues applicatives"
ordering = ['order', 'name_fr']
class UserConfig(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="config")
is_intern = models.BooleanField(default=False)
roles = models.ManyToManyField(Role, blank=True)
default_thematic = models.ForeignKey(Thematic, on_delete=models.SET_NULL, null=True, blank=True, related_name="default_user_accesses")
thematics = models.ManyToManyField("common.Thematic", through="UserThematics", related_name="user_accesses")
contracts = models.ManyToManyField("contracts.Contract", through="UserContractAccess", related_name="user_accesses")
warehouses = models.ManyToManyField("stock.Warehouse", through="UserWarehouseAccess", related_name="user_accesses", blank=True)
warehouse_locations = models.ManyToManyField("stock.WarehouseLocation", through="UserWarehouseLocationAccess", related_name="user_configs", blank=True)
limit_assets_to_contracts = models.BooleanField(default=True)
limit_interventions_to_contracts = models.BooleanField(default=True)
limit_shortcuts_to_contracts = models.BooleanField(
default=False,
verbose_name="Limiter la page d'accueil aux contrats",
help_text="Limite les raccourcis et les compteurs de la page d'accueil aux contrats pour lesquels l'utilisateur dispose de permissions explicites.",
)
generic_email = models.EmailField(blank=True, null=True) # Email générique de l'utilisateur
REGIONAL_STANDARD_CHOICES = [
('BE', 'Belgique (PEB, Maison Communale / Hôtel de Ville, Sécurité PMR)'),
('FR', 'France (DPE, GES, Mairie, Normes ERP)'),
('INT', 'International / Générique'),
]
regional_standard = models.CharField(
max_length=10,
choices=REGIONAL_STANDARD_CHOICES,
default='BE',
verbose_name="Norme régionale & vocabulaire",
help_text="Définit la terminologie et les normes réglementaires adaptées à la région.",
)
hide_intervention_map_by_default = models.BooleanField(
default=False,
verbose_name="Masquer la carte des interventions par défaut",
help_text="Affiche un bouton permettant d'afficher la carte au besoin.",
)
INTERVENTION_LIST_LAYOUT_CHOICES = [
('both', 'Tableau et carte'),
('table_only', 'Tableau seul'),
('map_only', 'Carte seule'),
]
intervention_list_layout = models.CharField(
max_length=20,
choices=INTERVENTION_LIST_LAYOUT_CHOICES,
default='both',
verbose_name="Disposition de la liste des interventions",
help_text="Préférence de disposition pour la vue liste des interventions.",
)
intervention_list_filters = models.JSONField(
default=dict,
blank=True,
verbose_name="Filtres de la liste des interventions",
help_text="Préférences de filtrage pour la vue liste des interventions.",
)
mobile_intervention_list_filters = models.JSONField(
default=dict,
blank=True,
verbose_name="Filtres de la liste des interventions mobile",
help_text="Préférences de filtrage pour la vue mobile de la liste des interventions.",
)
intervention_list_sort = models.JSONField(
default=dict,
blank=True,
verbose_name="Tri de la liste des interventions",
help_text="Préférences de tri pour la vue liste des interventions.",
)
intervention_list_columns = models.JSONField(
default=None,
null=True,
blank=True,
verbose_name="Colonnes masquées de la liste des interventions",
help_text="Liste des champs masqués dans le tableau de la vue liste des interventions.",
)
observation_list_filters = models.JSONField(
default=dict,
blank=True,
verbose_name="Filtres de la liste des observations",
help_text="Préférences de filtrage pour la vue liste des observations.",
)
observation_list_sort = models.JSONField(
default=dict,
blank=True,
verbose_name="Tri de la liste des observations",
help_text="Préférences de tri pour la vue liste des observations.",
)
project_list_filters = models.JSONField(
default=dict,
blank=True,
verbose_name="Filtres de la liste des projets",
help_text="Préférences de filtrage pour la vue liste des projets.",
)
project_list_sort = models.JSONField(
default=dict,
blank=True,
verbose_name="Tri de la liste des projets",
help_text="Préférences de tri pour la vue liste des projets.",
)
ASSETS_LIST_LAYOUT_CHOICES = [
('both', 'Tableau et carte'),
('table_only', 'Tableau seul'),
('map_only', 'Carte seule'),
]
assets_list_layout = models.CharField(
max_length=20,
choices=ASSETS_LIST_LAYOUT_CHOICES,
default='both',
verbose_name="Disposition de la liste des assets",
help_text="Préférence de disposition pour la vue liste des assets.",
)
linked_configs = models.ManyToManyField(
"self",
blank=True,
symmetrical=False,
verbose_name="Comptes liés",
)
auto_logout_hours = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name="Déconnexion automatique (heures)",
help_text="Nombre d'heures après la connexion avant déconnexion automatique. Laisser vide pour désactiver.",
)
# Generic view access permissions
accessible_views = models.ManyToManyField(
AppView,
blank=True,
verbose_name="Vues accessibles",
help_text="Les vues/applications auxquelles l'utilisateur a accès.",
related_name="user_configs",
)
panoramax_can_upload = models.BooleanField(
default=False,
verbose_name="Peut uploader des photos 360",
help_text="Permet à l'utilisateur d'uploader de nouvelles photos ou vidéos 360 pour Panoramax."
)
# Société à laquelle appartient cet utilisateur (ex: prestataire)
company = models.ForeignKey(
'contracts.Company',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='user_configs',
verbose_name="Société",
help_text="Société/organisme auquel appartient cet utilisateur (utile pour filtrer les membres visibles).",
)
# Document management permissions for external users
can_add_unique_tag_documents = models.BooleanField(
default=False,
verbose_name="Peut ajouter des documents avec tags uniques",
help_text="Permet aux utilisateurs externes d'ajouter/modifier des documents avec des tags uniques (CME, PIS, As-Built, etc.).",
)
esign_can_edit = models.BooleanField(
default=False,
verbose_name="eSign : Peut éditer",
help_text="Permission pour éditer les panneaux et les poteaux.",
)
esign_can_manage_rcp = models.BooleanField(
default=False,
verbose_name="eSign : Peut gérer les RCP",
help_text="Permission pour créer, modifier et supprimer les RCP.",
)
esign_can_validate_rcp = models.BooleanField(
default=False,
verbose_name="eSign : Peut valider les RCP",
help_text="Permission pour valider les RCP.",
)
esign_organizations = models.ManyToManyField(
"sign.Organization",
blank=True,
related_name="user_configs",
verbose_name="eSign : Gestionnaires",
help_text="Les gestionnaires (communes, Bruxelles Mobilité) pour lesquels l'utilisateur a des permissions."
)
# Catégories d'assets concernées pour l'approbation des interventions
approval_categories = models.ManyToManyField(
'assets.AssetCategory',
blank=True,
verbose_name="Catégories d'approbation",
help_text="Catégories d'assets pour lesquelles l'utilisateur approuve des interventions. Si vide, aucun filtre de catégorie n'est appliqué.",
related_name='approver_user_configs',
)
# Préférences de filtrage du reporting (structurées par page/widget)
# Ex : {"dashboard": {"granularity": "week", "contract_ids": [], "thematic_ids": [], ...}}
reporting_preferences = models.JSONField(
default=dict,
blank=True,
verbose_name="Préférences du reporting",
help_text="Préférences de filtrage pour les différentes pages du reporting.",
)
assets_index_preferences = models.JSONField(
default=dict,
blank=True,
verbose_name="Préférences de la page index unifiée",
help_text="Préférences de la page assets (thématiques actives, catégories, filtres, position carte).",
)
calendar_preferences = models.JSONField(
default=dict,
blank=True,
null=True,
verbose_name="Préférences du calendrier",
help_text="Préférences de la page calendrier (mode d'affichage, zoom, filtres actifs, état d'expansion du backlog).",
)
def __str__(self):
return f"Configuration de {self.user.username}"
def has_role(self, role_str):
return self.roles.filter(name=role_str).exists()
@property
def can_approve_any_intervention(self):
"""Détermine si l'utilisateur dispose de permissions d'approbation sur au moins un contrat ou un rôle supérieur."""
if self.has_role('admin') or self.has_role('top_manager') or self.user.is_superuser:
return True
return self.usercontractaccess_set.filter(can_approve=True).exists()
def can_access_view(self, view_code):
"""Check if the user has access to a specific view by its code."""
return self.accessible_views.filter(code=view_code).exists()
def get_accessible_views(self):
"""Returns all accessible views ordered by their display order."""
return self.accessible_views.all().order_by('order')
def can_view_warehouse(self, warehouse):
"""Check if the user can view a specific warehouse."""
if self.user.is_superuser or self.user.is_staff or self.has_role('admin'):
return True
if not self.userwarehouses.exists():
if self.userlocations.exists():
return self.userlocations.filter(warehouse_location__warehouse=warehouse).exists()
return True
return self.userwarehouses.filter(warehouse=warehouse, can_view=True).exists()
def can_manage_warehouse(self, warehouse):
"""Check if the user can manage a specific warehouse."""
if self.user.is_superuser or self.user.is_staff or self.has_role('admin'):
return True
if not self.userwarehouses.exists():
if self.userlocations.exists():
return False
return True
return self.userwarehouses.filter(warehouse=warehouse, can_manage=True).exists()
def can_manage_preparation_orders_any(self):
"""Vérifie si l'utilisateur peut gérer des bons de préparation sur au moins un entrepôt."""
if self.user.is_superuser or self.has_role('admin'):
return True
if not self.userwarehouses.exists():
if self.userlocations.exists():
return False
return True
return self.userwarehouses.filter(can_manage_preparation_orders=True).exists()
def can_manage_preparation_orders_for(self, warehouse):
"""Vérifie si l'utilisateur peut gérer des bons de préparation pour un entrepôt spécifique."""
if self.user.is_superuser or self.has_role('admin'):
return True
if not self.userwarehouses.exists():
if self.userlocations.exists():
return False
return True
return self.userwarehouses.filter(warehouse=warehouse, can_manage_preparation_orders=True).exists()
def can_manage_purchase_orders_any(self):
"""Vérifie si l'utilisateur peut gérer des bons de commande sur au moins un entrepôt."""
if self.user.is_superuser or self.has_role('admin'):
return True
if not self.userwarehouses.exists():
if self.userlocations.exists():
return False
return True
return self.userwarehouses.filter(can_manage_purchase_orders=True).exists()
def can_manage_purchase_orders_for(self, warehouse):
"""Vérifie si l'utilisateur peut gérer des bons de commande pour un entrepôt spécifique."""
if self.user.is_superuser or self.has_role('admin'):
return True
if not self.userwarehouses.exists():
if self.userlocations.exists():
return False
return True
return self.userwarehouses.filter(warehouse=warehouse, can_manage_purchase_orders=True).exists()
def can_validate_inventory_for(self, warehouse):
"""Vérifie si l'utilisateur peut valider les inventaires d'un entrepôt."""
if self.user.is_superuser or self.has_role('admin'):
return True
if not self.userwarehouses.exists():
if self.userlocations.exists():
return False
return True
return self.userwarehouses.filter(warehouse=warehouse, can_validate_inventory=True).exists()
def can_perform_inventory_on(self, location):
"""Vérifie si l'utilisateur peut effectuer l'inventaire d'un emplacement spécifique."""
if self.user.is_superuser or self.user.is_staff or self.has_role('admin'):
return True
if self.can_manage_warehouse(location.warehouse) or self.can_validate_inventory_for(location.warehouse):
return True
return self.userlocations.filter(warehouse_location=location, can_perform_inventory=True).exists()
def can_validate_inventory_on(self, location):
"""Vérifie si l'utilisateur peut valider les inventaires d'un emplacement spécifique."""
if self.user.is_superuser or self.has_role('admin'):
return True
if self.can_validate_inventory_for(location.warehouse):
return True
return self.userlocations.filter(warehouse_location=location, can_validate_inventory=True).exists()
def get_accessible_warehouses(self, can_manage=False):
"""Returns all warehouses the user can access."""
from stock.models import Warehouse
if self.user.is_superuser or self.user.is_staff or self.has_role('admin'):
return Warehouse.objects.all()
if not self.userwarehouses.exists():
if self.userlocations.exists():
if can_manage:
from django.db.models import Q
return Warehouse.objects.filter(
Q(locations__user_accesses__user_config=self, locations__user_accesses__can_perform_inventory=True) |
Q(locations__user_accesses__user_config=self, locations__user_accesses__can_validate_inventory=True)
).distinct()
return Warehouse.objects.filter(
locations__user_accesses__user_config=self
).distinct()
return Warehouse.objects.all()
if can_manage:
return Warehouse.objects.filter(
userwarehouseaccess__user_config=self,
userwarehouseaccess__can_manage=True
).distinct()
return Warehouse.objects.filter(
userwarehouseaccess__user_config=self,
userwarehouseaccess__can_view=True
).distinct()
def get_default_warehouse(self):
"""Returns the user's default warehouse if set."""
default_access = self.userwarehouses.filter(is_default=True).first()
return default_access.warehouse if default_access else None
def can_administer_warehouse(self, warehouse):
"""Check if the user can administer a specific warehouse."""
if self.user.is_superuser or self.has_role('admin'):
return True
return self.userwarehouses.filter(warehouse=warehouse, can_administer=True).exists()
def can_administer_any_warehouse(self):
"""Check if the user can administer at least one warehouse."""
if self.user.is_superuser or self.has_role('admin'):
return True
return self.userwarehouses.filter(can_administer=True).exists()
def get_viewable_assets_thematics(self):
"""Retourne le QuerySet des thématiques pour lesquelles l'utilisateur est autorisé à voir les assets."""
from common.models import Thematic
if self.user.is_superuser or self.has_role('admin'):
return Thematic.objects.all()
if not self.userthematics.exists():
return None
return Thematic.objects.filter(
userthematics__user_config=self,
userthematics__can_view_assets=True
).distinct()
def get_inspectable_thematics(self):
"""Retourne le QuerySet des thématiques pour lesquelles l'utilisateur est autorisé à réaliser des inspections."""
from common.models import Thematic
if self.user.is_superuser or self.has_role('admin'):
return Thematic.objects.all()
if not self.userthematics.exists():
return Thematic.objects.all()
return Thematic.objects.filter(
userthematics__user_config=self,
userthematics__can_inspect=True
).distinct()
def can_inspect_thematic(self, thematic):
"""Vérifie si l'utilisateur peut réaliser des inspections pour une thématique donnée."""
if self.user.is_superuser or self.has_role('admin'):
return True
if not self.userthematics.exists():
return True
return self.userthematics.filter(thematic=thematic, can_inspect=True).exists()
class UserThematics(models.Model):
"""Table intermédiaire pour gérer les thématiques d'un utilisateur"""
user_config = models.ForeignKey(UserConfig, on_delete=models.CASCADE, related_name="userthematics")
thematic = models.ForeignKey(Thematic, on_delete=models.CASCADE)
can_view_assets = models.BooleanField(default=True) # Peut voir les assets de la thématique
can_edit_assets = models.BooleanField(default=False) # Peut éditer les assets de la thématique
can_view_interventions = models.BooleanField(default=True) # Peut voir les interventions de la thématique
can_edit_interventions = models.BooleanField(default=False) # Peut éditer les interventions de la thématique
can_edit_locations = models.BooleanField(default=False) # Peut créer/modifier/supprimer les localisations de la thématique
can_process_observations = models.BooleanField(default=False, verbose_name="Peut traiter les observations") # Peut changer le statut des observations (traiter, rediriger, archiver, etc.)
can_view_projects = models.BooleanField(default=False, verbose_name="Peut voir les projets") # Peut voir les projets de la thématique
can_edit_projects = models.BooleanField(default=False, verbose_name="Peut éditer les projets") # Peut créer/modifier les projets de la thématique
can_validate_assets = models.BooleanField(default=False, verbose_name="Peut valider les assets")
can_inspect = models.BooleanField(default=False, verbose_name="Peut réaliser des inspections")
class Meta:
unique_together = ("user_config", "thematic") # Empêche les doublons
verbose_name = "Thématique utilisateur"
verbose_name_plural = "Thématiques utilisateur"
def __str__(self):
return f"{self.user_config.user.username} - {self.thematic.name}"
# Statuts d'intervention (copie locale pour éviter import circulaire avec interventions)
INTERVENTION_STATUS_CHOICES = [
('in_preparation', 'En préparation'),
('to_be_approved', 'À approuver'),
('to_be_planned', 'À planifier'),
('to_be_processed', 'À traiter'),
('assigned', 'Pris en charge'),
('in_progress', 'Démarré'),
('on_pause', 'En pause'),
('finished', 'Terminé'),
('processed', 'Traité'),
('to_be_corrected', 'À corriger'),
('corrected', 'Corrigé'),
('validated', 'Validé'),
('invoiced', 'Comptabilisé'),
('closed', 'Finalisé'),
('canceled', 'Annulé'),
]
# Ordre d'affichage des statuts pour les colonnes de la table admin
INTERVENTION_STATUS_ORDER = [
'in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed',
'assigned', 'in_progress', 'on_pause', 'finished', 'processed',
'to_be_corrected', 'corrected', 'validated', 'invoiced', 'closed', 'canceled',
]
class UserThematicStatusPermission(models.Model):
"""Permissions par statut d'intervention pour un accès thématique utilisateur."""
user_thematic = models.ForeignKey(
UserThematics,
on_delete=models.CASCADE,
related_name='status_permissions',
)
status = models.CharField(
max_length=30,
choices=INTERVENTION_STATUS_CHOICES,
verbose_name="Statut",
)
can_view = models.BooleanField(
default=False,
verbose_name="Voir",
help_text="Peut voir les interventions à ce statut.",
)
can_edit = models.BooleanField(
default=False,
verbose_name="Modifier",
help_text="Peut modifier les interventions à ce statut.",
)
can_change_status_to = models.BooleanField(
default=False,
verbose_name="Changer vers",
help_text="Peut déclencher une transition vers ce statut (selon le graphe global des transitions).",
)
class Meta:
unique_together = ('user_thematic', 'status')
verbose_name = "Permission par statut (thématique)"
verbose_name_plural = "Permissions par statut (thématique)"
indexes = [
# Index couvrant can_view : permet un index-only scan dans les sous-requêtes EXISTS
models.Index(fields=['user_thematic', 'status', 'can_view'], name='utsp_status_canview_idx'),
]
def __str__(self):
return f"{self.user_thematic} – {self.status}"
class UserContractAccess(models.Model):
"""Table intermédiaire pour gérer les droits d'accès d'un utilisateur à un contrat"""
user_config = models.ForeignKey(UserConfig, on_delete=models.CASCADE)
contract = models.ForeignKey("contracts.Contract", on_delete=models.CASCADE)
can_view_posts = models.BooleanField(default=False) # Peut voir les postes du contrat
can_view_prices = models.BooleanField(default=False) # Peut voir les prix des postes
can_view_interventions = models.BooleanField(default=True) # Peut voir les interventions du contrat
can_view_assets = models.BooleanField(default=True) # Peut voir les assets du contrat
can_create_interventions = models.BooleanField(
default=False,
verbose_name="Peut créer des interventions",
help_text="Permet au prestataire de créer des interventions pour ce contrat."
) # Permet aux prestataires de créer des interventions
can_check_interventions = models.BooleanField(
default=False,
verbose_name="Peut vérifier les interventions",
help_text="Permet de vérifier les interventions pour ce contrat."
) # Permet de vérifier les interventions
can_approve = models.BooleanField(
default=False,
verbose_name="Peut approuver les interventions",
help_text="Permet de faire passer les interventions du statut 'à approuver' vers un autre statut pour ce contrat."
) # Permet d'approuver les interventions (passage depuis 'to_be_approved')
class Meta:
unique_together = ("user_config", "contract") # Empêche les doublons
def __str__(self):
return f"{self.user_config.user.username} - {self.contract.contract_number}"
class UserContractStatusPermission(models.Model):
"""Permissions par statut d'intervention pour un accès contrat utilisateur."""
user_contract = models.ForeignKey(
UserContractAccess,
on_delete=models.CASCADE,
related_name='status_permissions',
)
status = models.CharField(
max_length=30,
choices=INTERVENTION_STATUS_CHOICES,
verbose_name="Statut",
)
can_view = models.BooleanField(
default=False,
verbose_name="Voir",
help_text="Peut voir les interventions à ce statut.",
)
can_edit = models.BooleanField(
default=False,
verbose_name="Modifier",
help_text="Peut modifier les interventions à ce statut.",
)
can_change_status_to = models.BooleanField(
default=False,
verbose_name="Changer vers",
help_text="Peut déclencher une transition vers ce statut (selon le graphe global des transitions).",
)
class Meta:
unique_together = ('user_contract', 'status')
verbose_name = "Permission par statut (contrat)"
verbose_name_plural = "Permissions par statut (contrat)"
indexes = [
# Index couvrant can_view : permet un index-only scan dans les sous-requêtes EXISTS
models.Index(fields=['user_contract', 'status', 'can_view'], name='ucsp_status_canview_idx'),
]
def __str__(self):
return f"{self.user_contract} – {self.status}"
class UserWarehouseAccess(models.Model):
"""Table intermédiaire pour gérer les droits d'accès d'un utilisateur à un entrepôt"""
user_config = models.ForeignKey(UserConfig, on_delete=models.CASCADE, related_name="userwarehouses")
warehouse = models.ForeignKey("stock.Warehouse", on_delete=models.CASCADE)
can_view = models.BooleanField(default=True, verbose_name="Peut visualiser") # Peut voir l'entrepôt
can_manage = models.BooleanField(default=False, verbose_name="Peut gérer") # Peut gérer l'entrepôt (ajouter/modifier stock, commandes, etc.)
can_administer = models.BooleanField(default=False, verbose_name="Peut administrer") # Peut administrer l'entrepôt (gérer les accès, config, etc.)
can_manage_preparation_orders = models.BooleanField(default=False, verbose_name="Peut gérer les bons de préparation")
can_manage_purchase_orders = models.BooleanField(default=False, verbose_name="Peut gérer les bons de commande")
can_validate_inventory = models.BooleanField(default=False, verbose_name="Peut valider les inventaires")
is_default = models.BooleanField(default=False, verbose_name="Entrepôt par défaut") # Entrepôt par défaut pour cet utilisateur
class Meta:
unique_together = ("user_config", "warehouse") # Empêche les doublons
verbose_name = "Accès Entrepôt"
verbose_name_plural = "Accès Entrepôts"
def __str__(self):
return f"{self.user_config.user.username} - {self.warehouse.name}"
def save(self, *args, **kwargs):
# Si cet entrepôt est défini comme par défaut, retirer le flag des autres
if self.is_default:
UserWarehouseAccess.objects.filter(
user_config=self.user_config,
is_default=True
).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
class UserWarehouseLocationAccess(models.Model):
"""Table intermédiaire pour gérer les droits d'accès d'un utilisateur à un emplacement d'entrepôt (camionnette, etc.)"""
user_config = models.ForeignKey(UserConfig, on_delete=models.CASCADE, related_name="userlocations")
warehouse_location = models.ForeignKey("stock.WarehouseLocation", on_delete=models.CASCADE, related_name="user_accesses", verbose_name="Emplacement")
can_perform_inventory = models.BooleanField(default=False, verbose_name="Peut faire l'inventaire")
can_validate_inventory = models.BooleanField(default=False, verbose_name="Peut valider l'inventaire")
class Meta:
unique_together = ("user_config", "warehouse_location")
verbose_name = "Accès Emplacement"
verbose_name_plural = "Accès Emplacements"
def __str__(self):
return f"{self.user_config.user.username} - {self.warehouse_location.name}"
class UserAssetAccess(models.Model):
"""
Permissions explicites par instance d'asset ou de location pour un utilisateur.
Logique de priorité (identique aux contrats) :
- Si une ligne existe → ses valeurs (True OU False) sont DÉFINITIVES, sans fallback thématique.
- Si aucune ligne n'existe → on consulte AssetTypePermissionConfig.requires_explicit_permissions :
* True → l'utilisateur n'a PAS accès (droits thématiques insuffisants).
* False → fallback sur les droits thématiques habituels.
- Le rôle admin bypass toujours ces vérifications.
Le champ editable_fields, s'il est renseigné (liste non vide), REMPLACE complètement
les champs éditables déterminés par le rôle.
Le champ visible_fields, s'il est renseigné (liste non vide), REMPLACE complètement
les champs visibles par défaut (masque les champs absents de la liste).
"""
user_config = models.ForeignKey(
UserConfig,
on_delete=models.CASCADE,
related_name='asset_accesses',
verbose_name="Configuration utilisateur",
)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
verbose_name="Type d'objet",
help_text="Le modèle Django de l'asset ou de la location concernée.",
)
object_id = models.PositiveIntegerField(
verbose_name="ID de l'objet",
)
content_object = GenericForeignKey('content_type', 'object_id')
can_view = models.BooleanField(
default=False,
verbose_name="Peut visualiser",
)
can_edit = models.BooleanField(
default=False,
verbose_name="Peut modifier",
)
can_delete = models.BooleanField(
default=False,
verbose_name="Peut supprimer",
)
editable_fields = ArrayField(
models.CharField(max_length=200),
null=True,
blank=True,
verbose_name="Champs modifiables",
help_text=(
"Liste des noms de champs que cet utilisateur peut modifier sur cet objet. "
"Si renseignée (non vide), remplace complètement les champs autorisés par le rôle."
),
)
visible_fields = ArrayField(
models.CharField(max_length=200),
null=True,
blank=True,
verbose_name="Champs visibles",
help_text=(
"Liste des noms de champs que cet utilisateur peut voir sur cet objet. "
"Si renseignée (non vide), remplace complètement les champs affichés par défaut."
),
)
class Meta:
unique_together = [('user_config', 'content_type', 'object_id')]
verbose_name = "Accès asset spécifique"
verbose_name_plural = "Accès assets spécifiques"
indexes = [
models.Index(fields=['content_type', 'object_id']),
]
def __str__(self):
return (
f"{self.user_config.user.username} → {self.content_type.model} #{self.object_id}"
)
class UserAssetTypeAccess(models.Model):
"""
Permissions explicites pour un utilisateur sur TOUS les objets d'un type d'asset donné.
Niveau intermédiaire entre UserAssetAccess (par instance) et les droits thématiques.
Logique de priorité :
- Plus prioritaire que les droits thématiques et que requires_explicit_permissions.
- Moins prioritaire qu'une ligne UserAssetAccess portant sur l'instance précise.
Le champ editable_fields, s'il est renseigné (liste non vide), remplace les champs
éditables déterminés par le rôle **pour tous les objets de ce type**, sauf si une
ligne UserAssetAccess spécifique à l'instance définit ses propres editable_fields.
Le champ visible_fields, s'il est renseigné (liste non vide), remplace les champs
visibles par défaut pour tous les objets de ce type. Peut être écrasé par instance.
"""
user_config = models.ForeignKey(
UserConfig,
on_delete=models.CASCADE,
related_name='asset_type_accesses',
verbose_name="Configuration utilisateur",
)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
verbose_name="Type d'asset",
help_text="Le modèle Django concerné (s'applique à toutes ses instances).",
)
can_view = models.BooleanField(
default=False,
verbose_name="Peut visualiser",
)
can_edit = models.BooleanField(
default=False,
verbose_name="Peut modifier",
)
can_delete = models.BooleanField(
default=False,
verbose_name="Peut supprimer",
)
editable_fields = ArrayField(
models.CharField(max_length=200),
null=True,
blank=True,
verbose_name="Champs modifiables",
help_text=(
"Liste des noms de champs modifiables pour tous les objets de ce type. "
"Si renseignée (non vide), remplace les champs autorisés par le rôle. "
"Peut être écrasée objet par objet via UserAssetAccess."
),
)
visible_fields = ArrayField(
models.CharField(max_length=200),
null=True,
blank=True,
verbose_name="Champs visibles",
help_text=(
"Liste des noms de champs visibles pour tous les objets de ce type. "
"Si renseignée (non vide), remplace les champs affichés par défaut. "
"Peut être écrasée objet par objet via UserAssetAccess."
),
)
class Meta:
unique_together = [('user_config', 'content_type')]
verbose_name = "Accès par type d'asset"
verbose_name_plural = "Accès par type d'asset"
def __str__(self):
return (
f"{self.user_config.user.username} → {self.content_type.model} (tous les objets)"
)
class AssetTypePermissionConfig(models.Model):
"""
Configuration des permissions au niveau d'un type d'asset (modèle Django).
Lorsque requires_explicit_permissions=True, les droits thématiques ne sont pas
suffisants pour accéder aux instances de ce type : chaque utilisateur doit avoir
une ligne UserAssetAccess ou UserAssetTypeAccess explicite.
"""
content_type = models.OneToOneField(
ContentType,
on_delete=models.CASCADE,
related_name='asset_permission_config',
verbose_name="Type d'asset",
)
requires_explicit_permissions = models.BooleanField(
default=False,
verbose_name="Droits explicites requis",
help_text=(
"Si coché, les droits thématiques seuls ne suffisent pas. "
"Chaque utilisateur doit avoir une entrée explicite dans "
"« Accès assets spécifiques » pour ce type d'objet."
),
)
class Meta:
verbose_name = "Config permissions par type d'asset"
verbose_name_plural = "Configs permissions par type d'asset"
def __str__(self):
flag = "⚠ droits explicites requis" if self.requires_explicit_permissions else "droits thématiques suffisants"
return f"{self.content_type.model} — {flag}"
class UserApprovalConfig(UserConfig):
"""Proxy model de UserConfig dédié à la gestion des approbateurs d'interventions."""
class Meta:
proxy = True
verbose_name = "Configuration approbateur"
verbose_name_plural = "Configurations approbateurs"
ordering = ['user__username']
DOCUMENT_STATUS_CHOICES = {
'active': 'Actif',
'archived': 'Archivé',
}
class Document(models.Model):
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="Ajouté par",
)
description = models.TextField(blank=True,null=True,verbose_name="Description du document")
upload_date = models.DateTimeField(auto_now=True,verbose_name="Date de téléversement")
status = models.CharField(choices=DOCUMENT_STATUS_CHOICES, default='active')
managed_document = models.ForeignKey(
"documents.ManagedDocument",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="%(app_label)s_%(class)s_sources",
)
@property
def filename(self):
return os.path.basename(self.file.name)
class Meta:
abstract = True
ordering = ["upload_date"]
def __str__(self):
return f"{self.file.name}"
DOCUMENT_MANAGEMENT_SYNC = False # Désactive la synchronisation par défaut
def get_document_context(self):
for field in self._meta.fields:
if not field.is_relation or not field.many_to_one:
continue
if field.name in {"uploaded_by", "managed_document"}:
continue
value = getattr(self, field.name, None)
if value is not None:
return value
return None
def get_document_title(self):
return getattr(self, "description", None) or self.filename
def generate_thumbnail(self):
"""Générer une vignette pour les fichiers PDF."""
if self.file.name.endswith(".pdf"):
try:
pdf_path = self.file.path
images = convert_from_path(pdf_path, first_page=1, last_page=1, size=(200, 200))
thumbnail_path = f"{self.file.path}_thumbnail.jpg"
images[0].save(thumbnail_path, "JPEG")
self.thumbnail.name = f"{self.file.name}_thumbnail.jpg"
self.save()
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error generating PDF thumbnail for {self.file.name}: {e}")
else:
try:
with Image.open(self.file.path) as img:
# Conversion en mode RGB si nécessaire (évite les erreurs avec le format JPEG)
if img.mode in ("RGBA", "P"): # "P" correspond aux images en palette (GIF)
img = img.convert("RGB")
img.thumbnail((200, 200))
thumbnail_path = f"{self.file.path}_thumbnail.jpg"
img.save(thumbnail_path, "JPEG")
self.thumbnail.name = f"{self.file.name}_thumbnail.jpg"
self.save()
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error generating image thumbnail for {self.file.name}: {e}")
def save(self, *args, **kwargs):
is_update = self.pk is not None
old_file_name = None
if is_update:
old_file_name = (
self.__class__.objects.filter(pk=self.pk)
.values_list("file", flat=True)
.first()
)
super().save(*args, **kwargs) # Save the file first
if self.file: # Vérifier qu'un fichier est bien présent
try:
with Image.open(self.file.path) as img:
# Vérifier et corriger l'orientation EXIF si nécessaire
try:
exif = img._getexif()
if exif:
for tag, value in exif.items():
if tag in ExifTags.TAGS and ExifTags.TAGS[tag] == "Orientation":
if value == 3:
img = img.rotate(180, expand=True)
elif value == 6:
img = img.rotate(270, expand=True)
elif value == 8:
img = img.rotate(90, expand=True)
except AttributeError:
pass # Pas d'EXIF, on ignore l'orientation
# Sauvegarde de l'image corrigée en écrasant l'originale
img.save(self.file.path, format=img.format)
except Exception:
pass # Fichier non-image (PDF, etc.)
if not self.thumbnail:
self.generate_thumbnail()
if not getattr(self, "DOCUMENT_MANAGEMENT_SYNC", False):
return
try:
from documents.models import ensure_document_for_legacy
ensure_document_for_legacy(self, old_file_name=old_file_name)
except Exception as exc:
print(f"Error while synchronising document management for {self}: {exc}")
class Municipality(models.Model):
fid = 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")
niscode = models.CharField(max_length=30, verbose_name="Postal code", null=True, blank=True)
geom = models.MultiPolygonField(srid=3812, null=True, blank=True)
@property
def name(self):
"""Renvoie le nom dans la langue active."""
lang = get_language()
if lang == 'nl':
return self.name_nl
return self.name_fr
objects = LanguageManager()
def __str__(self):
return self.name
class AdrStreet(models.Model):
fid = models.CharField(max_length=100, unique=True)
name_fr = models.CharField(max_length=255, verbose_name="Nom")
name_nl = models.CharField(max_length=255, verbose_name="Naam")
municipality = models.ForeignKey(Municipality, on_delete=models.CASCADE, related_name="street_names", null=True, blank=True)
full_street_fr = models.CharField(max_length=2048, verbose_name="Nom complet FR", null=True, blank=True)
full_street_nl = models.CharField(max_length=2048, verbose_name="Nom complet NL", null=True, blank=True)
centroid = models.PointField(srid=3812, null=True, blank=True)
lon = models.FloatField(null=True, blank=True)
lat = models.FloatField(null=True, blank=True)
geom = models.MultiPolygonField(srid=3812, null=True, blank=True)
@property
def name(self):
"""Renvoie le nom dans la langue active."""
lang = get_language()
if lang == 'nl':
street_name = self.name_nl
else:
street_name = self.name_fr
return f"{street_name} ({self.municipality.name})" if self.municipality else street_name
def get_name(self):
"""Compatibilité avec les appels get_name()"""
return self.name
objects = LanguageManager()
def __str__(self):
return self.name
class AdrPoint(models.Model):
fid = models.CharField(max_length=100, unique=True)
number = models.CharField(max_length=255, verbose_name="Numéro")
street_name = models.ForeignKey(AdrStreet, on_delete=models.CASCADE, related_name="addresses", null=True, blank=True)
zipcode = models.CharField(max_length=255, verbose_name="Code postal", null=True, blank=True)
municipality = models.ForeignKey(Municipality, on_delete=models.SET_NULL, related_name="addresses", null=True, blank=True)
full_address_fr = models.CharField(max_length=2048, verbose_name="Adresse complète FR", null=True, blank=True)
full_address_nl = models.CharField(max_length=2048, verbose_name="Adresse complète NL", null=True, blank=True)
geom = models.PointField(srid=3812, null=True, blank=True)
lon = models.FloatField(null=True, blank=True)
lat = models.FloatField(null=True, blank=True)
def __str__(self):
if self.street_name:
return f"{self.number} {self.street_name.name} ({self.municipality.name})" if self.municipality else f"{self.number} {self.street_name.name}"
return f"{self.number} ({self.municipality.name})" if self.municipality else self.number
LOG_LEVEL_CHOICES = {
'DEBUG': 'Debug',
'INFO': 'Info',
'WARNING': 'Warning',
'ERROR': 'Error',
'CRITICAL': 'Critical',
}
class RequestLog(models.Model):
"""
Modèle pour stocker les logs des requêtes et erreurs de l'application.
Permet de visualiser les erreurs dans l'admin Django.
"""
timestamp = models.DateTimeField(auto_now_add=True, verbose_name="Date et heure", db_index=True)
level = models.CharField(max_length=10, choices=LOG_LEVEL_CHOICES, default='ERROR', verbose_name="Niveau")
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="Utilisateur",
related_name="request_logs"
)
username = models.CharField(max_length=150, blank=True, null=True, verbose_name="Nom d'utilisateur")
method = models.CharField(max_length=10, blank=True, null=True, verbose_name="Méthode HTTP")
path = models.CharField(max_length=2048, verbose_name="URL")
full_url = models.CharField(max_length=4096, blank=True, null=True, verbose_name="URL complète")
status_code = models.IntegerField(null=True, blank=True, verbose_name="Code HTTP")
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name="Adresse IP")
user_agent = models.TextField(blank=True, null=True, verbose_name="User Agent")
referer = models.CharField(max_length=2048, blank=True, null=True, verbose_name="Referer")
exception_type = models.CharField(max_length=255, blank=True, null=True, verbose_name="Type d'exception")
exception_message = models.TextField(blank=True, null=True, verbose_name="Message d'erreur")
traceback = models.TextField(blank=True, null=True, verbose_name="Traceback")
request_data = models.TextField(blank=True, null=True, verbose_name="Données de la requête")
class Meta:
verbose_name = "Log de requête"
verbose_name_plural = "Logs de requêtes"
ordering = ["-timestamp"]
indexes = [
models.Index(fields=['level']),
models.Index(fields=['user']),
models.Index(fields=['status_code']),
models.Index(fields=['timestamp', 'level']),
]
def __str__(self):
return f"[{self.level}] {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')} - {self.path}"
@classmethod
def log_request(cls, request, exception=None, level='ERROR', status_code=None, traceback_str=None):
"""
Crée une entrée de log à partir d'une requête HTTP.
"""
import json
# Récupérer l'adresse IP
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip_address = x_forwarded_for.split(',')[0].strip()
else:
ip_address = request.META.get('REMOTE_ADDR')
# Récupérer les données de la requête (sans données sensibles)
request_data = None
try:
if request.method in ['POST', 'PUT', 'PATCH']:
data = dict(request.POST)
# Masquer les mots de passe et autres données sensibles
sensitive_keys = ['password', 'password1', 'password2', 'token', 'secret', 'csrfmiddlewaretoken']
for key in sensitive_keys:
if key in data:
data[key] = ['***MASKED***']
request_data = json.dumps(data, default=str)
except Exception:
pass
# Informations sur l'exception
exception_type = None
exception_message = None
if exception:
exception_type = type(exception).__name__
exception_message = str(exception)
# Créer le log
log_entry = cls.objects.create(
level=level,
user=request.user if request.user.is_authenticated else None,
username=request.user.username if request.user.is_authenticated else None,
method=request.method,
path=request.path,
full_url=request.build_absolute_uri(),
status_code=status_code,
ip_address=ip_address,
user_agent=request.META.get('HTTP_USER_AGENT', '')[:2000] if request.META.get('HTTP_USER_AGENT') else None,
referer=request.META.get('HTTP_REFERER', '')[:2048] if request.META.get('HTTP_REFERER') else None,
exception_type=exception_type,
exception_message=exception_message,
traceback=traceback_str,
request_data=request_data,
)
return log_entry
# Import des modèles de tâches planifiées
from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog