import logging import os import io import json import time import re from django.shortcuts import render, get_object_or_404, redirect from django.views.decorators.csrf import csrf_exempt from django.views.decorators.cache import never_cache from django.http import JsonResponse, FileResponse, HttpResponse, HttpResponseForbidden from django.views.decorators.http import require_POST from django.conf import settings from django.contrib.staticfiles import finders as staticfiles_finders from pathlib import Path from django.contrib import messages from django.contrib.auth.decorators import login_required from django.utils.timezone import now from django.db.models import Q, Exists, OuterRef, Subquery from django.db import transaction from django.urls import reverse from django.utils.translation import gettext as _ from django.contrib.gis.db.models.functions import Transform, Centroid from pdf2image import convert_from_path from PIL import Image from common.private_files.helpers import build_private_url from .views_reports_pdf import generate_control_pdf from .models import Control, Remark, RemarkImage, RemarkHistory, RemarkTag, ControlReferencePlan, ControlReport, ControlReportTemplate, Category, ControlQuantity from .forms import ControlForm, RemarkForm, ControlReferencePlanForm, ControlReportTemplateForm, ControlQuantityForm, QuantityFormSet from .permissions import control_view_required, control_add_required, user_can_add_control, control_change_required, user_can_change_control from projects.models import Project, ProjectUserAccess, ProjectPhase from projects.permissions import filter_viewable_projects_for_user from interventions.models import Intervention from interventions.models import Intervention from common.models import UserConfig, UserThematics, UserContractAccess from contracts.models import ContractPost, Contract from common.utils import redirect_with_variant logger = logging.getLogger(__name__) QTY_FORM_INDEX_RE = re.compile(r"^qty-(\d+)-") def _normalize_qty_management_form(post_data): """Ensure qty formset management fields exist, even with partial/stale clients.""" if post_data is None: return None has_total = "qty-TOTAL_FORMS" in post_data has_initial = "qty-INITIAL_FORMS" in post_data if has_total and has_initial: return post_data fixed_post_data = post_data.copy() max_index = -1 for key in fixed_post_data.keys(): match = QTY_FORM_INDEX_RE.match(key) if match: max_index = max(max_index, int(match.group(1))) inferred_total = max_index + 1 if max_index >= 0 else 0 if not has_total: fixed_post_data["qty-TOTAL_FORMS"] = str(inferred_total) if not has_initial: fixed_post_data["qty-INITIAL_FORMS"] = "0" if "qty-MIN_NUM_FORMS" not in fixed_post_data: fixed_post_data["qty-MIN_NUM_FORMS"] = "0" if "qty-MAX_NUM_FORMS" not in fixed_post_data: fixed_post_data["qty-MAX_NUM_FORMS"] = "1000" logger.warning( "Missing qty formset management fields were normalized (inferred qty-TOTAL_FORMS=%s)", inferred_total, ) return fixed_post_data def interventions_autocomplete(request): """ API endpoint pour l'autocomplétion des interventions. Renvoie les interventions filtrées selon les permissions de l'utilisateur. """ q = request.GET.get('q', '').strip() user = request.user if len(q) < 2: return JsonResponse([], safe=False) # Filtrer selon permissions (admin ou permissions thématiques) is_admin = bool(getattr(user, "config", None) and (user.config.has_role("admin") or user.config.has_role("top_manager"))) if is_admin: interventions = Intervention.objects.all() else: # Filtrer selon accès thématique if getattr(user, "config", None) and user.config.is_intern: user_thematics = UserThematics.objects.filter( user_config=user.config, can_view_interventions=True ).values_list('thematic_id', flat=True) interventions = Intervention.objects.filter(thematic__in=user_thematics) else: interventions = Intervention.objects.none() # Recherche multi-mots sur code ou titre # Séparer les mots de recherche et créer un filtre pour chaque mot search_terms = q.split() query = Q() for term in search_terms: if term: # Ignorer les termes vides query &= (Q(code__icontains=term) | Q(title__icontains=term)) interventions = interventions.filter(query).select_related('thematic').order_by('-code')[:20] results = [] for inter in interventions: results.append({ 'id': inter.id, 'code': inter.code, 'title': inter.title or '', 'status': inter.get_status_display(), 'status_color': inter.get_status_color(), 'thematic': inter.thematic.name if inter.thematic else '', 'label': f"{inter.code} - {inter.title or ''}", }) return JsonResponse(results, safe=False) def controls_install(request): context = {} return render(request, "controls/controls_install_mobile.html", context) @never_cache def controls_service_worker(request): """Sert le service worker des contrôles depuis /controls/service-worker.js. Ce chemin donne au SW un scope naturel sur /controls/ sans header supplémentaire. """ content = _read_service_worker_content() response = HttpResponse(content, content_type='application/javascript; charset=utf-8') return response def controls_offline(request): """Page de fallback hors ligne pré-cachée par le service worker.""" return render(request, 'controls/offline.html') def _read_service_worker_content(): """Localise et lit le fichier service-worker.js. En développement : utilisé via staticfiles finders (répertoires sources). En production : recherché dans STATIC_ROOT après collectstatic. """ rel = 'controls/service-worker.js' # 1. Finders (fonctionne en dév, peut fonctionner en prod selon la config) abs_path = staticfiles_finders.find(rel) if abs_path: try: return Path(abs_path).read_text(encoding='utf-8') except (FileNotFoundError, IOError): pass # 2. STATIC_ROOT (fichiers collectés en production) try: return (Path(settings.STATIC_ROOT) / rel).read_text(encoding='utf-8') except (FileNotFoundError, IOError, TypeError): pass # 3. Chemin source app (fallback ultime) try: return (settings.BASE_DIR / 'controls' / 'static' / rel).read_text(encoding='utf-8') except (FileNotFoundError, IOError): return 'console.error("Controls service worker introuvable");' def controls_index(request): user = request.user variant = request.GET.get('v', 'desktop') if "/mobile/" in request.path: variant = 'mobile' # Admin is_admin = bool(getattr(user, "config", None) and (user.config.has_role("admin") or user.config.has_role("top_manager"))) # Auteur author_q = Q(control_author=user) # Accès direct au projet is_member_q = Q(Exists( ProjectUserAccess.objects.filter(user=user, project=OuterRef('project'), can_view=True) )) # Accès thématique (uniquement si user interne) : # - contrôles sans projet : accès si thématique correspondante # - contrôles avec projet + visibilité 'all' : accès thématique en fallback if getattr(user, "config", None) and user.config.is_intern: user_thematic_ids = UserThematics.objects.filter( user_config=user.config, can_view_interventions=True ).values_list('thematic_id', flat=True) # Un interne peut voir tout contrôle avec thématique correspondante, # qu'il soit lié à un projet ou non, dès lors qu'il n'est pas privé # (les contrôles privés sont filtrés par not_private_q). thematic_access_q = Q(thematics__in=user_thematic_ids) else: thematic_access_q = Q(pk__in=[]) # aucun accès thématique # Visibilité : exclure les contrôles privés dont l'utilisateur n'est pas l'auteur not_private_q = ~Q(visibility='private') | author_q # Visibilité : exclure les contrôles internes pour les utilisateurs externes is_intern = getattr(user, "config", None) and user.config.is_intern if is_intern: not_intern_only_q = Q() # les internes peuvent voir les contrôles 'intern' else: not_intern_only_q = ~Q(visibility='intern') | author_q if is_admin: controls_qs = Control.objects.all().distinct() else: controls_qs = Control.objects.filter( ( author_q | ( # Membre du projet (si projet présent) Q(project__isnull=False) & is_member_q ) | thematic_access_q # Accès thématique (avec ou sans projet) ) & not_private_q & not_intern_only_q ).distinct() controls_list = [] for control in controls_qs: control_dict = { 'id': control.id, 'control_code': control.control_code, 'description': control.description, 'author': control.control_author.username if control.control_author else "", 'link': control.get_absolute_url(), # Use get_absolute_url for the link #'coordinates': [project.geom_4326.x, project.geom_4326.y] # Extract coordinates from the transformed centroid } controls_list.append(control_dict) context = { 'controls': controls_qs, 'controls_list': controls_list, # for javascript usage 'is_add_control_allowed': user_can_add_control(request.user), } template_name = 'controls/controls_index_mobile.html' if variant == 'mobile' else 'controls/controls_index.html' return render(request, template_name, context) @control_add_required def controls_add(request): variant = request.POST.get('v') or request.GET.get('v') preselected_interventions = [] if request.method == "POST": # Travailler sur une copie du POST post_data = request.POST.copy() # Récupérer les IDs d'interventions depuis un select multiple (format Django) intervention_ids_list = [] try: intervention_ids_list = [int(x) for x in post_data.getlist('interventions') if str(x).strip().isdigit()] except Exception: intervention_ids_list = [] # Compat: si jamais on reçoit une chaîne CSV (ancien champ hidden) if not intervention_ids_list: raw_csv = post_data.get('interventions', '').strip() if raw_csv: intervention_ids_list = [int(x) for x in raw_csv.split(',') if x.strip().isdigit()] # S'assurer que le formulaire voit bien la liste complète post_data.setlist('interventions', [str(x) for x in intervention_ids_list]) form = ControlForm(post_data) if form.is_valid(): control = form.save(commit=False) control.control_author = request.user control.save() # Gérer les interventions multiples (mettre exactement la sélection courante) control.interventions.set(intervention_ids_list) form.save_m2m() # Rediriger vers la page de détail du contrôle créé return redirect_with_variant('controls:controls_detail', variant=variant, url_kwargs={'control_id': control.id}) else: # En cas d'erreur, reconstruire les querysets nécessaires # 1. Queryset des projets form.fields['project'].queryset = Project.objects.exclude(status__in=['canceled', 'archived']) # 2. Queryset des phases basé sur le projet sélectionné project_id = post_data.get('project') if project_id: try: form.fields['phase'].queryset = ProjectPhase.objects.filter(project_id=project_id) except: pass # 3. Queryset des catégories basé sur les thématiques sélectionnées thematic_ids = post_data.getlist('thematics') if thematic_ids: from assets.models import AssetCategory form.fields['categories'].queryset = AssetCategory.objects.filter( thematic__id__in=thematic_ids ).distinct() # 4. Reconstruire la liste des interventions pré-sélectionnées if intervention_ids_list: interventions = Intervention.objects.filter(id__in=intervention_ids_list) for inter in interventions: preselected_interventions.append({ 'id': inter.id, 'code': inter.code, 'title': inter.title or '', 'label': f"{inter.code} - {inter.title or ''}", 'status': inter.get_status_display(), 'status_color': inter.get_status_color(), }) else: form = ControlForm(user=request.user) project_id = request.GET.get('project_id') if project_id: try: project = Project.objects.get(id=project_id) form.fields['project'].initial = project.id except Project.DoesNotExist: messages.error(request, _("Le projet spécifié n'existe pas.")) form.fields['project'].queryset = Project.objects.exclude(status__in=['canceled', 'archived']) # Gestion de la pré-sélection d'intervention intervention_id = request.GET.get('intervention_id') control_type_param = request.GET.get('control_type') if intervention_id: try: intervention = Intervention.objects.get(id=intervention_id) # Pré-sélectionner la thématique de l'intervention if intervention.thematic: form.initial['thematics'] = [intervention.thematic.id] # Pré-remplir la localisation depuis l'adresse ou la première localisation liée if intervention.address: form.initial['location'] = intervention.address else: first_location = intervention.locations.first() if first_location and first_location.location: location_obj = first_location.location location_name = "" if hasattr(location_obj, 'get_name'): location_name = location_obj.get_name() elif hasattr(location_obj, 'name'): location_name = location_obj.name elif hasattr(location_obj, '__str__'): location_name = str(location_obj) form.initial['location'] = f"{location_obj.code} - {location_name}" # Pré-remplir la description avec le code et titre de l'intervention description_prefix = f"Intervention {intervention.code}" if intervention.title: description_prefix += f" - {intervention.title}" form.initial['description'] = description_prefix # Pré-sélectionner la catégorie d'équipement de l'intervention if intervention.asset_category: form.initial['categories'] = [intervention.asset_category.id] # S'assurer que la catégorie est visible dans le queryset from assets.models import AssetCategory thematic_ids = [intervention.thematic.id] if intervention.thematic else [] if thematic_ids: form.fields['categories'].queryset = AssetCategory.objects.filter( thematic__id__in=thematic_ids ).distinct() else: form.fields['categories'].queryset = AssetCategory.objects.filter( pk=intervention.asset_category.id ) # Pré-remplir les coordonnées géographiques if intervention.lon is not None and intervention.lat is not None: form.initial['lon'] = intervention.lon form.initial['lat'] = intervention.lat # Pré-sélectionner le type de contrôle si fourni en paramètre if control_type_param: form.initial['control_type'] = control_type_param preselected_interventions = [{ 'id': intervention.id, 'code': intervention.code, 'title': intervention.title or '', 'label': f"{intervention.code} - {intervention.title or ''}", 'status': intervention.get_status_display(), 'status_color': intervention.get_status_color(), }] except Intervention.DoesNotExist: messages.error(request, _("L'intervention spécifiée n'existe pas.")) template_name = 'controls/controls_form_mobile.html' if variant == 'mobile' else 'controls/controls_form.html' is_post_with_errors = request.method == "POST" and not form.is_valid() return render(request, template_name, { 'form': form, 'preselected_interventions': json.dumps(preselected_interventions) if preselected_interventions else '[]', 'is_post_with_errors': is_post_with_errors, }) @control_add_required def control_quick_create_from_intervention(request): """ Crée directement un contrôle à partir d'une intervention sans passer par le formulaire. Les informations sont reprises automatiquement depuis l'intervention. """ from .models import CONTROL_TYPE_CHOICES intervention_id = request.GET.get('intervention_id') control_type = request.GET.get('control_type', 'control') variant = request.GET.get('v') if control_type not in CONTROL_TYPE_CHOICES: control_type = 'control' intervention = get_object_or_404(Intervention, pk=intervention_id) if intervention_id else None control = Control() control.control_type = control_type control.status = 'in_progress' control.visibility = 'intern' control.control_author = request.user if intervention: # Description : "Intervention Ixxxx - Titre" description = f"Intervention {intervention.code}" if intervention.title: description += f" - {intervention.title}" control.description = description # Localisation if intervention.address: control.location = intervention.address else: first_location = intervention.locations.first() if hasattr(intervention, 'locations') else None if first_location and first_location.location: loc_obj = first_location.location loc_name = "" if hasattr(loc_obj, 'get_name'): loc_name = loc_obj.get_name() elif hasattr(loc_obj, 'name'): loc_name = loc_obj.name else: loc_name = str(loc_obj) control.location = f"{loc_obj.code} - {loc_name}" if hasattr(loc_obj, 'code') else loc_name # Coordonnées géographiques if intervention.lon is not None and intervention.lat is not None: control.lon = intervention.lon control.lat = intervention.lat control.save() if intervention: control.interventions.add(intervention) if intervention.thematic: control.thematics.add(intervention.thematic) if intervention.asset_category: control.categories.add(intervention.asset_category) return redirect_with_variant('controls:controls_detail', variant=variant, url_kwargs={'control_id': control.id}) @control_change_required def change_control(request, control_id): control = get_object_or_404(Control, pk=control_id) variant = request.POST.get('v') or request.GET.get('v') if "/mobile/" in request.path: variant = 'mobile' if request.method == "POST": post_data = request.POST.copy() # Récupérer interventions depuis select multiple try: intervention_ids_list = [int(x) for x in post_data.getlist('interventions') if str(x).strip().isdigit()] except Exception: intervention_ids_list = [] # Compat CSV ancien hidden if not intervention_ids_list: raw_csv = post_data.get('interventions', '').strip() if raw_csv: intervention_ids_list = [int(x) for x in raw_csv.split(',') if x.strip().isdigit()] post_data.setlist('interventions', [str(x) for x in intervention_ids_list]) form = ControlForm(post_data, instance=control) if form.is_valid(): control = form.save(commit=False) control.control_author = request.user control.save() # Appliquer exactement la sélection courante control.interventions.set(intervention_ids_list) form.save_m2m() if variant == 'mobile': return redirect(reverse('mobile:controls_detail', kwargs={'control_id': control.id})) return redirect_with_variant('controls:controls_detail', variant=variant, url_kwargs={'control_id': control.id}) else: form = ControlForm(instance=control) # Préparer la liste des interventions existantes pour le template preselected_interventions = [] for inter in control.interventions.all(): preselected_interventions.append({ 'id': inter.id, 'code': inter.code, 'title': inter.title or '', 'label': f"{inter.code} - {inter.title or ''}", 'status': inter.get_status_display(), 'status_color': inter.get_status_color(), }) template_name = 'controls/controls_form_mobile.html' if variant == 'mobile' else 'controls/controls_form.html' return render(request, template_name, { 'form': form, 'control': control, 'preselected_interventions': json.dumps(preselected_interventions) }) def controls_projects(request): projects = filter_viewable_projects_for_user(request.user) projects = projects.exclude(status__in=['canceled', 'archived', 'closed', 'invoiced']) projects = projects.annotate(geom_4326=Transform(Centroid('geom'), 4326)) projects_list = [] for project in projects: x = project.geom_4326.x if project.geom_4326 else None y = project.geom_4326.y if project.geom_4326 else None project_dict = { 'id': project.id, 'name': project.name, 'description': project.description, 'link': project.get_absolute_url(), # Use get_absolute_url for the link 'coordinates': [x, y] # Extract coordinates from the transformed centroid } projects_list.append(project_dict) context = { 'projects': projects, 'projects_list': projects_list, # for javascript usage } return render(request, "controls/controls_projects_mobile.html", context) @control_view_required def controls_detail(request, control_id): control = get_object_or_404(Control, id=control_id) variant = request.POST.get('v') or request.GET.get('v') if "/mobile/" in request.path: variant = 'mobile' remark_form = RemarkForm(request.POST or None, request.FILES or None) empty_remark = Remark() qty_post_data = None if request.method == "POST" and "submit_remark" in request.POST: qty_post_data = _normalize_qty_management_form(request.POST) qty_formset = QuantityFormSet( qty_post_data, prefix="qty", instance=Remark(), queryset=ControlQuantity.objects.none(), user=request.user, ) # qty_formset = None if request.method == "POST": if 'submit_remark' in request.POST: if remark_form.is_valid() and qty_formset.is_valid(): remark = remark_form.save(commit=False) remark.control = control remark.updated_by = request.user # Assigner explicitement les coordonnées lon/lat si elles sont présentes if request.POST.get('longitude'): remark.lon = float(request.POST.get('longitude')) if request.POST.get('latitude'): remark.lat = float(request.POST.get('latitude')) # Assigner les coordonnées x/y relatives au plan si présentes if request.POST.get('coord_x'): remark.x = float(request.POST.get('coord_x')) if request.POST.get('coord_y'): remark.y = float(request.POST.get('coord_y')) if (request.POST.get("reference_plan") != "map"): remark.reference_plan = ControlReferencePlan.objects.get(id=request.POST.get("reference_plan")) remark.save() # Créer une entrée d'historique initiale history_entry = RemarkHistory.objects.create( remark=remark, status=remark.status, description=remark.description, created_by=request.user ) files = remark_form.cleaned_data["images"] for file in files: image = Image.open(file) width, height = image.size RemarkImage.objects.create(remark=remark, history_entry=history_entry, file=file) category_ids = request.POST.get("categories", "").split(",") categories = Category.objects.filter(id__in=category_ids) if category_ids != [''] else [] for category in categories: RemarkTag.objects.create(remark=remark, category=category) qty_formset.instance = remark qty_formset.queryset = ControlQuantity.objects.filter(remark=remark) for f in qty_formset.forms: if not f.cleaned_data or f.cleaned_data.get('DELETE'): continue cp_id = f.cleaned_data.get('contract_post_id') if cp_id: try: f.instance.contract_post = ContractPost.objects.get(id=cp_id) except ContractPost.DoesNotExist: f.instance.contract_post = None qty_formset.save() messages.success(request, _("Remarque sauvegardée avec succès !")) #return redirect("controls:control_detail", control_id=control.id) else: messages.error(request, _("Erreur lors de la sauvegarde de la remarque.")) elif 'submit_plan' in request.POST: form = ControlReferencePlanForm(request.POST, request.FILES) if form.is_valid(): reference_plan = form.save(commit=False) reference_plan.control = control reference_plan.uploaded_by = request.user # Récupérer le fichier téléchargé uploaded_file = form.cleaned_data['plan'] # Vérifier si le fichier est un PDF if uploaded_file and uploaded_file.name.endswith('.pdf'): try: # Convertir le PDF en PNG pdf_path = os.path.join(settings.MEDIA_ROOT, uploaded_file.name) png_path = pdf_path.replace('.pdf', '.png') # Sauvegarder temporairement le fichier PDF with open(pdf_path, 'wb+') as destination: for chunk in uploaded_file.chunks(): destination.write(chunk) # Convertir le PDF en image PNG avec pdf2image images = convert_from_path(pdf_path, first_page=1, last_page=1, dpi=150) if not images: raise Exception("Could not convert PDF page to image") image = images[0] max_size = (4096, 4096) # Taille maximale pour le PNG image.thumbnail(max_size) image.save(png_path) # Sauvegarder l'image en PNG # Remplacer le fichier PDF par le PNG dans l'instance du formulaire with open(png_path, 'rb') as png_file: reference_plan.plan.save( os.path.basename(png_path), png_file, save=False ) # Supprimer les fichiers temporaires os.remove(pdf_path) except Exception as e: messages.error(request, f'Error converting PDF to PNG: {str(e)}') # Sauvegarder l'instance du formulaire reference_plan.save() messages.success(request, _("Plan ajouté avec succès !")) else: messages.error(request, _("Erreur lors de la sauvegarde du plan.")) remarks = control.remarks.exclude(archived=True) archives = control.remarks.filter(archived=True) plans = control.reference_plans.all() reports = control.reports.all() categories = Category.objects.all() remark_form = RemarkForm() upload_plan_form = ControlReferencePlanForm() report_template = control.templates.first() can_change_control = user_can_change_control(request.user, control) # Préparer les données des plans pour JavaScript plans_data = [ { "id": plan.id, "name": plan.name, "url": build_private_url("controls", "ControlReferencePlan", plan.id, "plan"), # URL de l'image "min_x": 0, # min_x est toujours 0 "min_y": 0, # min_y est toujours 0 "max_x": plan.max_x, "max_y": plan.max_y, } for plan in plans ] # Préparer les données GeoJSON des remarques remarks_data = { "type": "FeatureCollection", "features": [ { "type": "Feature", "id": remark.id, "geometry": { "type": "Point", "coordinates": [remark.x, remark.y] }, "properties": { "id": remark.id, "remark_number": remark.remark_number, "status": remark.status, "reference_plan": remark.reference_plan.id if remark.reference_plan else "map", } } for remark in remarks.order_by("remark_number") if remark.x and remark.y ] } # quantities = control.quantities.all().select_related("contract", "contract_post") # qty_form = ControlQuantityForm(initial={"control": control.id}) template_name = 'controls/controls_detail_mobile.html' if variant == 'mobile' else 'controls/controls_detail.html' context = { "control": control, "remarks": remarks, "archives": archives, "plans": plans, "reports": reports, "categories": categories, "remark_form": remark_form, "qty_formset": qty_formset, "upload_plan_form": upload_plan_form, "plans_data": plans_data, "remarks_data": remarks_data, "report_template": report_template, "can_change_control": can_change_control, } return render(request, template_name, context) STATUS_LABELS = { "information": "Info", "to_be_processed": "NOK", "processed": "Traité", "validated": "OK", "canceled": "Annulé" } STATUS_CLASSES = { "information": "bg-info", "to_be_processed": "bg-danger", "processed": "bg-primary", "validated": "bg-success", "canceled": "bg-secondary" } STATUS_ICONS = { "information": "bi-info-circle", "to_be_processed": "bi-exclamation-triangle", "processed": "bi-check-circle", "validated": "bi-shield-check", "canceled": "bi-x-circle" } @transaction.atomic def save_categories(request, remark): # 1) IDs postés, nettoyés raw = request.POST.get('categories', '') posted_ids = {int(x) for x in raw.split(',') if x.isdigit()} # 2) Ne garder que des catégories valides (au cas où un ID n'existe pas) valid_ids = set( Category.objects.filter(id__in=posted_ids).values_list('id', flat=True) ) # 3) IDs déjà liés à la remarque existing_ids = set( RemarkTag.objects .filter(remark=remark) .values_list('category_id', flat=True) ) # 4) À ajouter / à retirer to_add = valid_ids - existing_ids to_remove = existing_ids - valid_ids # 5) Ajout sans doublons RemarkTag.objects.bulk_create( [RemarkTag(remark=remark, category_id=cid) for cid in to_add], ignore_conflicts=True, # safe si contrainte unique(remark, category) ) # 6) Suppression des tags décochés (optionnel, retire cette partie si tu ne veux pas “désélectionner”) if to_remove: RemarkTag.objects.filter(remark=remark, category_id__in=to_remove).delete() def change_remark(request, remark_id): remark = get_object_or_404(Remark, pk=remark_id) variant = request.POST.get('v') or request.GET.get('v') if "/mobile/" in request.path: variant = 'mobile' remark_form = RemarkForm(request.POST or None, request.FILES or None, instance=remark) qty_post_data = _normalize_qty_management_form(request.POST) if request.method == "POST" else None qty_formset = QuantityFormSet( qty_post_data, prefix="qty", instance=remark, user=request.user, ) # permissions if not user_can_change_control(request.user, remark.control): return HttpResponseForbidden(_("Vous n'avez pas le droit de modifier cette remarque.")) if request.method == "POST": if remark_form.is_valid() and qty_formset.is_valid(): remark = remark_form.save(commit=False) remark.updated_by = request.user if (request.POST.get("reference_plan") != "map"): remark.reference_plan = ControlReferencePlan.objects.get(id=request.POST.get("reference_plan")) remark.save() create_new = request.POST.get('new_history_entry') == 'on' if create_new: history_entry = RemarkHistory.objects.create( remark=remark, status=remark.status, description=remark.description, created_by=request.user ) else: history_entry = remark.history.order_by('created_at').last() if not history_entry: history_entry = RemarkHistory.objects.create( remark=remark, status=remark.status, description=remark.description, created_by=request.user ) else: history_entry.status = remark.status history_entry.description = remark.description history_entry.created_by = request.user history_entry.save() files = remark_form.cleaned_data["images"] for file in files: RemarkImage.objects.create(remark=remark, history_entry=history_entry, file=file) selected_category_ids = [int(x) for x in request.POST.get('categories', '').split(',') if x] save_categories(request, remark) for f in qty_formset.forms: if not f.cleaned_data or f.cleaned_data.get('DELETE'): continue cp_id = f.cleaned_data.get('contract_post_id') if cp_id: try: f.instance.contract_post = ContractPost.objects.get(id=cp_id) except ContractPost.DoesNotExist: f.instance.contract_post = None qty_formset.save() messages.success(request, _("Remarque sauvegardée avec succès !")) if variant == 'mobile': return redirect(reverse('mobile:controls_detail', kwargs={'control_id': remark.control.id})) return redirect_with_variant('controls:controls_detail', variant=variant, url_kwargs={'control_id': remark.control.id}) else: selected_category_ids = list(remark.remark_tags.values_list('category_id', flat=True)) if remark.pk else [] plans = remark.control.reference_plans.all() categories = Category.objects.all() # Préparer les données des plans pour JavaScript plans_data = [ { "id": plan.id, "name": plan.name, "url": build_private_url("controls", "ControlReferencePlan", plan.id, "plan"), # URL de l'image "min_x": 0, # min_x est toujours 0 "min_y": 0, # min_y est toujours 0 "max_x": plan.max_x, "max_y": plan.max_y, } for plan in plans ] # Préparer les données GeoJSON des remarques remarks_data = { "type": "FeatureCollection", "features": [ { "type": "Feature", "id": remark.id, "geometry": { "type": "Point", "coordinates": [remark.x, remark.y] }, "properties": { "id": remark.id, "remark_number": remark.remark_number, "status": remark.status, "reference_plan": remark.reference_plan.id if remark.reference_plan else "map", } } for remark in remark.control.remarks.order_by("remark_number") if remark.x and remark.y ] } template_name = 'controls/remarks_form_mobile.html' if variant == 'mobile' else 'controls/remarks_form.html' return render(request, template_name, { "remark_form": remark_form, "qty_formset": qty_formset, "mode": "update", "plans": plans, "categories": categories, "selected_category_ids": selected_category_ids, "remark": remark, "remarks_data": remarks_data, "plans_data": plans_data }) def update_remark_status(request, remark_id, new_status): """Met à jour le statut d'une remarque""" if request.method == "POST": try: remark = Remark.objects.get(id=remark_id) # permissions if not user_can_change_control(request.user, remark.control): return JsonResponse({"success": False, "error": _("Vous n'avez pas le droit de modifier cette remarque.")}, status=403) remark.status = new_status remark.updated_by = request.user remark.updated_at = now() remark.save() # Créer une entrée d'historique RemarkHistory.objects.create( remark=remark, status=new_status, description=_("Statut modifié en %s") % _(STATUS_LABELS.get(new_status, new_status)), created_by=request.user ) return JsonResponse({ "success": True, "new_status": new_status, "new_status_label": STATUS_LABELS.get(new_status, "Inconnu"), "new_status_class": STATUS_CLASSES.get(new_status, "bg-light"), "new_status_icon": STATUS_ICONS.get(new_status, "bi-question-circle"), "updated_by": request.user.username, "updated_at": remark.updated_at.strftime("%d/%m/%Y %H:%M") }) except Remark.DoesNotExist: return JsonResponse({"success": False, "error": _("Remarque introuvable")}, status=404) return JsonResponse({"success": False, "error": _("Requête invalide")}, status=400) @require_POST def archive_remark(request, remark_id): remark = get_object_or_404(Remark, id=remark_id) # permissions if not user_can_change_control(request.user, remark.control): return JsonResponse({"success": False, "error": _("Vous n'avez pas le droit de modifier cette remarque.")}, status=403) remark.archived = True remark.save() return JsonResponse({'success': True}) @require_POST def restore_remark(request, remark_id): remark = get_object_or_404(Remark, id=remark_id) # permissions if not user_can_change_control(request.user, remark.control): return JsonResponse({"success": False, "error": _("Vous n'avez pas le droit de modifier cette remarque.")}, status=403) remark.archived = False remark.save() return JsonResponse({ 'success': True, "status": remark.status }) @require_POST def delete_remark_image(request, image_id): image = get_object_or_404(RemarkImage, id=image_id) remark = image.remark if not user_can_change_control(request.user, remark.control): return JsonResponse({"success": False, "error": _("Vous n'avez pas le droit de modifier cette remarque.")}, status=403) image.delete() return JsonResponse({'success': True}) @control_view_required def control_report(request, control_id): """ Affiche le rapport d’un contrôle en HTML avec une carte intégrée """ control = get_object_or_404(Control, id=control_id) return render(request, "controls/control_report.html", {"control": control}) def change_template(request, template_id): template = get_object_or_404(ControlReportTemplate, id=template_id) variant = request.POST.get('v') or request.GET.get('v') if "/mobile/" in request.path: variant = 'mobile' # permissions if not user_can_change_control(request.user, template.control): return HttpResponseForbidden(_("Vous n'avez pas le droit de modifier ce contrôle.")) if request.method == "POST": form = ControlReportTemplateForm(request.POST, instance=template) if form.is_valid(): updated_template = form.save(commit=False) updated_template.updated_by = request.user updated_template.save() if variant == 'mobile': return redirect(reverse('mobile:controls_detail', kwargs={'control_id': template.control.id})) return redirect_with_variant('controls:controls_detail', variant=variant, url_kwargs={'control_id': template.control.id}) else: form = ControlReportTemplateForm(instance=template) template_name = 'controls/change_template_mobile.html' if variant == 'mobile' else 'controls/change_template.html' return render(request, template_name, {'form': form, 'template': template})