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

1131 lines
43 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.

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