1131 lines
43 KiB
Python
1131 lines
43 KiB
Python
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})
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|