1214 lines
54 KiB
Python
1214 lines
54 KiB
Python
from django.contrib import 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 (
|
||
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 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(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="{}">{}…</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',
|
||
'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']
|
||
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')
|
||
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", "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."
|
||
}),
|
||
('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é)."""
|
||
# Champs scalaires
|
||
target.is_intern = source.is_intern
|
||
target.default_thematic = source.default_thematic
|
||
target.company = source.company
|
||
target.limit_assets_to_contracts = source.limit_assets_to_contracts
|
||
target.limit_interventions_to_contracts = source.limit_interventions_to_contracts
|
||
target.limit_shortcuts_to_contracts = source.limit_shortcuts_to_contracts
|
||
target.generic_email = source.generic_email
|
||
target.hide_intervention_map_by_default = source.hide_intervention_map_by_default
|
||
target.auto_logout_hours = source.auto_logout_hours
|
||
target.intervention_list_layout = source.intervention_list_layout
|
||
target.assets_list_layout = source.assets_list_layout
|
||
target.can_add_unique_tag_documents = source.can_add_unique_tag_documents
|
||
target.esign_can_edit = source.esign_can_edit
|
||
target.esign_can_manage_rcp = source.esign_can_manage_rcp
|
||
target.esign_can_validate_rcp = source.esign_can_validate_rcp
|
||
target.panoramax_can_upload = source.panoramax_can_upload
|
||
target.save()
|
||
# M2M directs
|
||
target.roles.set(source.roles.all())
|
||
target.accessible_views.set(source.accessible_views.all())
|
||
target.esign_organizations.set(source.esign_organizations.all())
|
||
# Thématiques
|
||
target.userthematics.all().delete()
|
||
for ut in source.userthematics.prefetch_related('status_permissions').all():
|
||
new_ut = UserThematics.objects.create(
|
||
user_config=target,
|
||
thematic=ut.thematic,
|
||
can_view_assets=ut.can_view_assets,
|
||
can_edit_assets=ut.can_edit_assets,
|
||
can_validate_assets=ut.can_validate_assets,
|
||
can_inspect=ut.can_inspect,
|
||
can_view_interventions=ut.can_view_interventions,
|
||
can_edit_interventions=ut.can_edit_interventions,
|
||
can_edit_locations=ut.can_edit_locations,
|
||
can_process_observations=ut.can_process_observations,
|
||
can_view_projects=ut.can_view_projects,
|
||
can_edit_projects=ut.can_edit_projects,
|
||
)
|
||
UserThematicStatusPermission.objects.bulk_create([
|
||
UserThematicStatusPermission(
|
||
user_thematic=new_ut,
|
||
status=sp.status,
|
||
can_view=sp.can_view,
|
||
can_edit=sp.can_edit,
|
||
can_change_status_to=sp.can_change_status_to,
|
||
)
|
||
for sp in ut.status_permissions.all()
|
||
])
|
||
# Contrats
|
||
target.usercontractaccess_set.all().delete()
|
||
for uc in source.usercontractaccess_set.prefetch_related('status_permissions').all():
|
||
new_uc = UserContractAccess.objects.create(
|
||
user_config=target,
|
||
contract=uc.contract,
|
||
can_view_posts=uc.can_view_posts,
|
||
can_view_prices=uc.can_view_prices,
|
||
can_view_interventions=uc.can_view_interventions,
|
||
can_view_assets=uc.can_view_assets,
|
||
can_create_interventions=uc.can_create_interventions,
|
||
can_check_interventions=uc.can_check_interventions,
|
||
can_approve=uc.can_approve,
|
||
)
|
||
UserContractStatusPermission.objects.bulk_create([
|
||
UserContractStatusPermission(
|
||
user_contract=new_uc,
|
||
status=sp.status,
|
||
can_view=sp.can_view,
|
||
can_edit=sp.can_edit,
|
||
can_change_status_to=sp.can_change_status_to,
|
||
)
|
||
for sp in uc.status_permissions.all()
|
||
])
|
||
# Accès aux entrepôts
|
||
target.userwarehouses.all().delete()
|
||
for wa in source.userwarehouses.all():
|
||
UserWarehouseAccess.objects.create(
|
||
user_config=target,
|
||
warehouse=wa.warehouse,
|
||
can_view=wa.can_view,
|
||
can_manage=wa.can_manage,
|
||
can_administer=wa.can_administer,
|
||
can_manage_preparation_orders=wa.can_manage_preparation_orders,
|
||
can_manage_purchase_orders=wa.can_manage_purchase_orders,
|
||
can_validate_inventory=wa.can_validate_inventory,
|
||
is_default=wa.is_default,
|
||
)
|
||
# Accès aux emplacements
|
||
target.userlocations.all().delete()
|
||
for la in source.userlocations.all():
|
||
UserWarehouseLocationAccess.objects.create(
|
||
user_config=target,
|
||
warehouse_location=la.warehouse_location,
|
||
can_perform_inventory=la.can_perform_inventory,
|
||
can_validate_inventory=la.can_validate_inventory,
|
||
)
|
||
# Accès aux équipes
|
||
target.team_accesses.all().delete()
|
||
for ta in source.team_accesses.all():
|
||
UserCompanyTeamAccess.objects.create(
|
||
user_config=target,
|
||
team=ta.team,
|
||
can_view=ta.can_view,
|
||
can_edit=ta.can_edit,
|
||
)
|
||
|
||
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"]
|
||
# Créer la nouvelle config
|
||
new_config = UserConfig.objects.create(
|
||
user=new_user,
|
||
is_intern=orig_config.is_intern,
|
||
default_thematic=orig_config.default_thematic,
|
||
company=orig_config.company,
|
||
limit_assets_to_contracts=orig_config.limit_assets_to_contracts,
|
||
limit_interventions_to_contracts=orig_config.limit_interventions_to_contracts,
|
||
limit_shortcuts_to_contracts=orig_config.limit_shortcuts_to_contracts,
|
||
generic_email=orig_config.generic_email,
|
||
hide_intervention_map_by_default=orig_config.hide_intervention_map_by_default,
|
||
auto_logout_hours=orig_config.auto_logout_hours,
|
||
esign_can_edit=orig_config.esign_can_edit,
|
||
esign_can_manage_rcp=orig_config.esign_can_manage_rcp,
|
||
esign_can_validate_rcp=orig_config.esign_can_validate_rcp,
|
||
)
|
||
# Copier les rôles
|
||
new_config.roles.set(orig_config.roles.all())
|
||
# Copier les linked_configs
|
||
new_config.linked_configs.set(orig_config.linked_configs.all())
|
||
# Copier les vues accessibles
|
||
new_config.accessible_views.set(orig_config.accessible_views.all())
|
||
# Copier les gestionnaires eSign
|
||
new_config.esign_organizations.set(orig_config.esign_organizations.all())
|
||
# Copier les thématiques (UserThematics + permissions par statut)
|
||
for ut in orig_config.userthematics.prefetch_related('status_permissions').all():
|
||
new_ut = UserThematics.objects.create(
|
||
user_config=new_config,
|
||
thematic=ut.thematic,
|
||
can_view_assets=ut.can_view_assets,
|
||
can_edit_assets=ut.can_edit_assets,
|
||
can_validate_assets=ut.can_validate_assets,
|
||
can_inspect=ut.can_inspect,
|
||
can_view_interventions=ut.can_view_interventions,
|
||
can_edit_interventions=ut.can_edit_interventions,
|
||
can_edit_locations=ut.can_edit_locations,
|
||
can_process_observations=ut.can_process_observations,
|
||
can_view_projects=ut.can_view_projects,
|
||
can_edit_projects=ut.can_edit_projects,
|
||
)
|
||
UserThematicStatusPermission.objects.bulk_create([
|
||
UserThematicStatusPermission(
|
||
user_thematic=new_ut,
|
||
status=sp.status,
|
||
can_view=sp.can_view,
|
||
can_edit=sp.can_edit,
|
||
can_change_status_to=sp.can_change_status_to,
|
||
)
|
||
for sp in ut.status_permissions.all()
|
||
])
|
||
# Copier les contrats (UserContractAccess + permissions par statut)
|
||
for uc in orig_config.usercontractaccess_set.prefetch_related('status_permissions').all():
|
||
new_uc = UserContractAccess.objects.create(
|
||
user_config=new_config,
|
||
contract=uc.contract,
|
||
can_view_posts=uc.can_view_posts,
|
||
can_view_prices=uc.can_view_prices,
|
||
can_view_interventions=uc.can_view_interventions,
|
||
can_view_assets=uc.can_view_assets,
|
||
can_create_interventions=uc.can_create_interventions,
|
||
can_check_interventions=uc.can_check_interventions,
|
||
can_approve=uc.can_approve,
|
||
)
|
||
UserContractStatusPermission.objects.bulk_create([
|
||
UserContractStatusPermission(
|
||
user_contract=new_uc,
|
||
status=sp.status,
|
||
can_view=sp.can_view,
|
||
can_edit=sp.can_edit,
|
||
can_change_status_to=sp.can_change_status_to,
|
||
)
|
||
for sp in uc.status_permissions.all()
|
||
])
|
||
# Copier les accès aux entrepôts (UserWarehouseAccess)
|
||
for wa in orig_config.userwarehouses.all():
|
||
UserWarehouseAccess.objects.create(
|
||
user_config=new_config,
|
||
warehouse=wa.warehouse,
|
||
can_view=wa.can_view,
|
||
can_manage=wa.can_manage,
|
||
can_administer=wa.can_administer,
|
||
can_manage_preparation_orders=wa.can_manage_preparation_orders,
|
||
can_manage_purchase_orders=wa.can_manage_purchase_orders,
|
||
can_validate_inventory=wa.can_validate_inventory,
|
||
is_default=wa.is_default,
|
||
)
|
||
# Copier les accès aux emplacements (UserWarehouseLocationAccess)
|
||
for la in orig_config.userlocations.all():
|
||
UserWarehouseLocationAccess.objects.create(
|
||
user_config=new_config,
|
||
warehouse_location=la.warehouse_location,
|
||
can_perform_inventory=la.can_perform_inventory,
|
||
can_validate_inventory=la.can_validate_inventory,
|
||
)
|
||
# Copier les accès aux équipes (UserCompanyTeamAccess)
|
||
for ta in orig_config.team_accesses.all():
|
||
UserCompanyTeamAccess.objects.create(
|
||
user_config=new_config,
|
||
team=ta.team,
|
||
can_view=ta.can_view,
|
||
can_edit=ta.can_edit,
|
||
)
|
||
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"
|