loko/loko/contracts/admin.py

491 lines
20 KiB
Python

import io
from django import forms
from django.contrib import admin, messages
from django.contrib.gis import admin as gis_admin
from django.utils.translation import gettext_lazy as _
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,
CompanyMemberAbsence,
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"
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "assigned_member":
kwargs["queryset"] = CompanyMember.objects.prefetch_related('teams').order_by('name')
return super().formfield_for_foreignkey(db_field, request, **kwargs)
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
def _get_operation_template_posts_inline():
from interventions.models import ContractOperationTemplatePost, OperationTemplate
from django.db.models import Q
class ContractOperationTemplatePostInline(admin.TabularInline):
model = ContractOperationTemplatePost
extra = 0
fields = ('operation_template', 'contract_post', 'default_quantity', 'comment')
verbose_name = "Poste par défaut (Template d'opérations)"
verbose_name_plural = "Postes par défaut (Templates d'opérations)"
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "contract_post":
contract_id = request.resolver_match.kwargs.get('object_id') if request.resolver_match else None
if contract_id:
kwargs["queryset"] = ContractPost.objects.filter(
contract_id=contract_id
).select_related('contract').order_by('order_number')
else:
kwargs["queryset"] = ContractPost.objects.none()
elif db_field.name == "operation_template":
contract_id = request.resolver_match.kwargs.get('object_id') if request.resolver_match else None
if contract_id:
contract = Contract.objects.prefetch_related('thematics').filter(pk=contract_id).first()
if contract and contract.thematics.exists():
kwargs["queryset"] = OperationTemplate.objects.filter(
Q(thematic__in=contract.thematics.all()) | Q(thematic__isnull=True)
).distinct().order_by('name')
else:
kwargs["queryset"] = OperationTemplate.objects.none()
return super().formfield_for_foreignkey(db_field, request, **kwargs)
return ContractOperationTemplatePostInline
@admin.register(Company)
class CompanyAdmin(admin.ModelAdmin):
list_display = ('name', 'contact_email', 'phone_number')
@admin.register(Contract)
class ContractAdmin(gis_admin.GISModelAdmin):
list_display = ('contract_number', 'description', 'company', 'municipalities_list', 'has_geom', 'color', 'is_blocked', 'is_active', 'start_date', 'end_date', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'thematics_list')
search_fields = ('contract_number', 'company__name', 'description', 'contact_phone')
list_filter = ('is_blocked', 'is_active', 'municipalities', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'send_daily_summary')
filter_horizontal = ('thematics', 'municipalities')
def get_queryset(self, request):
return super().get_queryset(request).select_related('company', 'executive_officer').prefetch_related('municipalities', 'thematics')
def get_inlines(self, request, obj=None):
inlines = [_get_pause_reason_inline(), _get_symptom_autoclose_inline()]
if obj:
inlines.append(_get_operation_template_posts_inline())
return inlines + list(super().get_inlines(request, obj))
fieldsets = (
(None, {
'fields': ('company', 'municipalities', 'contract_number', 'description', 'start_date', 'end_date', 'is_active', 'committed_amount', 'internal_link', 'executive_officer', 'contact_phone', 'signatory_name', 'signatory_role', 'thematics'),
}),
('Emprise géographique (Lot)', {
'fields': ('geom', 'color'),
'description': (
"Emprise géographique du contrat / lot (Multi-polygone) et couleur d'affichage sur les cartes."
),
}),
('Blocage budgétaire / Suspension', {
'fields': ('is_blocked', 'blocked_reason', 'blocked_at'),
'description': (
"Permet de bloquer temporairement l'envoi et la planification des interventions "
"vers le prestataire pour des raisons budgétaires."
),
}),
('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', 'approval_except_urgent', 'needs_checking', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'warn_if_no_prestation_on_processed', 'allow_team_members_edit', 'auto_assign_control_team'),
}),
)
def has_geom(self, obj):
return bool(obj.geom)
has_geom.boolean = True
has_geom.short_description = _("Zone définie")
class Media:
css = {
'all': ('contracts/css/contract_admin.css',)
}
js = ('contracts/js/contract_admin.js',)
def municipalities_list(self, obj):
return ", ".join([m.name for m in obj.municipalities.all()])
municipalities_list.short_description = 'Communes'
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"
class CompanyTeamAdminForm(forms.ModelForm):
operation_types = forms.MultipleChoiceField(
choices=CompanyTeam.OPERATION_TYPES,
widget=forms.CheckboxSelectMultiple,
required=False,
label=_("Types d'opérations gérées"),
)
class Meta:
model = CompanyTeam
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
self.initial['operation_types'] = self.instance.operation_types or []
def clean_operation_types(self):
return list(self.cleaned_data.get('operation_types') or [])
@admin.register(CompanyTeam)
class CompanyTeamAdmin(gis_admin.GISModelAdmin):
form = CompanyTeamAdminForm
list_display = (
'name', 'company', 'thematics_list', 'maintain_types_list', 'operation_types_list', 'has_geom',
'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', 'maintain_types', 'can_generate_daily_report', 'photos_required',
'restrict_today_interventions_visibility', 'restrict_to_assigned_interventions'
)
filter_horizontal = ('thematics', 'asset_categories', 'maintain_types')
inlines = [CompanyTeamMemberInline]
def thematics_list(self, obj):
return ", ".join([thematic.name for thematic in obj.thematics.all()])
thematics_list.short_description = 'Thématiques'
def maintain_types_list(self, obj):
return obj.get_maintain_types_display()
maintain_types_list.short_description = _("Types de maintenance")
def operation_types_list(self, obj):
return obj.get_operation_types_display() or "—"
operation_types_list.short_description = _("Opérations gérées")
def has_geom(self, obj):
return bool(obj.geom)
has_geom.boolean = True
has_geom.short_description = _("Zone définie")
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', 'can_tag')
list_filter = ('can_view', 'can_edit', 'can_tag', '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', 'changed_by')
date_hierarchy = 'join_date'
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return True
@admin.register(CompanyMemberAbsence)
class CompanyMemberAbsenceAdmin(admin.ModelAdmin):
list_display = ('member', 'date', 'reason', 'created_by', 'created_at')
list_filter = ('reason', 'date')
search_fields = ('member__name', 'comment')
date_hierarchy = 'date'
# ---------------------------------------------------------------------------
# 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',)