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 '', }, })