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

1250 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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.get(id=order_id)
interventions = order.interventions.all()
# 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'})
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 '',
},
})