1333 lines
54 KiB
Python
1333 lines
54 KiB
Python
from django.shortcuts import render, redirect, get_object_or_404
|
||
from django.contrib import messages
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.views.decorators.http import require_GET, require_POST
|
||
from django.http import JsonResponse, FileResponse, Http404
|
||
from django.core.exceptions import PermissionDenied
|
||
from django.urls import reverse
|
||
from django.utils.encoding import smart_str
|
||
from django.utils.translation import get_language, gettext as _, ngettext
|
||
from django.db.models import F, Q
|
||
from django.db.models.functions import Cast
|
||
from django.db.models import CharField
|
||
from django.contrib.gis.db.models.functions import AsGeoJSON, Transform
|
||
|
||
import json
|
||
import os
|
||
from decimal import Decimal, InvalidOperation
|
||
from urllib.parse import urlencode
|
||
|
||
from contracts.models import (
|
||
Contract,
|
||
ContractPost,
|
||
ContractOrder,
|
||
ContractOrderDocument,
|
||
ContractOrderQuote,
|
||
ClaimDeclarationAttachment,
|
||
ContractOrderAttachment,
|
||
DirectOrderPost,
|
||
DIRECT_ORDER_POST_STATUS_CHOICES,
|
||
)
|
||
from contracts.forms import ContractOrderForm, ContractOrderQuoteQuantityForm, ClaimDeclarationAttachmentForm, ContractOrderAttachmentForm
|
||
from contracts.permissions import get_allowed_contracts_for_user, contract_view_required, order_view_required, user_can_generate_order_documents
|
||
from contracts.permissions import user_can_add_order, user_can_add_order_for_contract, user_has_access_to_contract, user_has_access_to_contract_posts, user_has_access_to_contract_order, user_can_manage_direct_order_posts, user_can_validate_direct_order_posts
|
||
from contracts.permissions import user_can_manage_quote_for_contract
|
||
from contracts.views_pdf_reports import (
|
||
preview_claim_declaration_pdf,
|
||
preview_state_of_works_pdf,
|
||
preview_order_quote_pdf,
|
||
generate_documents_for_order,
|
||
)
|
||
|
||
from common.models import UserConfig, UserThematics
|
||
from common.private_files.helpers import build_private_url
|
||
from interventions.permissions import can_add_intervention
|
||
|
||
|
||
def contracts_index(request):
|
||
contracts = get_allowed_contracts_for_user(request.user)
|
||
|
||
# Filtrage par statut(s)
|
||
status_filters = request.GET.getlist('status')
|
||
if status_filters == []:
|
||
# Par défaut, afficher les commandes en 'pending' et 'sent'
|
||
status_filters = ['pending', 'sent', 'completed', 'invoiced']
|
||
|
||
orders = orders = ContractOrder.objects.filter(contract__in=contracts, order_status__in=status_filters)
|
||
|
||
# Filtrage par date
|
||
date_from = request.GET.get('date_from', '')
|
||
if date_from:
|
||
orders = orders.filter(order_date__gte=date_from)
|
||
|
||
date_to = request.GET.get('date_to', '')
|
||
if date_to:
|
||
orders = orders.filter(order_date__lte=date_to)
|
||
|
||
# Tri par date décroissante
|
||
orders = orders.order_by('-order_date')
|
||
|
||
context = {
|
||
'contracts': contracts,
|
||
'orders': orders,
|
||
'is_add_order_allowed': user_can_add_order(request.user),
|
||
'selected_statuses': status_filters, # Passer les statuts sélectionnés au template
|
||
}
|
||
return render(request, "contracts/contracts_index.html", context)
|
||
|
||
|
||
@contract_view_required
|
||
def contracts_detail(request, contract_id):
|
||
contract = Contract.objects.get(id=contract_id)
|
||
posts = ContractPost.objects.filter(contract=contract)
|
||
|
||
# Normalize numeric values to Decimal to avoid float/Decimal mixing errors
|
||
total = contract.committed_amount
|
||
if total is None:
|
||
total = Decimal('0')
|
||
elif not isinstance(total, Decimal):
|
||
total = Decimal(str(total))
|
||
|
||
realized_raw = contract.get_total_realized_amount() or 0
|
||
validated_raw = contract.get_total_validated_amount() or 0
|
||
realized = realized_raw if isinstance(realized_raw, Decimal) else Decimal(str(realized_raw))
|
||
validated = validated_raw if isinstance(validated_raw, Decimal) else Decimal(str(validated_raw))
|
||
|
||
contract.realized_percent = (realized / total * Decimal('100')) if total else Decimal('100')
|
||
# Use Decimal('1') in fallback max to keep types consistent
|
||
contract.validated_percent = (validated / total * Decimal('100')) if total else (validated / max(realized, validated, Decimal('1')) * Decimal('100'))
|
||
|
||
is_intern = UserConfig.objects.filter(user=request.user, is_intern=True).exists()
|
||
|
||
context = {
|
||
'contract': contract,
|
||
'posts': posts,
|
||
'is_intern': is_intern,
|
||
}
|
||
return render(request, "contracts/contracts_detail.html", context)
|
||
|
||
|
||
@order_view_required
|
||
def contracts_orders_detail(request, order_id):
|
||
order = ContractOrder.objects.select_related('contract').get(id=order_id)
|
||
interventions = order.interventions.all().select_related('assigned_controller', 'assigned_control_team')
|
||
|
||
# Normalize numeric values to Decimal to avoid float/Decimal mixing errors
|
||
total = order.total_amount
|
||
if total is None:
|
||
total = Decimal('0')
|
||
elif not isinstance(total, Decimal):
|
||
total = Decimal(str(total))
|
||
|
||
realized_raw = order.get_total_realized_amount() or 0
|
||
validated_raw = order.get_total_validated_amount() or 0
|
||
realized = realized_raw if isinstance(realized_raw, Decimal) else Decimal(str(realized_raw))
|
||
validated = validated_raw if isinstance(validated_raw, Decimal) else Decimal(str(validated_raw))
|
||
|
||
order.realized_percent = (realized / total * Decimal('100')) if total else Decimal('100')
|
||
order.validated_percent = (validated / total * Decimal('100')) if total else (validated / max(realized, validated, Decimal('1')) * Decimal('100'))
|
||
|
||
private_url_order_file = None
|
||
if order.order_file:
|
||
private_url_order_file = build_private_url("contracts", "ContractOrder", order.id, "order_file")
|
||
|
||
# URLs pour chaque document financier
|
||
docs = []
|
||
for d in order.documents.all().order_by('uploaded_at'):
|
||
d.private_url = build_private_url("contracts", "ContractOrderDocument", d.id, "document")
|
||
docs.append(d)
|
||
|
||
# URLs pour chaque pièce jointe (photos / fichiers génériques)
|
||
attachments = []
|
||
for a in order.attachments.all().order_by('upload_date'):
|
||
a.file_url = build_private_url("contracts", "ContractOrderAttachment", a.id, "file")
|
||
a.thumbnail_url = (
|
||
build_private_url("contracts", "ContractOrderAttachment", a.id, "thumbnail")
|
||
if a.thumbnail
|
||
else a.file_url
|
||
)
|
||
attachments.append(a)
|
||
|
||
quote = getattr(order, "quote", None)
|
||
quote_total = order.get_quote_total_amount()
|
||
|
||
# Récupérer le résumé de consommation avec les états d'avancement
|
||
result = order.get_post_consumption_summary(include_progress_states=True)
|
||
if isinstance(result, tuple):
|
||
consumption_summary, progress_states = result
|
||
else:
|
||
consumption_summary = result
|
||
progress_states = []
|
||
|
||
# Détermination du droit de créer une intervention pour cette commande
|
||
can_add_intervention_for_order = False
|
||
user_config = getattr(request.user, "config", None)
|
||
if user_config is not None:
|
||
# L'utilisateur doit pouvoir voir des interventions sur au moins une thématique
|
||
editable_thematics = UserThematics.objects.filter(
|
||
user_config=user_config,
|
||
can_edit_interventions=True,
|
||
)
|
||
if editable_thematics.exists() and user_has_access_to_contract_order(request.user, order.id):
|
||
can_add_intervention_for_order = True
|
||
|
||
# Préparer les données pour la timeline de statut
|
||
from contracts.models import CONTRACT_ORDER_STATUS_CHOICES
|
||
status_choices = [
|
||
{'key': choice[0], 'label': str(choice[1])}
|
||
for choice in CONTRACT_ORDER_STATUS_CHOICES
|
||
]
|
||
|
||
# Déterminer les statuts accessibles (managers et admins peuvent changer le statut)
|
||
allowed_statuses = []
|
||
if user_config and user_config.roles.filter(name__in=['manager', 'admin']).exists():
|
||
# Inclure 'cancelled' seulement si le statut actuel est 'pending' ou 'sent'
|
||
if order.order_status in ['pending', 'sent']:
|
||
allowed_statuses = [choice[0] for choice in CONTRACT_ORDER_STATUS_CHOICES]
|
||
else:
|
||
allowed_statuses = [choice[0] for choice in CONTRACT_ORDER_STATUS_CHOICES if choice[0] != 'cancelled']
|
||
|
||
# Déterminer les droits de création d'interventions par type
|
||
user_roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
|
||
can_create_preventive = bool(user_roles & {'admin', 'manager', 'controller'})
|
||
if not can_create_preventive and 'external_manager' in user_roles:
|
||
from interventions.permissions import is_external_manager_with_contract_creation_rights
|
||
can_create_preventive = is_external_manager_with_contract_creation_rights(request.user)
|
||
can_create_ameliorative = bool(user_roles & {'admin', 'manager', 'controller', 'external_manager'})
|
||
|
||
context = {
|
||
'order': order,
|
||
'interventions': interventions,
|
||
'is_order_edit_allowed': user_can_add_order_for_contract(request.user, order.contract_id),
|
||
'is_quote_edit_allowed': user_can_manage_quote_for_contract(request.user, order.contract_id),
|
||
'is_generate_documents_allowed': user_can_generate_order_documents(request.user, order),
|
||
'private_url_order_file': private_url_order_file,
|
||
'documents': docs,
|
||
'attachments': attachments,
|
||
'add_attachment_form': ContractOrderAttachmentForm(),
|
||
'quote': quote,
|
||
'quote_total': quote_total,
|
||
'consumption_summary': consumption_summary,
|
||
'progress_states': progress_states,
|
||
'can_add_intervention_for_order': can_add_intervention_for_order,
|
||
'can_create_preventive': can_create_preventive,
|
||
'can_create_ameliorative': can_create_ameliorative,
|
||
'status_choices': status_choices,
|
||
'allowed_statuses': json.dumps(allowed_statuses),
|
||
'can_manage_direct_posts': _user_can_manage_direct_posts(request.user, order),
|
||
'can_validate_direct_posts': _user_can_validate_direct_posts(request.user, order),
|
||
'direct_posts': list(order.direct_posts.select_related('contract_post', 'created_by', 'validated_by')),
|
||
}
|
||
return render(request, "contracts/contracts_orders_detail.html", context)
|
||
|
||
|
||
def _get_allowed_thematics_for_order_project(user, order):
|
||
"""Retourne l'ensemble des thématiques autorisées pour l'utilisateur, croisées
|
||
avec celles du projet de la commande si présent.
|
||
|
||
- L'utilisateur doit avoir des UserThematics(can_edit_interventions=True)
|
||
- Si order.project est défini, on intersecte avec project.thematics
|
||
"""
|
||
user_config = getattr(user, "config", None)
|
||
if not user_config:
|
||
return []
|
||
|
||
editable_links = (
|
||
UserThematics.objects
|
||
.filter(user_config=user_config, can_edit_interventions=True)
|
||
.select_related('thematic')
|
||
)
|
||
user_thematics = {ut.thematic for ut in editable_links}
|
||
|
||
# Si la commande a des projets, on intersecte avec les thématiques de tous les projets
|
||
order_projects = order.projects.all()
|
||
if order_projects.exists():
|
||
# Union de toutes les thématiques des projets
|
||
project_thematics = set()
|
||
for project in order_projects:
|
||
project_thematics.update(project.thematics.all())
|
||
return list(user_thematics & project_thematics)
|
||
|
||
return list(user_thematics)
|
||
|
||
|
||
@order_view_required
|
||
def contracts_orders_add_intervention(request, order_id):
|
||
"""Redirige vers le bon flux de création d'intervention à partir d'une commande.
|
||
|
||
Règles :
|
||
- L'utilisateur doit avoir accès à la commande (user_has_access_to_contract_order).
|
||
- Si la commande est liée à un projet :
|
||
* intervention améliorative (maintain_type=ameliorative)
|
||
* on croise les thématiques éditables avec celles du projet
|
||
* si une seule thématique -> redirection directe vers interventions:add
|
||
* sinon -> passage par l'écran de choix de thématique (restreint).
|
||
- Si la commande n'a pas de projet :
|
||
* on passe par choose_thematic_before_add comme depuis la home,
|
||
en propageant order_id.
|
||
"""
|
||
order = get_object_or_404(ContractOrder, pk=order_id)
|
||
|
||
# Vérification de l'accès à la commande / contrat
|
||
if not user_has_access_to_contract_order(request.user, order_id):
|
||
raise PermissionDenied(_("Vous n'avez pas accès à cette commande."))
|
||
|
||
order_projects = order.projects.all()
|
||
allowed_thematics = _get_allowed_thematics_for_order_project(request.user, order)
|
||
|
||
if not allowed_thematics:
|
||
messages.error(request, _("Vous n'avez pas de thématique d'intervention autorisée pour cette commande."))
|
||
return redirect('contracts:contracts_orders_detail', order_id=order.id)
|
||
|
||
# Si la commande est liée à des projets -> intervention améliorative
|
||
if order_projects.exists():
|
||
# Prendre le premier projet si plusieurs (ou adapter selon la logique métier)
|
||
project = order_projects.first()
|
||
if len(allowed_thematics) == 1:
|
||
thematic = allowed_thematics[0]
|
||
base_url = reverse('interventions:intervention_add', kwargs={'thematic_code': thematic.code})
|
||
params = {
|
||
'maintain_type': 'ameliorative',
|
||
'project_id': project.id,
|
||
'order_id': order.id,
|
||
'redirect_to_order': 'true',
|
||
}
|
||
return redirect(f"{base_url}?{urlencode(params)}")
|
||
|
||
# Plusieurs thématiques possibles : on passe par l'écran de choix,
|
||
# en restreignant les thématiques côté interventions.
|
||
base_url = reverse('interventions:intervention_add_choose_thematic')
|
||
params = {
|
||
'type': 'ameliorative',
|
||
'order_id': order.id,
|
||
'project_id': project.id,
|
||
'redirect_to_order': 'true',
|
||
}
|
||
# On ajoute les codes thématiques autorisées pour filtrage côté vue
|
||
for thematic in allowed_thematics:
|
||
params.setdefault('allowed_thematic', [])
|
||
params['allowed_thematic'].append(thematic.code)
|
||
|
||
# urlencode ne gère pas directement les listes imbriquées avec setdefault,
|
||
# on reconstruit proprement la query string.
|
||
flat_params = []
|
||
for key, value in params.items():
|
||
if isinstance(value, list):
|
||
for v in value:
|
||
flat_params.append((key, str(v)))
|
||
else:
|
||
flat_params.append((key, str(value)))
|
||
|
||
query_string = urlencode(flat_params)
|
||
return redirect(f"{base_url}?{query_string}")
|
||
|
||
# Pas de projet : on laisse l'utilisateur choisir type + thématique comme depuis la home.
|
||
# Si un type est déjà fourni (via la modale réutilisée), on le propage.
|
||
base_url = reverse('interventions:intervention_add_choose_thematic')
|
||
intervention_type = request.GET.get('type')
|
||
|
||
params = {
|
||
'order_id': order.id,
|
||
'redirect_to_order': 'true',
|
||
}
|
||
if intervention_type in ('corrective', 'preventive', 'ameliorative'):
|
||
params['type'] = intervention_type
|
||
|
||
query_string = urlencode(params)
|
||
return redirect(f"{base_url}?{query_string}")
|
||
|
||
|
||
|
||
def contracts_orders_add(request):
|
||
project_id = request.GET.get('project_id') or request.POST.get('project_id')
|
||
|
||
if request.method == 'POST':
|
||
form = ContractOrderForm(request.POST, request.FILES, user=request.user, project_id=project_id)
|
||
if form.is_valid():
|
||
contract_order = form.save(commit=False)
|
||
|
||
# permissions
|
||
if not user_can_add_order_for_contract(request.user, contract_order.contract_id):
|
||
raise PermissionDenied(_("Vous n'avez pas le droit d'ajouter une commande pour ce contrat."))
|
||
|
||
contract_order.ordered_by = request.user # Attribue l'utilisateur connecté
|
||
|
||
contract_order.save()
|
||
|
||
# Associate with project if project_id is provided
|
||
if project_id:
|
||
from projects.models import Project
|
||
try:
|
||
project = Project.objects.get(id=project_id)
|
||
contract_order.projects.add(project)
|
||
except Project.DoesNotExist:
|
||
pass
|
||
|
||
# Redirect to project detail if coming from a project
|
||
if project_id:
|
||
return redirect('projects:projects_detail', project_id=project_id)
|
||
return redirect('contracts:contracts_index')
|
||
else:
|
||
form = ContractOrderForm(user=request.user, project_id=project_id)
|
||
|
||
return render(request, 'contracts/contracts_order_form.html', {
|
||
'form': form,
|
||
'project_id': project_id,
|
||
})
|
||
|
||
|
||
|
||
def contracts_orders_change(request, order_id):
|
||
order = get_object_or_404(ContractOrder, pk=order_id)
|
||
original_contract_id = order.contract_id
|
||
first_project = order.projects.first()
|
||
project_id = first_project.id if first_project else None
|
||
|
||
if request.method == 'POST':
|
||
form = ContractOrderForm(request.POST, request.FILES, instance=order, user=request.user, project_id=project_id)
|
||
if form.is_valid():
|
||
# Sécurité supplémentaire côté vue pour empêcher une modification de contrat si des interventions sont déjà liées.
|
||
new_contract = form.cleaned_data.get('contract')
|
||
if order.interventions.exists() and new_contract and new_contract.id != original_contract_id:
|
||
form.add_error('contract', _("Impossible de changer le contrat: des interventions sont déjà liées à cette commande."))
|
||
else:
|
||
contract_order = form.save(commit=False)
|
||
|
||
|
||
contract_order = form.save(commit=False)
|
||
|
||
# permissions
|
||
if not user_can_add_order_for_contract(request.user, contract_order.contract_id):
|
||
raise PermissionDenied(_("Vous n'avez pas le droit de modifier une commande pour ce contrat."))
|
||
|
||
contract_order.ordered_by = request.user # Attribue l'utilisateur connecté
|
||
contract_order.save()
|
||
return redirect('contracts:contracts_orders_detail', order_id=order.id)
|
||
else:
|
||
form = ContractOrderForm(instance=order, user=request.user, project_id=project_id)
|
||
|
||
return render(request, 'contracts/contracts_order_form.html', {
|
||
'form': form,
|
||
'order': order,
|
||
})
|
||
|
||
|
||
@order_view_required
|
||
def contracts_orders_quote(request, order_id):
|
||
order = get_object_or_404(ContractOrder, pk=order_id)
|
||
|
||
if not user_can_manage_quote_for_contract(request.user, order.contract_id):
|
||
raise PermissionDenied(_("Vous n'avez pas le droit de modifier le devis de cette commande."))
|
||
|
||
quote, created = ContractOrderQuote.objects.get_or_create(
|
||
order=order,
|
||
defaults={'created_by': request.user},
|
||
)
|
||
|
||
can_edit_quote = not quote.is_finalized
|
||
|
||
posts = list(order.contract.posts.order_by('order_number'))
|
||
allowed_post_ids = {post.id for post in posts}
|
||
|
||
existing_items = {
|
||
item.contract_post_id: item
|
||
for item in quote.items.select_related('contract_post')
|
||
}
|
||
|
||
def format_quantity_for_input(value):
|
||
if value in (None, ''):
|
||
return ''
|
||
if isinstance(value, Decimal):
|
||
if value <= 0:
|
||
return ''
|
||
quantized = value.quantize(Decimal('0.01'))
|
||
if quantized <= 0:
|
||
return ''
|
||
return format(quantized, 'f')
|
||
try:
|
||
decimal_value = Decimal(str(value))
|
||
except (InvalidOperation, ValueError):
|
||
return str(value)
|
||
if decimal_value <= 0:
|
||
return ''
|
||
return format(decimal_value.quantize(Decimal('0.01')), 'f')
|
||
|
||
submitted_values = {}
|
||
row_errors = {}
|
||
general_errors = []
|
||
action = request.POST.get('action', 'save') if request.method == 'POST' else 'save'
|
||
raw_payload_value = request.POST.get('items_payload', '') if request.method == 'POST' else None
|
||
quantities_to_save = {}
|
||
|
||
if request.method == 'POST':
|
||
if not can_edit_quote:
|
||
messages.error(request, _("Ce devis est figé et ne peut plus être modifié."))
|
||
return redirect('contracts:contracts_orders_quote', order_id=order.id)
|
||
|
||
payload_data = []
|
||
payload_source = raw_payload_value.strip() if raw_payload_value else ''
|
||
if payload_source:
|
||
try:
|
||
payload_data = json.loads(payload_source)
|
||
except json.JSONDecodeError:
|
||
general_errors.append(_("Les données reçues sont invalides."))
|
||
else:
|
||
payload_data = []
|
||
|
||
if not general_errors and not isinstance(payload_data, list):
|
||
general_errors.append(_("Les données reçues sont invalides."))
|
||
payload_data = []
|
||
|
||
if not general_errors:
|
||
for entry in payload_data:
|
||
if not isinstance(entry, dict):
|
||
general_errors.append(_("Les données reçues sont invalides."))
|
||
continue
|
||
|
||
contract_post_raw = entry.get('contract_post_id')
|
||
quantity_raw = entry.get('quantity')
|
||
contract_post_int = None
|
||
if contract_post_raw is not None:
|
||
try:
|
||
contract_post_int = int(contract_post_raw)
|
||
except (TypeError, ValueError):
|
||
contract_post_int = None
|
||
|
||
if contract_post_int is not None:
|
||
submitted_values[contract_post_int] = '' if quantity_raw in (None, '') else str(quantity_raw)
|
||
|
||
form_data = {}
|
||
if contract_post_raw is not None:
|
||
form_data['contract_post_id'] = str(contract_post_raw)
|
||
if quantity_raw not in (None, ''):
|
||
form_data['quantity'] = str(quantity_raw)
|
||
|
||
form = ContractOrderQuoteQuantityForm(
|
||
data=form_data,
|
||
allowed_post_ids=allowed_post_ids,
|
||
)
|
||
|
||
if form.is_valid():
|
||
contract_post_id = form.cleaned_data['contract_post_id']
|
||
quantity = form.cleaned_data['quantity']
|
||
if quantity > 0:
|
||
quantities_to_save[contract_post_id] = quantity
|
||
else:
|
||
errors = []
|
||
for field_errors in form.errors.values():
|
||
errors.extend(field_errors)
|
||
if contract_post_int is not None:
|
||
row_errors[contract_post_int] = errors
|
||
else:
|
||
general_errors.extend(errors)
|
||
|
||
if general_errors or row_errors:
|
||
for error_message in general_errors:
|
||
messages.error(request, error_message)
|
||
messages.error(request, _("Le formulaire contient des erreurs."))
|
||
else:
|
||
if quote.created_by_id is None:
|
||
quote.created_by = request.user
|
||
quote.save()
|
||
|
||
posts_by_id = {post.id: post for post in posts}
|
||
|
||
ids_to_delete = set(existing_items.keys()) - set(quantities_to_save.keys())
|
||
if ids_to_delete:
|
||
quote.items.filter(contract_post_id__in=ids_to_delete).delete()
|
||
|
||
for contract_post_id, quantity in quantities_to_save.items():
|
||
item = existing_items.get(contract_post_id)
|
||
if item is None:
|
||
contract_post = posts_by_id.get(contract_post_id)
|
||
if contract_post is None:
|
||
continue
|
||
item = quote.items.create(
|
||
contract_post=contract_post,
|
||
quantity=quantity,
|
||
)
|
||
existing_items[contract_post_id] = item
|
||
elif item.quantity != quantity:
|
||
item.quantity = quantity
|
||
item.save(update_fields=['quantity'])
|
||
|
||
if action == 'finalize':
|
||
if not quote.items.exists():
|
||
messages.error(request, _("Impossible de figer un devis vide."))
|
||
else:
|
||
quote.finalize(request.user)
|
||
messages.success(request, _("Le devis a été figé."))
|
||
return redirect('contracts:contracts_orders_quote', order_id=order.id)
|
||
|
||
messages.success(request, _("Le devis a été enregistré."))
|
||
return redirect('contracts:contracts_orders_quote', order_id=order.id)
|
||
|
||
def get_row_quantity(post):
|
||
if post.id in submitted_values:
|
||
return format_quantity_for_input(submitted_values[post.id])
|
||
item = existing_items.get(post.id)
|
||
if item and item.quantity is not None:
|
||
return format_quantity_for_input(item.quantity)
|
||
return ''
|
||
|
||
rows = [
|
||
{
|
||
'post': post,
|
||
'quantity': get_row_quantity(post),
|
||
'errors': row_errors.get(post.id, []),
|
||
}
|
||
for post in posts
|
||
]
|
||
|
||
if raw_payload_value is None:
|
||
payload_items = [
|
||
{
|
||
'contract_post_id': item.contract_post_id,
|
||
'quantity': format_quantity_for_input(item.quantity),
|
||
}
|
||
for item in quote.items.select_related('contract_post')
|
||
if item.quantity and item.quantity > 0
|
||
]
|
||
raw_payload_value = json.dumps(payload_items)
|
||
elif not raw_payload_value:
|
||
raw_payload_value = '[]'
|
||
|
||
context = {
|
||
'order': order,
|
||
'quote': quote,
|
||
'rows': rows,
|
||
'quote_total': quote.get_total_amount() if quote else Decimal('0'),
|
||
'can_edit_quote': can_edit_quote,
|
||
'can_finalize_quote': can_edit_quote and quote.items.exists(),
|
||
'items_payload': raw_payload_value,
|
||
}
|
||
return render(request, 'contracts/contracts_order_quote_form.html', context)
|
||
|
||
|
||
@require_GET
|
||
def contracts_orders_get(request):
|
||
contract_id = request.GET.get("contract_id")
|
||
|
||
if not contract_id:
|
||
return JsonResponse({'error': 'Missing contract_id parameter'}, status=400)
|
||
|
||
# permissions
|
||
if not user_has_access_to_contract(request.user, contract_id):
|
||
return JsonResponse({'error': 'Unauthorized'}, status=403)
|
||
|
||
orders = ContractOrder.objects.filter(contract_id=contract_id).order_by("order_code")
|
||
data = [{"id": o.id, "order_code": o.order_code} for o in orders]
|
||
return JsonResponse(data, safe=False)
|
||
|
||
|
||
|
||
@require_GET
|
||
def autocomplete_posts(request):
|
||
q = request.GET.get('q', '')
|
||
contract_id = request.GET.get('contract_id', None)
|
||
|
||
lang = get_language()
|
||
|
||
|
||
field = request.GET.get('field', None)
|
||
|
||
if not q or not contract_id:
|
||
return JsonResponse([], safe=False)
|
||
|
||
# permissions
|
||
if not user_has_access_to_contract_posts(request.user, contract_id):
|
||
return JsonResponse({'error': 'Unauthorized'}, status=403)
|
||
|
||
results = ContractPost.objects.filter(contract=contract_id)
|
||
|
||
# Diviser la requête en mots pour permettre une recherche multi-termes
|
||
# Chaque mot doit être présent dans au moins un des champs recherchés
|
||
words = q.split()
|
||
|
||
if field is None:
|
||
# Pour chaque mot, on crée un filtre OR sur tous les champs
|
||
# Ensuite on combine tous les filtres avec AND (chaque mot doit matcher)
|
||
for word in words:
|
||
word_filter = (
|
||
Q(order_number__icontains=word) |
|
||
Q(code__icontains=word) |
|
||
Q(description_fr__icontains=word) |
|
||
Q(description_nl__icontains=word)
|
||
)
|
||
results = results.filter(word_filter)
|
||
|
||
results = (
|
||
results
|
||
.annotate(description=F(f"description_{lang}"))
|
||
.values("id", "order_number", "code", "description", "unit_price", "post_type", "is_coefficient_multipliable", "is_amount_to_be_justified", "coeff_amount_to_be_justified")[:10]
|
||
)
|
||
|
||
|
||
else:
|
||
# Choix du champ de description selon la langue
|
||
if field == 'description':
|
||
if lang == 'fr':
|
||
field = 'description_fr'
|
||
elif lang == 'nl':
|
||
field = 'description_nl'
|
||
else:
|
||
field = 'description_fr'
|
||
|
||
|
||
if field == "order_number":
|
||
results = results.annotate(order_number_str=Cast("order_number", CharField()))
|
||
# Chaque mot doit être présent dans le champ
|
||
for word in words:
|
||
results = results.filter(order_number_str__icontains=word)
|
||
else:
|
||
# Chaque mot doit être présent dans le champ spécifié
|
||
for word in words:
|
||
results = results.filter(**{f"{field}__icontains": word})
|
||
|
||
results = (
|
||
results
|
||
.annotate(description=F(f"description_{lang}")) # alias
|
||
.values("id", "order_number", "code", "description", "unit_price", "post_type", "is_coefficient_multipliable", "is_amount_to_be_justified", "coeff_amount_to_be_justified")[:10]
|
||
)
|
||
|
||
return JsonResponse(list(results), safe=False)
|
||
|
||
|
||
|
||
|
||
def order_document_download(request, order_id:int, doc_id: int):
|
||
doc = get_object_or_404(
|
||
ContractOrderDocument.objects.select_related("order"),
|
||
pk=doc_id
|
||
)
|
||
|
||
# Vérif d’accès : même logique que pour les commandes
|
||
if not user_has_access_to_contract_order(request.user, order_id):
|
||
raise PermissionDenied(_("Accès refusé au document de la commande.")) # :contentReference[oaicite:4]{index=4}
|
||
|
||
# Récupération du chemin réel
|
||
f = doc.document
|
||
if not f or not f.storage.exists(f.name):
|
||
raise Http404(_("Fichier introuvable"))
|
||
|
||
response = FileResponse(f.open("rb")) # support du streaming
|
||
# Optionnel : forcer le téléchargement ou non
|
||
response["Content-Disposition"] = f'inline; filename="{smart_str(os.path.basename(f.name))}"'
|
||
# Optionnel : type mime (sinon deviné)
|
||
# response["Content-Type"] = "application/pdf"
|
||
return response
|
||
|
||
|
||
@require_POST
|
||
def contracts_orders_create_ajax(request):
|
||
"""Vue AJAX pour créer une commande depuis le formulaire d'intervention préventive"""
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
contract_id = data.get('contract_id')
|
||
order_code = data.get('order_code', '').strip()
|
||
description = data.get('description', '').strip()
|
||
order_date = data.get('order_date')
|
||
delivery_date = data.get('delivery_date')
|
||
total_amount = data.get('total_amount')
|
||
|
||
# Validation
|
||
if not contract_id:
|
||
return JsonResponse({'success': False, 'error': _("Le contrat est requis")}, status=400)
|
||
|
||
if not order_code:
|
||
return JsonResponse({'success': False, 'error': _("La référence de commande est requise")}, status=400)
|
||
|
||
if not order_date:
|
||
return JsonResponse({'success': False, 'error': _("La date de commande est requise")}, status=400)
|
||
|
||
if not delivery_date:
|
||
return JsonResponse({'success': False, 'error': _("La date de livraison est requise")}, status=400)
|
||
|
||
# Vérifier les permissions
|
||
if not user_can_add_order_for_contract(request.user, contract_id):
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas le droit d'ajouter une commande pour ce contrat")}, status=403)
|
||
|
||
# Vérifier que le contrat existe
|
||
contract = get_object_or_404(Contract, pk=contract_id)
|
||
|
||
# Vérifier l'unicité de order_code pour ce contrat
|
||
if ContractOrder.objects.filter(contract=contract, order_code=order_code).exists():
|
||
return JsonResponse({'success': False, 'error': _("Une commande avec cette référence existe déjà pour ce contrat")}, status=400)
|
||
|
||
# Créer la commande
|
||
order = ContractOrder.objects.create(
|
||
contract=contract,
|
||
order_code=order_code,
|
||
description=description,
|
||
order_date=order_date,
|
||
delivery_date=delivery_date,
|
||
total_amount=Decimal(total_amount) if total_amount else None,
|
||
order_status='pending',
|
||
ordered_by=request.user
|
||
)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'order': {
|
||
'id': order.id,
|
||
'order_code': order.order_code
|
||
}
|
||
})
|
||
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'success': False, 'error': _("Données JSON invalides")}, status=400)
|
||
except Exception as e:
|
||
return JsonResponse({'success': False, 'error': str(e)}, status=500)
|
||
|
||
|
||
@require_GET
|
||
def available_orders(request):
|
||
"""Retourne la liste des commandes (ContractOrder) accessibles à l'utilisateur.
|
||
Par défaut, filtre sur les statuts 'pending' ou 'sent', mais accepte un paramètre
|
||
'status' pour personnaliser (ex: ?status=pending&status=sent&status=completed).
|
||
|
||
Format JSON: [{id, order_code, label, status, status_display, contract_id, contract_number}]
|
||
"""
|
||
if not request.user.is_authenticated:
|
||
return JsonResponse({'orders': []})
|
||
|
||
allowed_contracts = get_allowed_contracts_for_user(request.user)
|
||
if not allowed_contracts.exists():
|
||
return JsonResponse({'orders': []})
|
||
|
||
# Statuts par défaut: pending, sent
|
||
statuses = request.GET.getlist('status')
|
||
if not statuses:
|
||
statuses = ['pending', 'sent']
|
||
|
||
qs = (ContractOrder.objects
|
||
.select_related('contract')
|
||
.filter(contract__in=allowed_contracts, order_status__in=statuses))
|
||
|
||
# Recherche optionnelle
|
||
term = request.GET.get('q')
|
||
if term:
|
||
qs = qs.filter(Q(order_code__icontains=term) | Q(description__icontains=term))
|
||
|
||
data = []
|
||
for o in qs.order_by('-order_date')[:200]: # limite raisonnable
|
||
data.append({
|
||
'id': o.id,
|
||
'order_code': o.order_code,
|
||
'label': f"{o.order_code} ({o.contract.contract_number})",
|
||
'status': o.order_status,
|
||
'status_display': o.get_order_status_display(),
|
||
'contract_id': o.contract_id,
|
||
'contract_number': o.contract.contract_number,
|
||
})
|
||
return JsonResponse({'orders': data})
|
||
|
||
|
||
@require_GET
|
||
def contract_posts_list(request, contract_id):
|
||
"""
|
||
Retourne la liste de tous les postes d'un contrat.
|
||
Utilisé pour afficher tous les postes disponibles lors de l'édition d'une intervention.
|
||
|
||
Format JSON: {success: bool, posts: [{id, order_number, code, description_fr, description_nl, unit, unit_price}]}
|
||
"""
|
||
try:
|
||
# Vérifier l'accès au contrat
|
||
if not user_has_access_to_contract(request.user, contract_id):
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas accès à ce contrat")}, status=403)
|
||
|
||
contract = get_object_or_404(Contract, id=contract_id)
|
||
posts = ContractPost.objects.filter(contract=contract).order_by('order_number')
|
||
|
||
posts_data = []
|
||
for post in posts:
|
||
posts_data.append({
|
||
'id': post.id,
|
||
'order_number': post.order_number,
|
||
'code': post.code,
|
||
'description_fr': post.description_fr,
|
||
'description_nl': post.description_nl,
|
||
'unit': post.unit,
|
||
'unit_price': str(post.unit_price) if post.unit_price else '0.00',
|
||
'post_type': post.post_type or '',
|
||
'is_coefficient_multipliable': post.is_coefficient_multipliable,
|
||
})
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'posts': posts_data
|
||
})
|
||
|
||
except Exception as e:
|
||
return JsonResponse({'success': False, 'error': str(e)}, status=500)
|
||
|
||
|
||
@login_required
|
||
def claim_declaration_attachments(request, doc_id):
|
||
"""
|
||
Vue pour gérer les pièces jointes d'une déclaration de créance.
|
||
Affiche la liste des pièces jointes et permet d'en ajouter.
|
||
"""
|
||
claim_doc = get_object_or_404(ContractOrderDocument, id=doc_id, document_type='claim_declaration')
|
||
order = claim_doc.order
|
||
|
||
# Vérifier l'accès à la commande
|
||
if not user_has_access_to_contract_order(request.user, order.id):
|
||
raise PermissionDenied(_("Vous n'avez pas accès à cette commande."))
|
||
|
||
if request.method == 'POST':
|
||
form = ClaimDeclarationAttachmentForm(request.POST, request.FILES)
|
||
if form.is_valid():
|
||
attachment = form.save(commit=False)
|
||
attachment.claim_document = claim_doc
|
||
attachment.uploaded_by = request.user
|
||
attachment.save()
|
||
messages.success(request, _("Le justificatif a été ajouté avec succès."))
|
||
return redirect('contracts:claim_declaration_attachments', doc_id=doc_id)
|
||
else:
|
||
form = ClaimDeclarationAttachmentForm()
|
||
|
||
attachments = claim_doc.attachments.all().order_by('-uploaded_at')
|
||
|
||
# URLs privées pour chaque pièce jointe
|
||
for att in attachments:
|
||
att.private_url = build_private_url("contracts", "ClaimDeclarationAttachment", att.id, "document")
|
||
|
||
# URL privée pour le document de déclaration de créance
|
||
claim_doc_private_url = build_private_url("contracts", "ContractOrderDocument", claim_doc.id, "document")
|
||
|
||
context = {
|
||
'claim_doc': claim_doc,
|
||
'claim_doc_private_url': claim_doc_private_url,
|
||
'order': order,
|
||
'attachments': attachments,
|
||
'form': form,
|
||
}
|
||
return render(request, 'contracts/claim_declaration_attachments.html', context)
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def delete_claim_attachment(request, attachment_id):
|
||
"""
|
||
Supprime une pièce jointe d'une déclaration de créance.
|
||
"""
|
||
attachment = get_object_or_404(ClaimDeclarationAttachment, id=attachment_id)
|
||
order = attachment.claim_document.order
|
||
|
||
# Vérifier l'accès à la commande
|
||
if not user_has_access_to_contract_order(request.user, order.id):
|
||
raise PermissionDenied(_("Vous n'avez pas accès à cette commande."))
|
||
|
||
doc_id = attachment.claim_document.id
|
||
attachment_title = attachment.title
|
||
|
||
# Supprimer le fichier physique
|
||
if attachment.document:
|
||
attachment.document.delete(save=False)
|
||
attachment.delete()
|
||
|
||
messages.success(request, _("Le justificatif « %(title)s » a été supprimé.") % {'title': attachment_title})
|
||
return redirect('contracts:claim_declaration_attachments', doc_id=doc_id)
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def update_order_status(request, order_id):
|
||
"""
|
||
Met à jour le statut d'une commande.
|
||
Seuls les managers et admins peuvent changer le statut.
|
||
"""
|
||
order = get_object_or_404(ContractOrder, id=order_id)
|
||
|
||
# Vérifier l'accès à la commande
|
||
if not user_has_access_to_contract_order(request.user, order_id):
|
||
return JsonResponse({'message': _("Vous n'avez pas accès à cette commande.")}, status=403)
|
||
|
||
# Vérifier que l'utilisateur est manager ou admin
|
||
user_config = getattr(request.user, "config", None)
|
||
if not user_config or not user_config.roles.filter(name__in=['manager', 'admin']).exists():
|
||
return JsonResponse({'message': _("Seuls les managers et administrateurs peuvent changer le statut d'une commande.")}, status=403)
|
||
|
||
new_status = request.POST.get('new_status')
|
||
|
||
# Valider que le nouveau statut existe
|
||
from contracts.models import CONTRACT_ORDER_STATUS_CHOICES
|
||
valid_statuses = [choice[0] for choice in CONTRACT_ORDER_STATUS_CHOICES]
|
||
|
||
if new_status not in valid_statuses:
|
||
return JsonResponse({'message': _("Statut invalide.")}, status=400)
|
||
|
||
# Mettre à jour le statut
|
||
old_status = order.order_status
|
||
order.order_status = new_status
|
||
order.save()
|
||
|
||
return JsonResponse({
|
||
'message': _("Le statut de la commande a été mis à jour avec succès."),
|
||
'old_status': old_status,
|
||
'new_status': new_status
|
||
})
|
||
|
||
|
||
@login_required
|
||
def order_add_attachment(request, order_id):
|
||
"""Ajoute une ou plusieurs pièces jointes (photos, PDF, …) à une commande."""
|
||
order = get_object_or_404(ContractOrder, id=order_id)
|
||
|
||
if not user_has_access_to_contract_order(request.user, order_id):
|
||
raise PermissionDenied(_("Accès à la commande refusé."))
|
||
|
||
if not user_can_add_order_for_contract(request.user, order.contract_id):
|
||
raise PermissionDenied(_("Vous n'avez pas la permission d'ajouter des documents à cette commande."))
|
||
|
||
if request.method == 'POST':
|
||
form = ContractOrderAttachmentForm(request.POST, request.FILES)
|
||
if form.is_valid():
|
||
files = form.cleaned_data['files']
|
||
for f in files:
|
||
ContractOrderAttachment.objects.create(
|
||
order=order,
|
||
file=f,
|
||
uploaded_by=request.user,
|
||
)
|
||
messages.success(request, _("Document(s) ajouté(s) avec succès."))
|
||
else:
|
||
messages.error(request, _("Erreur lors de l'ajout des documents."))
|
||
|
||
return redirect('contracts:contracts_orders_detail', order_id=order_id)
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def order_delete_attachment(request, order_id, attachment_id):
|
||
"""Supprime une pièce jointe d'une commande (réponse JSON)."""
|
||
order = get_object_or_404(ContractOrder, id=order_id)
|
||
|
||
if not user_has_access_to_contract_order(request.user, order_id):
|
||
return JsonResponse({'success': False, 'error': _("Accès refusé.")}, status=403)
|
||
|
||
if not user_can_add_order_for_contract(request.user, order.contract_id):
|
||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas la permission de supprimer ce document.")}, status=403)
|
||
|
||
attachment = get_object_or_404(ContractOrderAttachment, id=attachment_id, order=order)
|
||
|
||
if attachment.file:
|
||
attachment.file.delete(save=False)
|
||
if attachment.thumbnail:
|
||
attachment.thumbnail.delete(save=False)
|
||
attachment.delete()
|
||
|
||
return JsonResponse({'success': True})
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Commandes directes (DirectOrderPost)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _user_can_manage_direct_posts(user, order):
|
||
"""L'utilisateur peut créer/modifier/supprimer des commandes directes s'il
|
||
est external_manager ET a accès au contrat associé à la commande."""
|
||
return user_can_manage_direct_order_posts(user, order.contract_id)
|
||
|
||
|
||
def _user_can_validate_direct_posts(user, order):
|
||
"""L'utilisateur peut valider/refuser des commandes directes
|
||
s'il est admin, manager ou controller ET a accès au contrat associé à la commande."""
|
||
return user_can_validate_direct_order_posts(user, order.contract_id)
|
||
|
||
|
||
@login_required
|
||
@require_GET
|
||
def order_direct_posts_list(request, order_id):
|
||
"""Retourne la liste JSON des commandes directes d'une commande."""
|
||
order = get_object_or_404(ContractOrder, id=order_id)
|
||
if not user_has_access_to_contract_order(request.user, order_id):
|
||
return JsonResponse({'error': _('Accès refusé.')}, status=403)
|
||
|
||
lang = get_language() or 'fr'
|
||
posts = [
|
||
{
|
||
'id': dp.id,
|
||
'contract_post_id': dp.contract_post_id,
|
||
'post_code': dp.contract_post.code,
|
||
'post_description': dp.contract_post.get_description_for_language(lang),
|
||
'post_order_number': dp.contract_post.order_number,
|
||
'unit': dp.contract_post.unit,
|
||
'unit_price': str(dp.contract_post.unit_price or '0.00'),
|
||
'quantity': str(dp.quantity),
|
||
'invoiced_quantity': str(dp.invoiced_quantity or '0.00'),
|
||
'total_price': str(dp.total_price),
|
||
}
|
||
for dp in order.direct_posts.select_related('contract_post', 'created_by', 'validated_by')
|
||
]
|
||
return JsonResponse({
|
||
'posts': posts,
|
||
'can_manage': _user_can_manage_direct_posts(request.user, order),
|
||
'can_validate': _user_can_validate_direct_posts(request.user, order),
|
||
})
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def order_direct_post_add(request, order_id):
|
||
"""Ajoute un poste de commande directe (AJAX JSON)."""
|
||
order = get_object_or_404(ContractOrder, id=order_id)
|
||
if not user_has_access_to_contract_order(request.user, order_id):
|
||
return JsonResponse({'success': False, 'error': _('Accès refusé.')}, status=403)
|
||
if not _user_can_manage_direct_posts(request.user, order):
|
||
return JsonResponse({'success': False, 'error': _('Permission insuffisante.')}, status=403)
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
except (json.JSONDecodeError, ValueError):
|
||
return JsonResponse({'success': False, 'error': _('Données JSON invalides.')}, status=400)
|
||
|
||
contract_post_id = data.get('contract_post_id')
|
||
quantity_raw = data.get('quantity')
|
||
comment = (data.get('comment') or '').strip()
|
||
|
||
if not contract_post_id:
|
||
return JsonResponse({'success': False, 'error': _('Le poste de marché est requis.')}, status=400)
|
||
|
||
try:
|
||
quantity = Decimal(str(quantity_raw))
|
||
if quantity <= 0:
|
||
raise ValueError
|
||
except (InvalidOperation, ValueError, TypeError):
|
||
return JsonResponse({'success': False, 'error': _('La quantité doit être un nombre positif.')}, status=400)
|
||
|
||
contract_post = get_object_or_404(ContractPost, id=contract_post_id, contract=order.contract)
|
||
|
||
if DirectOrderPost.objects.filter(order=order, contract_post=contract_post).exists():
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': _('Un poste direct existe déjà pour ce poste dans cette commande. Modifiez-le plutôt.'),
|
||
}, status=400)
|
||
|
||
dp = DirectOrderPost.objects.create(
|
||
order=order,
|
||
contract_post=contract_post,
|
||
quantity=quantity,
|
||
comment=comment or None,
|
||
created_by=request.user,
|
||
)
|
||
lang = get_language() or 'fr'
|
||
return JsonResponse({
|
||
'success': True,
|
||
'post': {
|
||
'id': dp.id,
|
||
'contract_post_id': dp.contract_post_id,
|
||
'post_code': dp.contract_post.code,
|
||
'post_description': dp.contract_post.get_description_for_language(lang),
|
||
'post_order_number': dp.contract_post.order_number,
|
||
'unit': dp.contract_post.unit,
|
||
'unit_price': str(dp.contract_post.unit_price or '0.00'),
|
||
'quantity': str(dp.quantity),
|
||
'invoiced_quantity': str(dp.invoiced_quantity or '0.00'),
|
||
'total_price': str(dp.total_price),
|
||
'comment': dp.comment or '',
|
||
'control_status': dp.control_status,
|
||
'created_at': dp.created_at.strftime('%d/%m/%Y') if dp.created_at else '',
|
||
'created_by': dp.created_by.get_full_name() if dp.created_by else '',
|
||
'validated_at': '',
|
||
'validated_by': '',
|
||
},
|
||
})
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def order_direct_post_update(request, order_id, direct_post_id):
|
||
"""Met à jour la quantité/commentaire d'un poste de commande directe (AJAX JSON)."""
|
||
order = get_object_or_404(ContractOrder, id=order_id)
|
||
if not user_has_access_to_contract_order(request.user, order_id):
|
||
return JsonResponse({'success': False, 'error': _('Accès refusé.')}, status=403)
|
||
if not _user_can_manage_direct_posts(request.user, order):
|
||
return JsonResponse({'success': False, 'error': _('Permission insuffisante.')}, status=403)
|
||
|
||
dp = get_object_or_404(DirectOrderPost, id=direct_post_id, order=order)
|
||
if dp.control_status == 'validated':
|
||
return JsonResponse({'success': False, 'error': _('Impossible de modifier un poste déjà validé.')}, status=400)
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
except (json.JSONDecodeError, ValueError):
|
||
return JsonResponse({'success': False, 'error': _('Données JSON invalides.')}, status=400)
|
||
|
||
quantity_raw = data.get('quantity')
|
||
comment = (data.get('comment') or '').strip()
|
||
|
||
try:
|
||
quantity = Decimal(str(quantity_raw))
|
||
if quantity < 0:
|
||
raise ValueError
|
||
except (InvalidOperation, ValueError, TypeError):
|
||
return JsonResponse({'success': False, 'error': _('La quantité doit être un nombre positif ou nul.')}, status=400)
|
||
|
||
dp.quantity = quantity
|
||
dp.comment = comment or None
|
||
# Si le poste était facturé, le repasser en attente à la modification
|
||
if dp.control_status == 'invoiced':
|
||
dp.control_status = 'pending'
|
||
dp.save(update_fields=['quantity', 'comment', 'control_status'])
|
||
|
||
lang = get_language() or 'fr'
|
||
return JsonResponse({
|
||
'success': True,
|
||
'post': {
|
||
'id': dp.id,
|
||
'quantity': str(dp.quantity),
|
||
'invoiced_quantity': str(dp.invoiced_quantity or '0.00'),
|
||
'total_price': str(dp.total_price),
|
||
'comment': dp.comment or '',
|
||
'control_status': dp.control_status,
|
||
},
|
||
})
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def order_direct_post_delete(request, order_id, direct_post_id):
|
||
"""Supprime un poste de commande directe (AJAX JSON)."""
|
||
order = get_object_or_404(ContractOrder, id=order_id)
|
||
if not user_has_access_to_contract_order(request.user, order_id):
|
||
return JsonResponse({'success': False, 'error': _('Accès refusé.')}, status=403)
|
||
if not _user_can_manage_direct_posts(request.user, order):
|
||
return JsonResponse({'success': False, 'error': _('Permission insuffisante.')}, status=403)
|
||
|
||
dp = get_object_or_404(DirectOrderPost, id=direct_post_id, order=order)
|
||
if dp.control_status == 'validated':
|
||
return JsonResponse({'success': False, 'error': _('Impossible de supprimer un poste déjà validé.')}, status=400)
|
||
if dp.invoiced_quantity and dp.invoiced_quantity > 0:
|
||
return JsonResponse({'success': False, 'error': _('Impossible de supprimer un poste déjà facturé.')}, status=400)
|
||
|
||
dp.delete()
|
||
return JsonResponse({'success': True})
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def order_direct_post_validate(request, order_id, direct_post_id):
|
||
"""Valide ou refuse un poste de commande directe (AJAX JSON)."""
|
||
order = get_object_or_404(ContractOrder, id=order_id)
|
||
if not user_has_access_to_contract_order(request.user, order_id):
|
||
return JsonResponse({'success': False, 'error': _('Accès refusé.')}, status=403)
|
||
if not _user_can_validate_direct_posts(request.user, order):
|
||
return JsonResponse({'success': False, 'error': _('Permission insuffisante.')}, status=403)
|
||
|
||
dp = get_object_or_404(DirectOrderPost, id=direct_post_id, order=order)
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
except (json.JSONDecodeError, ValueError):
|
||
return JsonResponse({'success': False, 'error': _('Données JSON invalides.')}, status=400)
|
||
|
||
new_status = data.get('status')
|
||
valid_statuses = [s[0] for s in DIRECT_ORDER_POST_STATUS_CHOICES]
|
||
if new_status not in valid_statuses:
|
||
return JsonResponse({'success': False, 'error': _('Statut invalide.')}, status=400)
|
||
|
||
from django.utils import timezone
|
||
dp.control_status = new_status
|
||
if new_status == 'validated':
|
||
dp.validated_at = timezone.now()
|
||
dp.validated_by = request.user
|
||
elif new_status == 'pending':
|
||
dp.validated_at = None
|
||
dp.validated_by = None
|
||
dp.save(update_fields=['control_status', 'validated_at', 'validated_by'])
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'post': {
|
||
'id': dp.id,
|
||
'quantity': str(dp.quantity),
|
||
'invoiced_quantity': str(dp.invoiced_quantity or '0.00'),
|
||
'control_status': dp.control_status,
|
||
'total_price': str(dp.total_price),
|
||
'validated_at': dp.validated_at.strftime('%d/%m/%Y') if dp.validated_at else '',
|
||
'validated_by': dp.validated_by.get_full_name() if dp.validated_by else '',
|
||
},
|
||
})
|
||
|
||
|
||
@login_required
|
||
@require_GET
|
||
def contracts_geojson(request):
|
||
"""
|
||
Retourne la liste des contrats sous forme de GeoJSON FeatureCollection
|
||
filtrée selon la thématique demandée (ex: nature) et les permissions de l'utilisateur.
|
||
Seuls les contrats disposant d'une géométrie (geom) sont inclus.
|
||
"""
|
||
thematic_code = request.GET.get('thematic', 'nature').strip()
|
||
|
||
# 1. Vérification de l'accès à la thématique si l'utilisateur n'est pas superuser / admin
|
||
if not request.user.is_superuser:
|
||
user_config = getattr(request.user, 'config', None)
|
||
if not user_config:
|
||
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||
|
||
user_roles = set(user_config.roles.values_list("name", flat=True))
|
||
if not user_roles.intersection({"admin", "top_manager"}):
|
||
has_thematic_access = UserThematics.objects.filter(
|
||
user_config=user_config,
|
||
thematic__code=thematic_code
|
||
).filter(
|
||
Q(can_view_assets=True) | Q(can_view_interventions=True)
|
||
).exists()
|
||
if not has_thematic_access:
|
||
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||
|
||
# 2. Récupération des contrats autorisés pour l'utilisateur
|
||
allowed_contracts = get_allowed_contracts_for_user(request.user, for_assets=True)
|
||
|
||
# 3. Filtrage par thématique et géométrie non nulle
|
||
contracts_qs = allowed_contracts.filter(
|
||
thematics__code=thematic_code,
|
||
geom__isnull=False
|
||
).select_related('company').prefetch_related('thematics').distinct()
|
||
|
||
# 4. Annotation PostGIS pour transformation rapide en GeoJSON EPSG:4326
|
||
contracts_qs = contracts_qs.annotate(
|
||
geom_geojson=AsGeoJSON(Transform('geom', 4326))
|
||
).order_by('contract_number')
|
||
|
||
features = []
|
||
for contract in contracts_qs:
|
||
if not contract.geom_geojson:
|
||
continue
|
||
try:
|
||
geom_data = json.loads(contract.geom_geojson)
|
||
except (json.JSONDecodeError, TypeError):
|
||
continue
|
||
|
||
detail_url = ""
|
||
try:
|
||
detail_url = reverse('contracts:contracts_detail', kwargs={'contract_id': contract.id})
|
||
except Exception:
|
||
detail_url = ""
|
||
|
||
features.append({
|
||
"type": "Feature",
|
||
"id": contract.id,
|
||
"geometry": geom_data,
|
||
"properties": {
|
||
"id": contract.id,
|
||
"contract_number": contract.contract_number,
|
||
"company_name": contract.company.name if contract.company else "",
|
||
"color": contract.color or "#28a745",
|
||
"description": contract.description or "",
|
||
"start_date": contract.start_date.isoformat() if contract.start_date else "",
|
||
"end_date": contract.end_date.isoformat() if contract.end_date else "",
|
||
"detail_url": detail_url,
|
||
}
|
||
})
|
||
|
||
return JsonResponse({
|
||
"type": "FeatureCollection",
|
||
"features": features
|
||
})
|
||
|
||
|