loko/streetup/contracts/admin.py
2026-07-22 14:48:40 +02:00

360 lines
14 KiB
Python

import io
from django import forms
from django.contrib import admin, messages
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.urls import path, reverse
from .models import (
Company,
Contract,
ContractPost,
ContractOrder,
ContractOrderDocument,
CompanyTeam,
CompanyMember,
CompanyMemberHistory,
ContractOrderQuote,
ContractOrderQuoteItem,
ContractOrderProgressState,
ContractOrderProgressStateItem,
MaintainType,
ClaimDeclarationAttachment,
UserCompanyTeamAccess,
DirectOrderPost,
)
# ---------------------------------------------------------------------------
# Formulaire d'import de postes
# ---------------------------------------------------------------------------
class ImportContractPostsForm(forms.Form):
contract = forms.ModelChoiceField(
queryset=None, # initialisé dans __init__
label="Contrat",
empty_label="-- Sélectionner un contrat --",
)
excel_file = forms.FileField(
label="Fichier Excel (.xlsx / .xlsm)",
help_text="Fichier inventaire des postes (format SPRB-GOB Fonteinen).",
)
update_existing = forms.BooleanField(
required=False,
label="Mettre à jour les postes existants",
help_text=(
"Si coché, les postes déjà présents (même numéro de poste) seront"
" mis à jour avec les valeurs du fichier Excel."
" Sinon, ils sont ignorés."
),
)
dry_run = forms.BooleanField(
required=False,
label="Mode simulation (dry-run)",
help_text="Affiche le résultat sans écrire quoi que ce soit en base de données.",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
from .models import Contract
self.fields['contract'].queryset = Contract.objects.order_by('contract_number')
# ---------------------------------------------------------------------------
# Admins
# ---------------------------------------------------------------------------
def _get_pause_reason_inline():
from interventions.models import ContractPauseReasonAssignment
class ContractPauseReasonAssignmentInline(admin.TabularInline):
model = ContractPauseReasonAssignment
extra = 0
fields = ('pause_reason', 'assigned_member')
verbose_name = "Réassignation automatique sur pause"
verbose_name_plural = "Réassignations automatiques sur pause"
return ContractPauseReasonAssignmentInline
def _get_symptom_autoclose_inline():
from interventions.models import SymptomContractAutoClose
class SymptomAutoCloseInline(admin.TabularInline):
model = SymptomContractAutoClose
extra = 0
fields = ('symptom', 'delay_days')
verbose_name = "Règle de clôture automatique"
verbose_name_plural = "Règles de clôture automatique (par symptôme)"
return SymptomAutoCloseInline
@admin.register(Company)
class CompanyAdmin(admin.ModelAdmin):
list_display = ('name', 'contact_email', 'phone_number')
@admin.register(Contract)
class ContractAdmin(admin.ModelAdmin):
list_display = ('contract_number', 'description', 'company', 'start_date', 'end_date', 'is_active', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'thematics_list')
search_fields = ('contract_number', 'company__name', 'description', 'contact_phone')
list_filter = ('is_active', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'send_daily_summary')
filter_horizontal = ('thematics',)
def get_inlines(self, request, obj=None):
return [_get_pause_reason_inline(), _get_symptom_autoclose_inline()] + list(super().get_inlines(request, obj))
fieldsets = (
(None, {
'fields': ('company', 'contract_number', 'description', 'start_date', 'end_date', 'is_active', 'committed_amount', 'internal_link', 'executive_officer', 'contact_phone', 'signatory_name', 'signatory_role', 'thematics'),
}),
('Notifications email', {
'fields': ('send_email_notifications', 'notification_emails', 'cc_notification_emails', 'send_daily_summary', 'daily_summary_hour', 'email_only_if_urgent'),
'description': (
"Si \"Envoi groupé journalier\" est activé, les fiches d'intervention ne sont PAS envoyées "
"individuellement. Elles sont regroupées et envoyées une fois par jour via la commande "
"\"send_daily_intervention_summary\" (à planifier via les tâches planifiées)."
),
}),
('Options de workflow', {
'fields': ('needs_approval', 'needs_checking', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'warn_if_no_prestation_on_processed'),
}),
)
def thematics_list(self, obj):
return ", ".join([thematic.name for thematic in obj.thematics.all()])
thematics_list.short_description = 'Thématiques'
@admin.register(ContractPost)
class ContractPostAdmin(admin.ModelAdmin):
list_display = ('contract', 'order_number', 'code', 'description_fr', 'unit_price', 'post_type', 'is_coefficient_multipliable')
list_filter = ('contract',)
search_fields = ('code', 'description_fr', 'contract__contract_number')
change_list_template = 'admin/contracts/contractpost/change_list.html'
def get_urls(self):
urls = super().get_urls()
custom = [
path(
'import-posts/',
self.admin_site.admin_view(self.import_posts_view),
name='contracts_contractpost_import_posts',
),
]
return custom + urls
def import_posts_view(self, request):
from .utils_import_posts import import_contract_posts
context = {
**self.admin_site.each_context(request),
'title': 'Importer / mettre à jour des postes (Excel)',
'form': ImportContractPostsForm(),
'opts': self.model._meta,
'stats': None,
'log_lines': None,
'dry_run': False,
}
if request.method == 'POST':
form = ImportContractPostsForm(request.POST, request.FILES)
context['form'] = form
if form.is_valid():
contract = form.cleaned_data['contract']
excel_file = form.cleaned_data['excel_file']
update_existing = form.cleaned_data['update_existing']
dry_run = form.cleaned_data['dry_run']
log_lines = []
# openpyxl accepte un objet bytes-like ; on wrappe le fichier
file_bytes = io.BytesIO(excel_file.read())
stats = import_contract_posts(
contract=contract,
excel_source=file_bytes,
dry_run=dry_run,
update_existing=update_existing,
log=log_lines.append,
)
context['stats'] = stats
context['log_lines'] = log_lines
context['dry_run'] = dry_run
if stats['errors']:
messages.error(request, f"{stats['errors']} erreur(s) lors de l'import.")
elif not dry_run:
messages.success(
request,
f"{stats['created']} poste(s) créé(s), "
f"{stats['updated']} mis à jour pour le contrat "
f"« {contract.contract_number} »."
)
else:
messages.info(
request,
f"[DRY-RUN] {stats['created']} poste(s) seraient créés, "
f"{stats['updated']} mis à jour."
)
return render(request, 'admin/contracts/contractpost/import_posts.html', context)
@admin.register(ContractOrder)
class ContractOrderAdmin(admin.ModelAdmin):
list_display = ('contract', 'order_code', 'description', 'order_date')
search_fields = ('order_code', 'contract__contract_number')
list_filter = ('contract',)
@admin.register(MaintainType)
class MaintainTypeAdmin(admin.ModelAdmin):
list_display = ('code', 'name_fr', 'name_nl')
search_fields = ('code', 'name_fr', 'name_nl')
ordering = ('code',)
class CompanyTeamMemberInline(admin.TabularInline):
model = CompanyMember.teams.through
extra = 0
verbose_name = "Membre"
verbose_name_plural = "Membres"
@admin.register(CompanyTeam)
class CompanyTeamAdmin(admin.ModelAdmin):
list_display = ('name', 'company', 'thematics_list', 'members_count', 'can_generate_daily_report', 'photos_required', 'restrict_today_interventions_visibility', 'visibility_start_hour', 'restrict_to_assigned_interventions')
search_fields = ('name', 'company__name')
list_filter = ('company', 'can_generate_daily_report', 'photos_required', 'restrict_today_interventions_visibility', 'restrict_to_assigned_interventions')
filter_horizontal = ('thematics',)
inlines = [CompanyTeamMemberInline]
def thematics_list(self, obj):
return ", ".join([thematic.name for thematic in obj.thematics.all()])
thematics_list.short_description = 'Thématiques'
def members_count(self, obj):
return obj.members.count()
members_count.short_description = 'Nb membres'
@admin.register(CompanyMember)
class CompanyMemberAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'display_teams', 'visible_maintain_types_list')
search_fields = ('name', 'user__username', 'teams__name')
list_filter = ('teams', 'visible_maintain_types')
filter_horizontal = ('teams', 'visible_maintain_types')
def display_teams(self, obj):
return ", ".join([t.name for t in obj.teams.all()]) or "—"
display_teams.short_description = 'Équipes'
def visible_maintain_types_list(self, obj):
types = obj.visible_maintain_types.all()
if not types:
return "Tous"
return ", ".join([t.name_fr for t in types])
visible_maintain_types_list.short_description = 'Types visibles'
class ContractOrderQuoteItemInline(admin.TabularInline):
model = ContractOrderQuoteItem
extra = 0
autocomplete_fields = ('contract_post',)
def get_queryset(self, request):
return super().get_queryset(request).select_related('contract_post__contract')
@admin.register(ContractOrderQuote)
class ContractOrderQuoteAdmin(admin.ModelAdmin):
list_display = ('order', 'created_by', 'created_at')
search_fields = ('order__order_code', 'order__contract__contract_number')
autocomplete_fields = ('order', 'created_by')
inlines = [ContractOrderQuoteItemInline]
class ContractOrderProgressStateItemInline(admin.TabularInline):
model = ContractOrderProgressStateItem
extra = 0
readonly_fields = ('contract_post', 'quantity')
@admin.register(ContractOrderProgressState)
class ContractOrderProgressStateAdmin(admin.ModelAdmin):
list_display = ('order', 'created_at', 'created_by')
search_fields = ('order__order_code', 'order__contract__contract_number')
list_filter = ('created_at',)
readonly_fields = ('created_at', 'created_by', 'order')
inlines = [ContractOrderProgressStateItemInline]
class ClaimDeclarationAttachmentInline(admin.TabularInline):
model = ClaimDeclarationAttachment
extra = 0
readonly_fields = ('uploaded_at', 'uploaded_by')
@admin.register(ContractOrderDocument)
class ContractOrderDocumentAdmin(admin.ModelAdmin):
list_display = ('title', 'order', 'document_type', 'uploaded_at')
search_fields = ('title', 'order__order_code')
list_filter = ('document_type',)
inlines = [ClaimDeclarationAttachmentInline]
@admin.register(ClaimDeclarationAttachment)
class ClaimDeclarationAttachmentAdmin(admin.ModelAdmin):
list_display = ('title', 'claim_document', 'uploaded_at', 'uploaded_by')
search_fields = ('title', 'claim_document__title', 'claim_document__order__order_code')
list_filter = ('uploaded_at',)
readonly_fields = ('uploaded_at',)
# ---------------------------------------------------------------------------
# Accès utilisateur aux équipes
# ---------------------------------------------------------------------------
@admin.register(UserCompanyTeamAccess)
class UserCompanyTeamAccessAdmin(admin.ModelAdmin):
list_display = ('user_config_user', 'team', 'can_view', 'can_edit')
list_filter = ('can_view', 'can_edit', 'team__company')
search_fields = ('user_config__user__username', 'user_config__user__first_name', 'user_config__user__last_name', 'team__name')
autocomplete_fields = ('team',)
def user_config_user(self, obj):
return obj.user_config.user.username
user_config_user.short_description = 'Utilisateur'
user_config_user.admin_order_field = 'user_config__user__username'
# ---------------------------------------------------------------------------
# Historique des appartenances aux équipes
# ---------------------------------------------------------------------------
@admin.register(CompanyMemberHistory)
class CompanyMemberHistoryAdmin(admin.ModelAdmin):
list_display = ('member', 'team', 'join_date', 'leave_date', 'changed_by')
list_filter = ('team__company', 'team')
search_fields = ('member__name', 'team__name')
readonly_fields = ('member', 'team', 'join_date', 'leave_date', 'changed_by')
date_hierarchy = 'join_date'
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
# ---------------------------------------------------------------------------
# Commandes directes
# ---------------------------------------------------------------------------
@admin.register(DirectOrderPost)
class DirectOrderPostAdmin(admin.ModelAdmin):
list_display = ('order', 'contract_post', 'quantity', 'control_status', 'created_by', 'created_at')
list_filter = ('control_status', 'order__contract')
search_fields = ('order__order_code', 'contract_post__code')
readonly_fields = ('created_at', 'created_by', 'validated_at', 'validated_by')
autocomplete_fields = ('contract_post',)