loko/loko/common/admin.py
kdeterme b5b0338e09 feat(assets): add management zones CRUD interfaces and permissions
- Add can_edit_management_zones permission to UserThematics model and admin
- Implement ManagementZoneForm with interactive WGS84 polygon capture and SRID 3812 reprojection
- Add views and routes for listing, creating, editing, detailing, and deleting management zones
- Support document attachments (ManagementZoneDocument) and explicit asset linkages
- Add MapLibre polygon drawing tool in zone form and preview map in detail view
- Integrate management zones shortcuts in nature assets map overlay and index
- Add comprehensive unit tests for permissions, forms, and CRUD operations
2026-10-05 15:35:33 +02:00

1055 lines
45 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 import admin
from django.contrib.gis import admin as gis_admin
from django.urls import path, reverse
from django.shortcuts import render, redirect
from django.contrib import messages
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils import timezone
from datetime import timedelta
from django.db.models import Case, When, IntegerField, Value
from .models import (
MapBackground,
Territory,
UserConfig, UserContractAccess, Thematic, Role, UserThematics,
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
UserThematicStatusPermission, UserContractStatusPermission,
UserAssetAccess, UserAssetTypeAccess, AssetTypePermissionConfig,
INTERVENTION_STATUS_ORDER, INTERVENTION_STATUS_CHOICES,
)
from .forms import UserConfigDuplicateForm, UserConfigCopyFromForm, UserConfigAssignCompanyForm
from .user_services import copy_user_config
from django.contrib.admin import helpers
from contracts.models import UserCompanyTeamAccess, CompanyMember, CompanyTeam
# Import de l'admin des tâches planifiées
from .admin_scheduled_tasks import ScheduledCommandAdmin, CommandExecutionLogAdmin
@admin.register(Territory)
class TerritoryAdmin(gis_admin.GISModelAdmin):
list_display = ("name_fr", "name_nl", "code", "is_default", "is_active", "updated_at")
list_filter = ("is_default", "is_active")
search_fields = ("name_fr", "name_nl", "code")
list_editable = ("is_default", "is_active")
ordering = ("-is_default", "name_fr")
@admin.register(Thematic)
class ThematicAdmin(admin.ModelAdmin):
list_display = ("name_fr", "name_nl", "code", "icon", "color", "uses_occupations", "include_in_daily_report", "allow_planning_without_contract")
search_fields = ("name_fr", "name_nl", "code")
list_filter = ("uses_occupations", "include_in_daily_report", "allow_planning_without_contract")
list_editable = ("include_in_daily_report", "allow_planning_without_contract")
@admin.register(AppView)
class AppViewAdmin(admin.ModelAdmin):
list_display = ("code", "name_fr", "name_nl", "icon", "url_name", "is_default", "order")
search_fields = ("code", "name_fr", "name_nl")
list_editable = ("order", "is_default")
ordering = ("order",)
@admin.register(RequestLog)
class RequestLogAdmin(admin.ModelAdmin):
"""
Admin pour visualiser les logs de requêtes et erreurs.
Permet de rechercher par date, utilisateur, URL, etc.
"""
list_display = (
"timestamp_display",
"level_badge",
"username_display",
"method",
"path_truncated",
"status_code_badge",
"ip_address",
"exception_type",
)
list_filter = (
"level",
"method",
"status_code",
("timestamp", admin.DateFieldListFilter),
"user",
)
search_fields = (
"path",
"full_url",
"username",
"user__username",
"ip_address",
"exception_type",
"exception_message",
"traceback",
)
readonly_fields = (
"timestamp",
"level",
"user",
"username",
"method",
"path",
"full_url",
"status_code",
"ip_address",
"user_agent",
"referer",
"exception_type",
"exception_message",
"traceback_display",
"request_data_display",
)
date_hierarchy = "timestamp"
ordering = ("-timestamp",)
list_per_page = 50
actions = ["delete_selected"]
change_list_template = "admin/common/requestlog/change_list.html"
fieldsets = (
("Informations générales", {
"fields": ("timestamp", "level", "status_code")
}),
("Requête", {
"fields": ("method", "path", "full_url", "referer")
}),
("Utilisateur", {
"fields": ("user", "username", "ip_address", "user_agent")
}),
("Erreur", {
"fields": ("exception_type", "exception_message", "traceback_display"),
"classes": ("collapse",),
}),
("Données de la requête", {
"fields": ("request_data_display",),
"classes": ("collapse",),
}),
)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"delete-old-logs/<str:period>/",
self.admin_site.admin_view(self.delete_old_logs_view),
name="common_requestlog_delete_old",
),
]
return custom_urls + urls
def delete_old_logs_view(self, request, period):
"""Vue pour supprimer les anciens logs selon la période."""
now = timezone.now()
# Option pour supprimer tous les logs
if period == "all":
deleted_count, _ = RequestLog.objects.all().delete()
if deleted_count > 0:
messages.success(
request,
f"Tous les {deleted_count} log(s) ont été supprimés avec succès."
)
else:
messages.info(request, "Aucun log à supprimer.")
return redirect("admin:common_requestlog_changelist")
periods = {
"1week": (timedelta(weeks=1), "1 semaine"),
"1month": (timedelta(days=30), "1 mois"),
"3months": (timedelta(days=90), "3 mois"),
"6months": (timedelta(days=180), "6 mois"),
"1year": (timedelta(days=365), "1 an"),
}
if period not in periods:
messages.error(request, "Période invalide.")
return redirect("admin:common_requestlog_changelist")
delta, period_name = periods[period]
cutoff_date = now - delta
# Supprimer les logs plus anciens que la date limite
deleted_count, _ = RequestLog.objects.filter(timestamp__lt=cutoff_date).delete()
if deleted_count > 0:
messages.success(
request,
f"{deleted_count} log(s) de plus de {period_name} supprimé(s) avec succès."
)
else:
messages.info(
request,
f"Aucun log de plus de {period_name} à supprimer."
)
return redirect("admin:common_requestlog_changelist")
def changelist_view(self, request, extra_context=None):
"""Ajouter le contexte pour les boutons de suppression."""
extra_context = extra_context or {}
extra_context['delete_periods'] = [
("1week", "1 semaine"),
("1month", "1 mois"),
("3months", "3 mois"),
("6months", "6 mois"),
("1year", "1 an"),
]
# Compter les logs par période pour afficher dans les boutons
now = timezone.now()
extra_context['log_counts'] = {
"1week": RequestLog.objects.filter(timestamp__lt=now - timedelta(weeks=1)).count(),
"1month": RequestLog.objects.filter(timestamp__lt=now - timedelta(days=30)).count(),
"3months": RequestLog.objects.filter(timestamp__lt=now - timedelta(days=90)).count(),
"6months": RequestLog.objects.filter(timestamp__lt=now - timedelta(days=180)).count(),
"1year": RequestLog.objects.filter(timestamp__lt=now - timedelta(days=365)).count(),
}
extra_context['total_logs'] = RequestLog.objects.count()
return super().changelist_view(request, extra_context=extra_context)
def has_add_permission(self, request):
"""Désactiver l'ajout manuel de logs."""
return False
def has_change_permission(self, request, obj=None):
"""Les logs ne peuvent pas être modifiés."""
return False
def timestamp_display(self, obj):
"""Affiche la date et l'heure formatées."""
return obj.timestamp.strftime("%d/%m/%Y %H:%M:%S")
timestamp_display.short_description = "Date/Heure"
timestamp_display.admin_order_field = "timestamp"
def level_badge(self, obj):
"""Affiche le niveau avec un badge coloré."""
colors = {
'DEBUG': '#6c757d',
'INFO': '#17a2b8',
'WARNING': '#ffc107',
'ERROR': '#dc3545',
'CRITICAL': '#721c24',
}
color = colors.get(obj.level, '#6c757d')
text_color = '#fff' if obj.level in ['ERROR', 'CRITICAL'] else '#000'
return format_html(
'<span style="background-color: {}; color: {}; padding: 3px 8px; '
'border-radius: 3px; font-size: 11px; font-weight: bold;">{}</span>',
color, text_color, obj.level
)
level_badge.short_description = "Niveau"
level_badge.admin_order_field = "level"
def username_display(self, obj):
"""Affiche le nom d'utilisateur ou 'Anonyme'."""
if obj.username:
return obj.username
return mark_safe('<span style="color: #999; font-style: italic;">Anonyme</span>')
username_display.short_description = "Utilisateur"
username_display.admin_order_field = "username"
def path_truncated(self, obj):
"""Affiche l'URL tronquée si trop longue."""
max_length = 60
if len(obj.path) > max_length:
return format_html(
'<span title="{}">{}&hellip;</span>',
obj.path, obj.path[:max_length]
)
return obj.path
path_truncated.short_description = "URL"
path_truncated.admin_order_field = "path"
def status_code_badge(self, obj):
"""Affiche le code de statut HTTP avec un badge coloré."""
if not obj.status_code:
return "-"
if obj.status_code >= 500:
color = '#dc3545'
elif obj.status_code >= 400:
color = '#ffc107'
elif obj.status_code >= 300:
color = '#17a2b8'
else:
color = '#28a745'
return format_html(
'<span style="background-color: {}; color: #fff; padding: 2px 6px; '
'border-radius: 3px; font-size: 11px;">{}</span>',
color, obj.status_code
)
status_code_badge.short_description = "Code"
status_code_badge.admin_order_field = "status_code"
def traceback_display(self, obj):
"""Affiche le traceback dans une zone de texte formatée."""
if not obj.traceback:
return "-"
return format_html(
'<pre style="background-color: #f8f9fa; padding: 10px; '
'border-radius: 4px; overflow-x: auto; max-height: 400px; '
'font-size: 12px; white-space: pre-wrap; word-wrap: break-word;">{}</pre>',
obj.traceback
)
traceback_display.short_description = "Traceback"
def request_data_display(self, obj):
"""Affiche les données de la requête formatées."""
if not obj.request_data:
return "-"
try:
import json
data = json.loads(obj.request_data)
formatted = json.dumps(data, indent=2, ensure_ascii=False)
return format_html(
'<pre style="background-color: #f8f9fa; padding: 10px; '
'border-radius: 4px; overflow-x: auto; max-height: 300px; '
'font-size: 12px;">{}</pre>',
formatted
)
except Exception:
return format_html('<pre>{}</pre>', obj.request_data)
request_data_display.short_description = "Données de la requête"
# ---------------------------------------------------------------------------
# Matrix script (injected once per page via window._matrixEvtBound guard)
# ---------------------------------------------------------------------------
_MATRIX_SCRIPT = """\
<script>(function(){
if(window._matrixEvtBound)return;
window._matrixEvtBound=true;
function initStates(){
document.querySelectorAll('.matrix-toggle-row').forEach(function(t){
var ut=t.dataset.ut,f=t.dataset.f;
var cbs=document.querySelectorAll('.matrix-cb[data-ut="'+ut+'"][data-f="'+f+'"]');
var n=Array.from(cbs).filter(function(c){return c.checked;}).length;
t.checked=(n===cbs.length);t.indeterminate=(n>0&&n<cbs.length);
});
}
document.addEventListener('change',function(e){
var cb=e.target;
if(!cb.classList)return;
if(cb.classList.contains('matrix-toggle-row')){
var ut=cb.dataset.ut,f=cb.dataset.f;
document.querySelectorAll('.matrix-cb[data-ut="'+ut+'"][data-f="'+f+'"]').forEach(function(c){c.checked=cb.checked;});
}else if(cb.classList.contains('matrix-cb')){
var ut=cb.dataset.ut,f=cb.dataset.f;
var cbs=document.querySelectorAll('.matrix-cb[data-ut="'+ut+'"][data-f="'+f+'"]');
var t=document.querySelector('.matrix-toggle-row[data-ut="'+ut+'"][data-f="'+f+'"]');
if(t){var n=Array.from(cbs).filter(function(c){return c.checked;}).length;t.checked=(n===cbs.length);t.indeterminate=(n>0&&n<cbs.length);}
}
});
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',initStates);}else{initStates();}
})();</script>"""
def _render_status_matrix(obj, prefix):
"""
Renvoie le HTML d’une matrice permissions-par-statut complète pour obj.
prefix : 'ut_perm' pour UserThematics, 'uca_perm' pour UserContractAccess.
Les checkboxes sont nommés ``{prefix}_{obj.pk}_{status}_{field}``.
"""
if not obj or not obj.pk:
return mark_safe('<em style="color:#999;">Enregistrez d\'abord pour configurer les permissions par statut.</em>')
choices_dict = dict(INTERVENTION_STATUS_CHOICES)
perms = {p.status: p for p in obj.status_permissions.all()}
uid = f'{prefix}_{obj.pk}' # identifiant unique par matrice (pour les data-ut JS)
# En-têtes de colonnes (texte vertical)
header_cells = ['<th style="min-width:130px;text-align:left;padding:4px 8px;border-bottom:2px solid #ccc;"></th>']
for status in INTERVENTION_STATUS_ORDER:
label = choices_dict.get(status, status)
header_cells.append(
f'<th style="width:40px;padding:0;text-align:center;vertical-align:bottom;border-bottom:2px solid #ccc;">'
f'<div style="writing-mode:vertical-lr;transform:rotate(180deg);height:80px;'
f'display:flex;align-items:center;justify-content:center;font-size:0.78em;" title="{label}">{label}</div>'
f'</th>'
)
# Lignes de permissions
perm_types = [
('can_view', 'Voir'),
('can_edit', 'Modifier'),
('can_change_status_to','Changer → vers'),
]
row_htmls = []
for field_name, field_label in perm_types:
cells = [
f'<td style="padding:4px 8px;white-space:nowrap;font-weight:600;">'
f'<label style="display:flex;align-items:center;gap:6px;cursor:pointer;" title="Tout cocher / décocher la ligne">'
f'<input type="checkbox" class="matrix-toggle-row" data-ut="{uid}" data-f="{field_name}">'
f'{field_label}</label></td>'
]
for status in INTERVENTION_STATUS_ORDER:
perm = perms.get(status)
if perm is not None:
checked = 'checked' if getattr(perm, field_name) else ''
name = f'{prefix}_{obj.pk}_{status}_{field_name}'
cells.append(
f'<td style="width:40px;text-align:center;">'
f'<div style="display:flex;justify-content:center;padding:4px 0;">'
f'<input type="checkbox" class="matrix-cb" name="{name}" data-ut="{uid}" data-f="{field_name}" {checked}>'
f'</div></td>'
)
else:
cells.append('<td></td>')
row_htmls.append('<tr>' + ''.join(cells) + '</tr>')
html = (
_MATRIX_SCRIPT
+ '<details style="margin:4px 0;">'
+ '<summary style="cursor:pointer;color:#417690;font-size:0.85em;padding:3px 0;">'
+ '▶ Permissions par statut (cliquer pour développer)</summary>'
+ '<div style="overflow-x:auto;margin-top:6px;">'
+ '<table style="border-collapse:collapse;font-size:0.82em;">'
+ '<thead><tr>' + ''.join(header_cells) + '</tr></thead>'
+ '<tbody>' + ''.join(row_htmls) + '</tbody>'
+ '</table></div></details>'
)
return mark_safe(html)
# ---------------------------------------------------------------------------
# Status-permission matrix inlines (used by UserThematicsAdmin / UserContractAccessAdmin)
# ---------------------------------------------------------------------------
class UserThematicStatusPermissionMatrixInline(admin.TabularInline):
"""Affiche les permissions par statut sous forme de table matricielle (thématique)."""
model = UserThematicStatusPermission
template = 'admin/common/status_permission_matrix.html'
can_delete = False
extra = 0
fields = ['can_view', 'can_edit', 'can_change_status_to']
verbose_name_plural = "Permissions par statut d'intervention"
def get_formset(self, request, obj=None, **kwargs):
if obj is not None:
for status in INTERVENTION_STATUS_ORDER:
UserThematicStatusPermission.objects.get_or_create(
user_thematic=obj,
status=status,
defaults={'can_view': False, 'can_edit': False, 'can_change_status_to': False},
)
return super().get_formset(request, obj, **kwargs)
def get_queryset(self, request):
qs = super().get_queryset(request)
order = Case(
*[When(status=s, then=Value(i)) for i, s in enumerate(INTERVENTION_STATUS_ORDER)],
output_field=IntegerField(),
)
return qs.annotate(_sorder=order).order_by('_sorder')
class UserContractStatusPermissionMatrixInline(admin.TabularInline):
"""Affiche les permissions par statut sous forme de table matricielle (contrat)."""
model = UserContractStatusPermission
template = 'admin/common/status_permission_matrix.html'
can_delete = False
extra = 0
fields = ['can_view', 'can_edit', 'can_change_status_to']
verbose_name_plural = "Permissions par statut d'intervention"
def get_formset(self, request, obj=None, **kwargs):
if obj is not None:
for status in INTERVENTION_STATUS_ORDER:
UserContractStatusPermission.objects.get_or_create(
user_contract=obj,
status=status,
defaults={'can_view': False, 'can_edit': False, 'can_change_status_to': False},
)
return super().get_formset(request, obj, **kwargs)
def get_queryset(self, request):
qs = super().get_queryset(request)
order = Case(
*[When(status=s, then=Value(i)) for i, s in enumerate(INTERVENTION_STATUS_ORDER)],
output_field=IntegerField(),
)
return qs.annotate(_sorder=order).order_by('_sorder')
# ---------------------------------------------------------------------------
# Inlines for UserConfig change page
# ---------------------------------------------------------------------------
class UserContractAccessInline(admin.TabularInline):
"""Permet d'afficher et modifier les accès aux contrats directement depuis UserConfig"""
model = UserContractAccess
extra = 1
autocomplete_fields = ["contract"]
fields = [
'contract', 'can_view_assets',
'can_view_interventions', 'can_create_interventions', 'can_check_interventions', 'can_approve',
'can_view_posts', 'can_view_prices',
'status_matrix',
]
readonly_fields = ['status_matrix']
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('status_permissions')
def status_matrix(self, obj):
return _render_status_matrix(obj, 'uca_perm')
status_matrix.short_description = "Permissions par statut"
class UserThematicsInline(admin.TabularInline):
model = UserThematics
extra = 1
fields = [
'thematic', 'can_view_assets', 'can_edit_assets', 'can_validate_assets', 'can_inspect',
'can_view_interventions', 'can_edit_interventions', 'can_edit_locations',
'can_process_observations',
'can_view_projects', 'can_edit_projects', 'can_edit_management_zones',
'status_matrix',
]
readonly_fields = ['status_matrix']
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('status_permissions')
def status_matrix(self, obj):
return _render_status_matrix(obj, 'ut_perm')
status_matrix.short_description = "Permissions par statut"
class UserWarehouseAccessInline(admin.TabularInline):
"""Permet d'afficher et modifier les accès aux entrepôts directement depuis UserConfig"""
model = UserWarehouseAccess
extra = 1
autocomplete_fields = ["warehouse"] # Facilite la recherche des entrepôts
verbose_name = "Accès Entrepôt"
verbose_name_plural = "Accès Entrepôts"
fields = ['warehouse', 'can_view', 'can_manage', 'can_administer', 'can_manage_preparation_orders', 'can_manage_purchase_orders', 'can_validate_inventory', 'is_default']
list_display = ['warehouse', 'can_view', 'can_manage', 'can_administer', 'can_manage_preparation_orders', 'can_manage_purchase_orders', 'can_validate_inventory', 'is_default']
class UserWarehouseLocationAccessInline(admin.TabularInline):
"""Permet d'afficher et modifier les accès aux emplacements d'entrepôt directement depuis UserConfig"""
model = UserWarehouseLocationAccess
extra = 1
autocomplete_fields = ["warehouse_location"]
verbose_name = "Accès Emplacement"
verbose_name_plural = "Accès Emplacements"
fields = ['warehouse_location', 'can_perform_inventory', 'can_validate_inventory']
class UserCompanyTeamAccessInline(admin.TabularInline):
"""Accès aux équipes de société directement depuis UserConfig"""
model = UserCompanyTeamAccess
extra = 1
autocomplete_fields = ['team']
fields = ['team', 'can_view', 'can_edit', 'can_tag']
verbose_name = "Accès équipe"
verbose_name_plural = "Accès aux équipes"
class UserAssetAccessInline(admin.TabularInline):
"""Accès spécifiques par instance d'asset ou de location, directement depuis UserConfig."""
model = UserAssetAccess
extra = 0
fields = ['content_type', 'object_id', 'can_view', 'can_edit', 'can_delete', 'editable_fields', 'visible_fields', 'object_link']
readonly_fields = ['object_link']
verbose_name = "Accès asset spécifique"
verbose_name_plural = "Accès assets spécifiques"
def get_queryset(self, request):
return super().get_queryset(request).select_related('content_type')
def object_link(self, obj):
if not obj.pk:
return "—"
model_class = obj.content_type.model_class()
if model_class is None:
return f"#{obj.object_id}"
try:
instance = model_class.objects.get(pk=obj.object_id)
if hasattr(instance, 'get_absolute_url'):
url = instance.get_absolute_url()
return format_html('<a href="{}" target="_blank">{}</a>', url, str(instance))
return str(instance)
except model_class.DoesNotExist:
return f"#{obj.object_id} (supprimé)"
object_link.short_description = "Objet"
class UserAssetTypeAccessInline(admin.TabularInline):
"""Accès par type d'asset (s'applique à tous les objets du type), directement depuis UserConfig."""
model = UserAssetTypeAccess
extra = 0
fields = ['content_type', 'can_view', 'can_edit', 'can_delete', 'editable_fields', 'visible_fields']
verbose_name = "Accès type d'asset"
verbose_name_plural = "Accès types d'asset"
def get_queryset(self, request):
return super().get_queryset(request).select_related('content_type')
# ---------------------------------------------------------------------------
# Standalone admin for UserThematics and UserContractAccess
# (allow editing the status-permission matrix for a single thematic/contract access)
# ---------------------------------------------------------------------------
@admin.register(UserThematics)
class UserThematicsAdmin(admin.ModelAdmin):
list_display = ('user_config', 'thematic', 'can_view_interventions', 'can_edit_interventions', 'can_view_assets', 'can_edit_assets', 'can_validate_assets', 'can_inspect', 'can_process_observations', 'can_edit_management_zones')
list_filter = ('thematic',)
search_fields = ('user_config__user__username', 'thematic__name_fr', 'thematic__name_nl')
raw_id_fields = ['user_config']
inlines = [UserThematicStatusPermissionMatrixInline]
verbose_name = "Thématique utilisateur"
verbose_name_plural = "Thématiques utilisateur"
@admin.register(UserContractAccess)
class UserContractAccessAdmin(admin.ModelAdmin):
list_display = ('user_config', 'contract', 'can_view_interventions', 'can_create_interventions', 'can_check_interventions', 'can_approve')
list_filter = ('contract',)
search_fields = ('user_config__user__username', 'contract__contract_number')
raw_id_fields = ['user_config', 'contract']
readonly_fields = ['can_approve', 'can_create_interventions']
inlines = [UserContractStatusPermissionMatrixInline]
@admin.register(UserConfig)
class UserConfigAdmin(admin.ModelAdmin):
list_display = ("user", "display_contracts", "display_thematics", "display_views", "is_intern", "display_roles", "company", "display_member_teams", "duplicate_button")
list_filter = ("thematics", "roles", "company", "user__companymember__teams", "contracts", "accessible_views", "auto_logout_hours")
search_fields = ("user__username", "user__first_name", "user__last_name", "contracts__contract_number")
inlines = [UserThematicsInline, UserContractAccessInline, UserWarehouseAccessInline, UserWarehouseLocationAccessInline, UserCompanyTeamAccessInline, UserAssetTypeAccessInline, UserAssetAccessInline]
filter_horizontal = ("linked_configs", "accessible_views")
actions = ["duplicate_config_action", "apply_config_from_action", "assign_company_action"]
ordering = ("user__username",)
readonly_fields = ('company_members_link', 'company_teams_link')
fieldsets = (
(None, {
'fields': ('user', 'is_intern', 'roles', 'company', 'company_members_link', 'company_teams_link', 'default_thematic', 'default_contract', 'generic_email')
}),
('Accès aux vues', {
'fields': ('accessible_views',),
'description': "Configurer les vues auxquelles l'utilisateur a accès."
}),
('eSign / Signalisation', {
'fields': ('esign_can_edit', 'esign_can_manage_rcp', 'esign_can_validate_rcp', 'esign_organizations'),
'description': "Permissions spécifiques pour l'application eSign / Signalisation."
}),
('Photos 360 / Panoramax', {
'fields': ('panoramax_can_upload',),
'description': "Permissions spécifiques pour l'application Photos 360 / Panoramax."
}),
('Ouvrages : Transports exceptionnels', {
'fields': ('has_exceptional_transport_profile', 'exceptional_transport_can_edit'),
'description': "Permissions pour l'accès aux données et documents de transport exceptionnel sur les ouvrages d'art."
}),
('Limitations', {
'fields': ('limit_assets_to_contracts', 'limit_interventions_to_contracts', 'limit_shortcuts_to_contracts', 'hide_intervention_map_by_default')
}),
('Gestion documentaire', {
'fields': ('can_add_unique_tag_documents',),
'description': "Permissions pour la gestion des documents avec tags uniques (CME, PIS, As-Built, etc.)."
}),
('Comptes liés', {
'fields': ('linked_configs',)
}),
('Session', {
'fields': ('auto_logout_hours',),
'description': 'Configurer la déconnexion automatique pour les utilisateurs partageant un ordinateur (shifts).'
}),
)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"<int:object_id>/duplicate/",
self.admin_site.admin_view(self.duplicate_config_view),
name="common_userconfig_duplicate",
),
]
return custom_urls + urls
# ---------------------------------------------------------------------------
# Action: appliquer la config d'un utilisateur source sur plusieurs cibles
# ---------------------------------------------------------------------------
def apply_config_from_action(self, request, queryset):
"""Action intermédiaire : sélectionne une config source et la copie sur toutes les cibles."""
if 'apply_source' in request.POST:
form = UserConfigCopyFromForm(request.POST)
if form.is_valid():
source_config = form.cleaned_data['source']
targets = queryset.exclude(pk=source_config.pk)
count = 0
for target in targets:
self._copy_config(source_config, target)
count += 1
self.message_user(
request,
f"Configuration de {source_config.user.username} copiée sur {count} utilisateur(s).",
level=messages.SUCCESS,
)
return None
# Form invalid → re-render with errors
else:
form = UserConfigCopyFromForm()
return render(request, 'admin/common/apply_config_from.html', {
**self.admin_site.each_context(request),
'form': form,
'queryset': queryset,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
'opts': self.model._meta,
'title': 'Appliquer la configuration d\'un utilisateur sur les sélectionnés',
})
apply_config_from_action.short_description = "Appliquer la config d'un autre utilisateur sur les sélectionnés"
# ---------------------------------------------------------------------------
# Action: assigner une société à plusieurs UserConfig
# ---------------------------------------------------------------------------
def assign_company_action(self, request, queryset):
if 'assign_company' in request.POST:
form = UserConfigAssignCompanyForm(request.POST)
if form.is_valid():
company = form.cleaned_data['company']
queryset.update(company=company)
label = company.name if company else "aucune"
self.message_user(
request,
f"Société « {label} » assignée à {queryset.count()} utilisateur(s).",
level=messages.SUCCESS,
)
return None
else:
form = UserConfigAssignCompanyForm()
return render(request, 'admin/common/assign_company.html', {
**self.admin_site.each_context(request),
'form': form,
'queryset': queryset,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
'opts': self.model._meta,
'title': 'Assigner une société aux utilisateurs sélectionnés',
})
assign_company_action.short_description = "Assigner une société aux sélectionnés"
def _copy_config(self, source, target):
"""Écrase la configuration de `target` avec celle de `source` (sauf l'utilisateur lié)."""
return copy_user_config(source, target)
def duplicate_config_action(self, request, queryset):
if queryset.count() != 1:
self.message_user(request, "Veuillez sélectionner une seule configuration à dupliquer.", level=messages.ERROR)
return
obj = queryset.first()
return redirect(f"{obj.id}/duplicate/")
duplicate_config_action.short_description = "Dupliquer la configuration sélectionnée"
def duplicate_config_view(self, request, object_id):
orig_config = UserConfig.objects.get(pk=object_id)
if request.method == "POST":
form = UserConfigDuplicateForm(request.POST)
if form.is_valid():
new_user = form.cleaned_data["user"]
new_config = copy_user_config(orig_config, new_user)
messages.success(request, f"Configuration dupliquée pour l'utilisateur {new_user.username} !")
return redirect(f"../../{new_config.id}/change/")
else:
form = UserConfigDuplicateForm()
return render(request, "admin/common/duplicate_userconfig.html", {"form": form, "orig_config": orig_config})
def company_members_link(self, obj):
if not obj.user_id:
return "—"
members = CompanyMember.objects.filter(user=obj.user).prefetch_related('teams')
if not members.exists():
return "—"
links = []
for member in members:
url = reverse('admin:contracts_companymember_change', args=[member.pk])
links.append(format_html('<a href="{}">{}</a>', url, str(member)))
return mark_safe(', '.join(links))
company_members_link.short_description = "CompanyMember(s)"
def company_teams_link(self, obj):
if not obj.user_id:
return "—"
teams = CompanyTeam.objects.filter(members__user=obj.user).distinct()
if not teams.exists():
return "—"
links = []
for team in teams:
url = reverse('admin:contracts_companyteam_change', args=[team.pk])
links.append(format_html('<a href="{}">{}</a>', url, team.name))
return mark_safe(', '.join(links))
company_teams_link.short_description = "Équipes (CompanyTeam)"
def display_contracts(self, obj):
return ", ".join([access.contract.contract_number for access in obj.usercontractaccess_set.all()])
display_contracts.short_description = "Contrats associés"
def display_thematics(self, obj):
return ", ".join([access.thematic.name for access in obj.userthematics.all()])
display_thematics.short_description = "Thématiques"
def display_views(self, obj):
return ", ".join([view.name for view in obj.accessible_views.all()])
display_views.short_description = "Vues"
def display_roles(self, obj):
return ", ".join(obj.roles.values_list('name', flat=True)) or "—"
display_roles.short_description = "Rôles"
def display_member_teams(self, obj):
from contracts.models import CompanyMember
teams = (
CompanyMember.objects
.filter(user=obj.user, teams__isnull=False)
.values_list('teams__name', flat=True)
.distinct()
)
return ", ".join(teams) or "—"
display_member_teams.short_description = "Équipes"
def duplicate_button(self, obj):
return format_html(
'<a href="{}/duplicate/" title="Dupliquer cette configuration">'
'<img src="/static/admin/img/icon-addlink.svg" alt="Dupliquer" style="vertical-align: middle;"/>'
'</a>',
obj.id
)
duplicate_button.short_description = "Actions"
def change_view(self, request, object_id, form_url='', extra_context=None):
"""
Avant le rendu, s’assure que toutes les lignes de permissions par statut existent
pour les thématiques et contrats déjà enregistrés (création lazy des 15 lignes).
"""
if object_id and request.method == 'GET':
obj = self.get_object(request, object_id)
if obj:
for ut in obj.userthematics.prefetch_related('status_permissions').all():
existing = {p.status for p in ut.status_permissions.all()}
to_create = [
UserThematicStatusPermission(user_thematic=ut, status=s)
for s in INTERVENTION_STATUS_ORDER if s not in existing
]
if to_create:
UserThematicStatusPermission.objects.bulk_create(to_create, ignore_conflicts=True)
for uca in obj.usercontractaccess_set.prefetch_related('status_permissions').all():
existing = {p.status for p in uca.status_permissions.all()}
to_create = [
UserContractStatusPermission(user_contract=uca, status=s)
for s in INTERVENTION_STATUS_ORDER if s not in existing
]
if to_create:
UserContractStatusPermission.objects.bulk_create(to_create, ignore_conflicts=True)
return super().change_view(request, object_id, form_url, extra_context)
def save_related(self, request, form, formsets, change):
"""
Après la sauvegarde standard des inlines, persiste les cases à cocher
de la matrice de permissions par statut (thématiques et contrats).
"""
super().save_related(request, form, formsets, change)
obj = form.instance
# Thématiques
for ut in obj.userthematics.all():
for status in INTERVENTION_STATUS_ORDER:
perm, _ = UserThematicStatusPermission.objects.get_or_create(
user_thematic=ut, status=status,
defaults={'can_view': False, 'can_edit': False, 'can_change_status_to': False},
)
perm.can_view = f'ut_perm_{ut.pk}_{status}_can_view' in request.POST
perm.can_edit = f'ut_perm_{ut.pk}_{status}_can_edit' in request.POST
perm.can_change_status_to = f'ut_perm_{ut.pk}_{status}_can_change_status_to' in request.POST
perm.save()
# Contrats
for uca in obj.usercontractaccess_set.all():
for status in INTERVENTION_STATUS_ORDER:
perm, _ = UserContractStatusPermission.objects.get_or_create(
user_contract=uca, status=status,
defaults={'can_view': False, 'can_edit': False, 'can_change_status_to': False},
)
perm.can_view = f'uca_perm_{uca.pk}_{status}_can_view' in request.POST
perm.can_edit = f'uca_perm_{uca.pk}_{status}_can_edit' in request.POST
perm.can_change_status_to = f'uca_perm_{uca.pk}_{status}_can_change_status_to' in request.POST
perm.save()
@admin.register(Role)
class RoleAdmin(admin.ModelAdmin):
list_display = ("name",)
search_fields = ("name",)
@admin.register(UserApprovalConfig)
class UserApprovalConfigAdmin(admin.ModelAdmin):
"""Page dédiée à la gestion des catégories d'approbation par utilisateur.
N'affiche que les utilisateurs ayant can_approve=True sur au moins un contrat.
"""
list_display = ("user", "display_approve_contracts", "display_approval_categories")
search_fields = ("user__username",)
filter_horizontal = ("approval_categories",)
ordering = ("user__username",)
fieldsets = (
(None, {
'fields': ('user',),
}),
("Catégories d'approbation", {
'fields': ('approval_categories',),
'description': (
"Catégories d'assets pour lesquelles cet utilisateur peut approuver des interventions. "
"Si aucune catégorie n'est sélectionnée, aucun filtre de catégorie n'est appliqué "
"(toutes les interventions des contrats concernés sont visibles)."
),
}),
)
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.filter(usercontractaccess__can_approve=True)
.distinct()
)
def display_approve_contracts(self, obj):
contracts = obj.usercontractaccess_set.filter(can_approve=True).select_related('contract')
return ", ".join(uca.contract.contract_number for uca in contracts) or "—"
display_approve_contracts.short_description = "Contrats (can_approve)"
def display_approval_categories(self, obj):
cats = obj.approval_categories.all()
if not cats:
return "— (toutes)"
return ", ".join(cat.get_name() for cat in cats)
display_approval_categories.short_description = "Catégories filtrées"
# ---------------------------------------------------------------------------
# AssetTypePermissionConfig admin
# ---------------------------------------------------------------------------
@admin.register(AssetTypePermissionConfig)
class AssetTypePermissionConfigAdmin(admin.ModelAdmin):
"""
Configure les droits explicites requis pour chaque type d'asset.
"""
list_display = ('content_type', 'model_verbose', 'requires_explicit_permissions', 'type_page_link')
list_editable = ('requires_explicit_permissions',)
list_filter = ('requires_explicit_permissions',)
search_fields = ('content_type__model', 'content_type__app_label')
ordering = ('content_type__model',)
def model_verbose(self, obj):
mc = obj.content_type.model_class()
if mc:
return mc._meta.verbose_name
return obj.content_type.model
model_verbose.short_description = "Nom verbose"
def type_page_link(self, obj):
url = f"/assets/permissions/type/{obj.content_type_id}/"
return format_html('<a href="{}" target="_blank">Voir les accès</a>', url)
type_page_link.short_description = "Page dédiée"
@admin.register(UserAssetAccess)
class UserAssetAccessAdmin(admin.ModelAdmin):
"""
Vue globale des accès spécifiques par instance.
"""
list_display = ('user_config', 'content_type', 'object_id', 'can_view', 'can_edit', 'can_delete', 'has_field_override', 'has_visible_field_override', 'object_link')
list_filter = ('content_type', 'can_view', 'can_edit', 'can_delete')
search_fields = ('user_config__user__username', 'content_type__model')
raw_id_fields = ['user_config']
ordering = ('user_config__user__username', 'content_type__model', 'object_id')
def has_field_override(self, obj):
return bool(obj.editable_fields)
has_field_override.boolean = True
has_field_override.short_description = "Champs modif. overridés"
def has_visible_field_override(self, obj):
return bool(obj.visible_fields)
has_visible_field_override.boolean = True
has_visible_field_override.short_description = "Champs visibles overridés"
def object_link(self, obj):
model_class = obj.content_type.model_class()
if model_class is None:
return f"#{obj.object_id}"
try:
instance = model_class.objects.get(pk=obj.object_id)
if hasattr(instance, 'get_absolute_url'):
url = instance.get_absolute_url()
return format_html('<a href="{}" target="_blank">{}</a>', url, str(instance))
return str(instance)
except model_class.DoesNotExist:
return f"#{obj.object_id} (supprimé)"
object_link.short_description = "Objet"
@admin.register(UserAssetTypeAccess)
class UserAssetTypeAccessAdmin(admin.ModelAdmin):
"""
Vue globale des accès par type d'asset (s'appliquent à tous les objets du type).
"""
list_display = ('user_config', 'content_type', 'can_view', 'can_edit', 'can_delete', 'has_field_override', 'has_visible_field_override', 'type_page_link')
list_filter = ('content_type', 'can_view', 'can_edit', 'can_delete')
search_fields = ('user_config__user__username', 'content_type__model')
raw_id_fields = ['user_config']
ordering = ('user_config__user__username', 'content_type__model')
def has_field_override(self, obj):
return bool(obj.editable_fields)
has_field_override.boolean = True
has_field_override.short_description = "Champs modif. overridés"
def has_visible_field_override(self, obj):
return bool(obj.visible_fields)
has_visible_field_override.boolean = True
has_visible_field_override.short_description = "Champs visibles overridés"
def type_page_link(self, obj):
url = f"/assets/permissions/type/{obj.content_type_id}/"
return format_html('<a href="{}" target="_blank">Page du type</a>', url)
type_page_link.short_description = "Page dédiée"
@admin.register(MapBackground)
class MapBackgroundAdmin(admin.ModelAdmin):
list_display = ("name_fr", "name_nl", "region_badge", "category_badge", "service_type", "order", "is_active", "is_default")
list_filter = ("region", "category", "service_type", "is_active", "is_default")
list_editable = ("order", "is_active", "is_default")
search_fields = ("name_fr", "name_nl", "identifier", "attribution", "url")
ordering = ("order", "name_fr")
@admin.display(description="Région")
def region_badge(self, obj):
colors = {
'WAL': '#dc2626',
'BRU': '#2563eb',
'VLG': '#d97706',
'ALL': '#4b5563',
}
bg = colors.get(obj.region, '#4b5563')
return format_html(
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;">{}</span>',
bg,
obj.get_region_display()
)
@admin.display(description="Catégorie")
def category_badge(self, obj):
return obj.get_category_display()