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//", 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( '{}', 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('Anonyme') 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( '{}…', 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( '{}', 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( '
{}
', 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( '
{}
', formatted ) except Exception: return format_html('
{}
', 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 = """\ """ 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('Enregistrez d\'abord pour configurer les permissions par statut.') 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 = [''] for status in INTERVENTION_STATUS_ORDER: label = choices_dict.get(status, status) header_cells.append( f'' f'
{label}
' f'' ) # 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'' f'' ] 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'' f'
' f'' f'
' ) else: cells.append('') row_htmls.append('' + ''.join(cells) + '') html = ( _MATRIX_SCRIPT + '
' + '' + '▶ Permissions par statut (cliquer pour développer)' + '
' + '' + '' + ''.join(header_cells) + '' + '' + ''.join(row_htmls) + '' + '
' ) 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('{}', 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', '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( "/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_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_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('{}', 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('{}', 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( '' 'Dupliquer' '', 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('Voir les accès', 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('{}', 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('Page du type', url) type_page_link.short_description = "Page dédiée"