491 lines
20 KiB
Python
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',)
|