from django.shortcuts import render, get_object_or_404, redirect from django.urls import reverse from django.contrib.gis.db.models.functions import Transform, Centroid from django.contrib.gis.geos import GEOSGeometry from django.http import JsonResponse from django.views.decorators.http import require_POST, require_GET from django.contrib.auth import get_user_model from django.db.models import Q from django.contrib import messages from django.utils.translation import gettext as _ from django.template.loader import render_to_string from urllib.parse import urlencode from django.core.exceptions import PermissionDenied import json from common.utils import redirect_with_variant from documents.services import DocumentManagementService from .models import Project, ProjectPhase, ProjectUserAccess, ProjectTeamAccess, ProjectDocument, ProjectTag from .models import ProjectTemplate, ProjectTemplateAttribute, ProjectAttributeValue from common.models import UserConfig, Thematic from contracts.models import ContractOrder, CompanyTeam from .models import STATUS_CHOICES from .forms import ProjectUserAccessForm, ProjectTeamAccessForm, ProjectForm, ProjectDocumentForm, ProjectOrderAssociationForm, ProjectPhaseForm from projects.permissions import project_permission_required, project_add_or_edit_permission_required, filter_viewable_projects_for_user, can_add_or_edit_project, can_add_project_documents, can_manage_project_orders, can_manage_project_members, project_manage_members_permission_required from controls.permissions import user_can_add_control from interventions.permissions import can_view_intervention, can_view_intervention_summary, can_add_intervention from contracts.permissions import user_can_view_contract_prices def _get_allowed_statuses_for_project(user, project): """Retourne la liste des statuts sur lesquels l'utilisateur peut basculer.""" allowed = [] if can_add_or_edit_project(user, project, ignore_status_lock=True): allowed = list(STATUS_CHOICES.keys()) # On interdit l'annulation pour les statuts déjà clôturés/archivés if project.status in ['finished', 'invoiced', 'closed', 'canceled']: allowed = [status for status in allowed if status != 'canceled'] return allowed def projects_index(request): # TODO: handle filters for mobile view as well; unify both views if possible if "/mobile/" in request.path: 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': reverse('mobile:projects_detail', args=[project.id]), '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, "projects/projects_index_mobile.html", context) else: user_config = getattr(request.user, 'config', None) saved_filters = (user_config.project_list_filters or {}) if user_config else {} saved_sort = (user_config.project_list_sort or {}) if user_config else {} # Redirect on first visit with saved preferences if not request.GET: if saved_filters: query_params = request.GET.copy() for key, value in saved_filters.items(): if isinstance(value, list): query_params.setlist(key, value) elif value is not None: query_params[key] = value from django.http import HttpResponseRedirect return HttpResponseRedirect(f"{request.path}?{query_params.urlencode()}") # Retrieve filters from URL statuses = request.GET.getlist('status') # maintain_types = request.GET.getlist('maintain_type') begin_date = request.GET.get('begin_date') end_date = request.GET.get('end_date') selected_tags = request.GET.getlist('tag') selected_thematics = request.GET.getlist('thematic') context = { 'status_choices': Project._meta.get_field('status').choices, # 'maintain_type_choices': Project._meta.get_field('maintain_type').choices, 'selected_statuses': statuses, # 'selected_maintain_types': maintain_types, 'begin_date': begin_date, 'end_date': end_date, 'is_add_project_allowed': can_add_or_edit_project(request.user), 'tags': ProjectTag.objects.all(), 'selected_tags': selected_tags, 'thematic_choices': Thematic.objects.all(), 'selected_thematics': selected_thematics, 'saved_sort': json.dumps(saved_sort, ensure_ascii=False), } return render(request, "projects/projects_index.html", context) def projects_geojson(request): statuses = request.GET.getlist('status') # maintain_types = request.GET.getlist('maintain_type') begin_date = request.GET.get('begin_date') end_date = request.GET.get('end_date') tags = request.GET.getlist('tag') thematics = request.GET.getlist('thematic') user_config = UserConfig.objects.filter(user=request.user).first() # permissions projects = filter_viewable_projects_for_user(request.user) projects = projects.annotate(geom_4326=Transform(Centroid('geom'), 4326)) if statuses: projects = projects.filter(status__in=statuses) # if maintain_types: # projects = projects.filter(maintain_type__in=maintain_types) if begin_date: projects = projects.filter(start_date__gte=begin_date) if end_date: projects = projects.filter(end_date__lte=end_date) if tags: projects = projects.filter(tags__slug__in=tags).distinct() if thematics: projects = projects.filter(thematics__code__in=thematics).distinct() def obj_to_feature(obj): geometry = json.loads(obj.geom_4326.geojson) if obj.geom_4326 else None return { "type": "Feature", "id": obj.id, "geometry": geometry, "properties": { "code": obj.code, "title": obj.name, "begin_time": obj.start_date, "status": obj.status, } } features = [] count = 0 max_features = 5000 for proj in projects: if count >= max_features: break features.append(obj_to_feature(proj)) count += 1 geojson_data = { "type": "FeatureCollection", "features": features, } return JsonResponse(geojson_data, safe=False) @project_add_or_edit_permission_required def projects_add_or_change(request, project_id=None): project = None initial_data = {} if project_id: project = get_object_or_404(Project, pk=project_id) else: # Valeurs initiales uniquement à la création try: user_config = UserConfig.objects.get(user=request.user) if user_config.default_thematic: initial_data['thematics'] = [user_config.default_thematic.id] except UserConfig.DoesNotExist: pass form = ProjectForm( request.POST or None, instance=project, initial=initial_data, user=request.user, is_update=bool(project_id), ) if request.method == 'POST' and form.is_valid(): project = form.save(commit=False) # On assigne le créateur si c’est une création if not project.pk: project.created_by = request.user geojson_str = request.POST.get('geom_geojson') if geojson_str: try: # 1. Désérialiser la chaîne JSON geojson_data = json.loads(geojson_str) # 2. Vérifier que c’est bien une FeatureCollection avec au moins une feature if geojson_data.get('type') == 'FeatureCollection' and geojson_data.get('features'): first_feature = geojson_data['features'][0] geometry = first_feature.get('geometry') if geometry: # 3. Reconvertir la géométrie seule en GeoJSON geom = GEOSGeometry(json.dumps(geometry)) geom.srid = 4326 # Geojson en WGS84 geom.transform(3812) # Reprojette en SRID 3812 project.geom = geom except Exception as e: print("Erreur lors du traitement de la géométrie GeoJSON : {e}") project.save() form.save_m2m() # <--- indispensable pour sauvegarder les thematics return redirect(project.get_absolute_url()) geom_geojson = None if project and project.geom: geom_geojson = project.geom.transform(4326, clone=True).geojson # reprojection WGS84 return render(request, 'projects/projects_form.html', { 'form': form, 'project': project, 'mode': 'update' if project_id else 'create', 'geom_geojson': geom_geojson, }) def _get_project_templates_with_values(project): """ Returns a list of dicts, one per template associated to the project. Each dict has 'template' and 'attributes', where each attribute has its current 'value' object (or None). """ result = [] existing_values = { v.attribute_id: v for v in project.attribute_values.select_related('attribute__choice_set').all() } for template in project.templates.prefetch_related('attributes__choice_set__choices').all(): attrs = [] for attr in template.attributes.order_by('order', 'name_fr'): value = existing_values.get(attr.pk) choices = list(attr.choice_set.choices.values('value', 'label_fr', 'label_nl').order_by('order', 'label_fr')) if attr.choice_set else [] attrs.append({'attr': attr, 'value': value, 'choices': choices}) result.append({'template': template, 'attributes': attrs}) return result @project_permission_required def projects_detail(request, project_id): project = get_object_or_404(Project, pk=project_id) variant = request.GET.get('v') if "/mobile/" in request.path: variant = "mobile" # ── Document management (commun desktop + mobile) ── doc_data = DocumentManagementService.get_documents_for_asset(project) project_documents = doc_data.get("main_documents", []) + doc_data.get("other_documents", []) document_folder = doc_data.get("folder") use_managed_documents = document_folder is not None user_doc_config = getattr(request.user, "config", None) can_add_unique_tag_docs = False if user_doc_config and user_doc_config.is_intern: can_add_unique_tag_docs = True elif user_doc_config: can_add_unique_tag_docs = user_doc_config.can_add_unique_tag_documents can_browse_folder = False if document_folder: from documents.permissions import user_can_browse_folder can_browse_folder = user_can_browse_folder(request.user, document_folder) if variant == "mobile": return render(request, "projects/projects_detail_mobile.html", { "project": project, 'can_add_documents': can_add_project_documents(request.user, project), 'project_documents': project_documents, 'use_managed_documents': use_managed_documents, 'document_folder': document_folder, 'can_add_unique_tag_documents': can_add_unique_tag_docs, 'can_browse_folder': can_browse_folder, }) geom_geojson = None if project.geom: geom_4326 = project.geom.transform(4326, clone=True) point_4326 = geom_4326.centroid project.lon = point_4326.x project.lat = point_4326.y geom_geojson = { "type": "FeatureCollection", "features": [ { "type": "Feature", "geometry": json.loads(geom_4326.geojson), "properties": { "code": project.code, "title": project.name } } ] } # Filtrer les commandes visibles selon le droit can_view_prices sur le contrat orders_qs = project.orders.select_related('contract').all() viewable_orders = [order for order in orders_qs if user_can_view_contract_prices(request.user, order.contract_id)] can_manage_orders = can_manage_project_orders(request.user, project) interventions = [] for project_intervention in project.interventions.select_related('intervention'): intervention = project_intervention.intervention interventions.append({ "intervention": intervention, "can_view": can_view_intervention(request.user, intervention), "can_view_summary": can_view_intervention_summary(request.user, intervention), }) _PROJECT_BLOCKED_STATUSES = {'finished', 'invoiced', 'closed', 'canceled'} is_edit_allowed = can_add_or_edit_project(request.user, project) # Un externe explicitement ajouté au projet peut ajouter des interventions # tant que le projet n'est pas terminé/clôturé/annulé. can_add_interventions = is_edit_allowed and project.status not in _PROJECT_BLOCKED_STATUSES if not can_add_interventions and project.status not in _PROJECT_BLOCKED_STATUSES: _user_config = getattr(request.user, 'config', None) if _user_config and not _user_config.is_intern: for _thematic in project.thematics.all(): if can_add_intervention(request.user, _thematic): can_add_interventions = True break if not can_add_interventions: can_add_interventions = can_add_intervention(request.user, None) return render(request, "projects/projects_detail.html", { "project": project, "interventions": interventions, "members": project.user_accesses.select_related('user').order_by('user__last_name'), "team_accesses": project.team_accesses.select_related('team').order_by('team__name'), "orders": viewable_orders, "status_choices" : [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()], 'geom_geojson': geom_geojson, 'add_member_form': ProjectUserAccessForm(), 'add_team_form': ProjectTeamAccessForm(), 'teams_list': [ {'id': t.id, 'name': t.name, 'company': t.company.name} for t in CompanyTeam.objects.select_related('company').order_by('company__name', 'name') ], 'is_edit_allowed': is_edit_allowed, 'can_add_interventions': can_add_interventions, 'can_add_documents': can_add_project_documents(request.user, project), 'can_manage_orders': can_manage_orders, 'can_manage_members': can_manage_project_members(request.user, project), 'can_add_controls': user_can_add_control(request.user) and is_edit_allowed, 'add_document_form': ProjectDocumentForm(), 'associate_order_form': ProjectOrderAssociationForm(project=project, user=request.user), 'add_phase_form': ProjectPhaseForm(user=request.user), 'allowed_statuses': json.dumps(_get_allowed_statuses_for_project(request.user, project)), # Managed document system 'project_documents': project_documents, 'use_managed_documents': use_managed_documents, 'document_folder': document_folder, 'can_add_unique_tag_documents': can_add_unique_tag_docs, 'can_browse_folder': can_browse_folder, 'available_tags': ProjectTag.objects.exclude(pk__in=project.tags.all()), # Template attributes 'project_templates': _get_project_templates_with_values(project), 'available_templates': ProjectTemplate.objects.filter(is_active=True).exclude(pk__in=project.templates.all()), }) @project_permission_required def projects_phases(request, project_id): project = get_object_or_404(Project, pk=project_id) if project: phases = project.phases.values('id', 'name') return JsonResponse(list(phases), safe=False) return JsonResponse([], safe=False) @require_POST @project_manage_members_permission_required def projects_add_member(request, project_id): project = get_object_or_404(Project, id=project_id) form = ProjectUserAccessForm(request.POST) if form.is_valid(): access_instance, _created = ProjectUserAccess.objects.update_or_create( project=project, user=form.cleaned_data['user'], defaults={ 'can_view': form.cleaned_data['can_view'], 'can_edit': form.cleaned_data['can_edit'], }, ) return JsonResponse({ 'success': True, 'member': { 'id': access_instance.id, 'first_name': access_instance.user.first_name, 'last_name': access_instance.user.last_name, 'can_edit': access_instance.can_edit, } }) else: return JsonResponse({'success': False, 'errors': form.errors}) @require_POST @project_manage_members_permission_required def projects_add_team_access(request, project_id): project = get_object_or_404(Project, id=project_id) form = ProjectTeamAccessForm(request.POST) if form.is_valid(): access_instance, _created = ProjectTeamAccess.objects.update_or_create( project=project, team=form.cleaned_data['team'], defaults={ 'can_view': form.cleaned_data['can_view'], 'can_edit': form.cleaned_data['can_edit'], }, ) return JsonResponse({ 'success': True, 'team_access': { 'id': access_instance.id, 'team_name': access_instance.team.name, 'team_company': access_instance.team.company.name, 'can_edit': access_instance.can_edit, } }) else: return JsonResponse({'success': False, 'errors': form.errors}) @require_POST @project_manage_members_permission_required def projects_delete_member(request, project_id, member_id): try: access = ProjectUserAccess.objects.get(id=member_id) access.delete() return JsonResponse({'success': True}) except ProjectUserAccess.DoesNotExist: return JsonResponse({'success': False, 'error': _('Accès introuvable')}) @require_POST @project_manage_members_permission_required def projects_delete_team_access(request, project_id, access_id): try: access = ProjectTeamAccess.objects.get(id=access_id) access.delete() return JsonResponse({'success': True}) except ProjectTeamAccess.DoesNotExist: return JsonResponse({'success': False, 'error': _('Accès introuvable')}) @require_GET def user_search(request): project_id = request.GET.get('project_id') project = get_object_or_404(Project, pk=project_id) if project_id else None if not project or not can_manage_project_members(request.user, project): raise PermissionDenied(_("Seul le responsable du projet ou un manager peut gérer les membres.")) User = get_user_model() term = request.GET.get('q', '') users = User.objects.filter( Q(first_name__icontains=term) | Q(last_name__icontains=term) )[:20] results = [] for user in users: results.append({ 'id': user.id, 'text': f"{user.first_name} {user.last_name} ({user.username})" }) return JsonResponse({'results': results}) @require_POST @project_permission_required def projects_add_tag(request, project_id): project = get_object_or_404(Project, pk=project_id) if not can_add_or_edit_project(request.user, project): raise PermissionDenied(_("Vous n'avez pas la permission de modifier ce projet.")) tag_id = request.POST.get('tag_id') tag = get_object_or_404(ProjectTag, pk=tag_id) project.tags.add(tag) return redirect(project.get_absolute_url()) @require_POST @project_permission_required def projects_remove_tag(request, project_id, tag_id): project = get_object_or_404(Project, pk=project_id) if not can_add_or_edit_project(request.user, project): raise PermissionDenied(_("Vous n'avez pas la permission de modifier ce projet.")) tag = get_object_or_404(ProjectTag, pk=tag_id) project.tags.remove(tag) return redirect(project.get_absolute_url()) @project_permission_required def projects_add_document(request, project_id): project = get_object_or_404(Project, id=project_id) if not can_add_project_documents(request.user, project): raise PermissionDenied(_("Vous n'avez pas la permission d'ajouter des documents.")) if request.method == 'POST': document_folder = DocumentManagementService.get_default_folder_for_object(project) if document_folder is not None: # ── New managed document system ── from documents.models import DocumentTag, DocumentFolder count = int(request.POST.get('file_count', 0)) files = request.FILES.getlist('files') for i in range(count): if i >= len(files): break file = files[i] tag_slug = request.POST.get(f'tag_{i}', None) folder_id = request.POST.get(f'folder_{i}', None) # Resolve tag (None is allowed – document will be stored without tag) tag = None if tag_slug: try: tag = DocumentTag.objects.get(slug=tag_slug) except DocumentTag.DoesNotExist: pass if tag and tag.is_unique: if not DocumentManagementService.can_user_add_unique_tag_document(request.user, tag): continue target_folder = document_folder if folder_id and (not tag or not tag.is_unique): try: target_folder = DocumentFolder.objects.get(pk=int(folder_id)) except (DocumentFolder.DoesNotExist, ValueError): target_folder = document_folder # Préparer les unique_params # Le format est automatiquement ajouté par le service d'après l'extension du fichier. # On ne passe ici que les paramètres extra spécifiques au tag. unique_params = None if tag and tag.slug == 'crossplan': unique_param_value = request.POST.get(f'unique_param_{i}', '').strip() if unique_param_value: unique_params = {'cables': unique_param_value} DocumentManagementService.add_document( file=file, tag=tag, folder=document_folder, user=request.user, content_object=project, target_folder=target_folder, unique_params=unique_params, ) messages.success(request, _("Document(s) sauvegardé(s) avec succès !")) else: # ── Legacy ProjectDocument system ── files = request.FILES.getlist('files') if files: for file in files: ProjectDocument.objects.create(project=project, file=file, uploaded_by=request.user) messages.success(request, _("Document(s) sauvegardé(s) avec succès !")) else: form = ProjectDocumentForm(request.POST, request.FILES) if form.is_valid(): for file in form.cleaned_data["documents"]: ProjectDocument.objects.create(project=project, file=file, uploaded_by=request.user) messages.success(request, _("Document(s) sauvegardé(s) avec succès !")) else: messages.error(request, _("Erreur lors de la sauvegarde des documents.")) return redirect(project.get_absolute_url()) form = ProjectDocumentForm() return render(request, 'projects/add_project_document.html', {'form': form, 'project': project}) @project_permission_required def projects_add_intervention(request, project_id): """ Redirects to the intervention creation form with the project_id and maintain_type set to ameliorative. Logic: - Get thematics that the user can edit interventions for (from UserThematics) - Intersect with the project's thematics - If only one thematic: redirect directly to intervention creation - If multiple thematics: show thematic choice page - If no thematics: show error message """ project = get_object_or_404(Project, id=project_id) # Bloquer si le projet est terminé ou au-delà _BLOCKED = {'finished', 'invoiced', 'closed', 'canceled'} if project.status in _BLOCKED: messages.error(request, _("Impossible d'ajouter une intervention à un projet terminé ou clôturé.")) return redirect(project.get_absolute_url()) user_config = getattr(request.user, "config", None) if not user_config: messages.error(request, _("Vous n'avez pas de configuration utilisateur.")) return redirect(project.get_absolute_url()) project_thematics = list(project.thematics.all()) if not user_config.is_intern: # Externe : vérifier les droits de création via contrats allowed_thematics = [t for t in project_thematics if can_add_intervention(request.user, t)] if not allowed_thematics and can_add_intervention(request.user, None): allowed_thematics = project_thematics else: # Interne : vérifier les thématiques éditables from common.models import UserThematics editable_links = UserThematics.objects.filter( user_config=user_config, can_edit_interventions=True ).select_related('thematic') user_thematics = {ut.thematic for ut in editable_links} allowed_thematics = list(user_thematics & set(project_thematics)) if not allowed_thematics: messages.error(request, _("Vous n'avez pas la permission de créer des interventions pour les thématiques de ce projet.")) return redirect(project.get_absolute_url()) # If only one thematic: direct redirect if len(allowed_thematics) == 1: thematic = allowed_thematics[0] base_url = reverse('interventions:intervention_add', kwargs={'thematic_code': thematic.code}) params = urlencode({ 'maintain_type': 'ameliorative', 'project_id': project_id, 'redirect_to_project': 'true' }) return redirect(f"{base_url}?{params}") # Multiple thematics: go to choice page base_url = reverse('interventions:intervention_add_choose_thematic') params = { 'type': 'ameliorative', 'project_id': project_id, 'redirect_to_project': 'true', } # Add allowed thematic codes for filtering for thematic in allowed_thematics: params.setdefault('allowed_thematic', []) params['allowed_thematic'].append(thematic.code) # Build query string properly (urlencode doesn't handle lists well with setdefault) flat_params = [] for key, value in params.items(): if isinstance(value, list): for v in value: flat_params.append((key, str(v))) else: flat_params.append((key, str(value))) query_string = urlencode(flat_params) return redirect(f"{base_url}?{query_string}") @require_POST @project_permission_required def projects_associate_order(request, project_id): """ Associate an existing order with a project. """ project = get_object_or_404(Project, id=project_id) if not can_manage_project_orders(request.user, project): raise PermissionDenied(_("Seuls les administrateurs ou managers peuvent associer une commande.")) form = ProjectOrderAssociationForm(request.POST, project=project, user=request.user) if form.is_valid(): order = form.cleaned_data['order'] order.projects.add(project) messages.success(request, _("Commande associée au projet avec succès !")) else: messages.error(request, _("Erreur lors de l'association de la commande.")) return redirect(project.get_absolute_url()) @require_POST @project_add_or_edit_permission_required def projects_dissociate_order(request, project_id, order_id): """ Dissociate an order from a project. """ project = get_object_or_404(Project, id=project_id) order = get_object_or_404(ContractOrder, id=order_id) if project in order.projects.all(): order.projects.remove(project) return JsonResponse({'success': True}) return JsonResponse({'success': False, 'error': _('Cette commande n\'est pas associée à ce projet.')}) @project_permission_required def projects_create_order(request, project_id): """ Redirects to the order creation form with the project_id pre-filled. """ project = get_object_or_404(Project, id=project_id) if not can_manage_project_orders(request.user, project): raise PermissionDenied(_("Seuls les administrateurs ou managers peuvent créer une commande.")) # Build the URL for the order creation form with the project_id base_url = reverse('contracts:contracts_orders_add') params = urlencode({ 'project_id': project_id, }) return redirect(f"{base_url}?{params}") @require_POST @project_add_or_edit_permission_required def projects_add_phase(request, project_id): """ Add a new phase to a project. """ project = get_object_or_404(Project, id=project_id) form = ProjectPhaseForm(request.POST, user=request.user) if form.is_valid(): phase = form.save(commit=False) phase.project = project phase.save() messages.success(request, _("Phase ajoutée avec succès !")) else: messages.error(request, _("Erreur lors de l'ajout de la phase.")) return redirect(project.get_absolute_url()) @require_POST @project_add_or_edit_permission_required def projects_delete_phase(request, project_id, phase_id): """ Delete a phase from a project. """ project = get_object_or_404(Project, id=project_id) phase = get_object_or_404(ProjectPhase, id=phase_id, project=project) phase.delete() return JsonResponse({'success': True}) @require_POST @project_add_or_edit_permission_required def projects_delete_document(request, project_id, document_id): """ Delete a document from a project. """ project = get_object_or_404(Project, id=project_id) document = get_object_or_404(ProjectDocument, id=document_id, project=project) # Supprimer les fichiers associés if document.file: document.file.delete(save=False) if document.thumbnail: document.thumbnail.delete(save=False) document.delete() return JsonResponse({'success': True}) @require_GET @project_permission_required def projects_document_history(request, project_id): """ Returns an HTML fragment with the version history of a managed document tag attached to this project. Consumed by the documentHistoryModal via fetch(). """ project = get_object_or_404(Project, pk=project_id) tag_slug = request.GET.get('tag_slug') unique_params_str = request.GET.get('unique_params') user = request.user user_config = getattr(user, "config", None) is_delete_allowed = False if user_config and user_config.is_intern: if user_config.roles.filter(name__in=["admin", "manager"]).exists(): is_delete_allowed = True unique_params = None if unique_params_str: try: unique_params = json.loads(unique_params_str) except (json.JSONDecodeError, ValueError): unique_params = None versions = DocumentManagementService.get_document_history_by_tag(project, tag_slug, unique_params) html = render_to_string('assets/document_version_history_list.html', { 'versions': versions, 'is_delete_document_allowed': is_delete_allowed, 'use_managed_documents': True, }) return JsonResponse({'html': html}) @require_GET @project_permission_required def projects_document_config(request, project_id): """ Returns JSON config (tags, folders, feature flags) consumed by the add-document modal JS to build the per-file tag/folder selects. """ project = get_object_or_404(Project, pk=project_id) user = request.user user_config = getattr(user, "config", None) can_add_unique = False if user_config and user_config.is_intern: can_add_unique = True elif user_config: can_add_unique = user_config.can_add_unique_tag_documents document_folder = DocumentManagementService.get_default_folder_for_object(project) if document_folder is None: return JsonResponse({ 'use_managed_documents': False, 'can_add_unique_tag_documents': can_add_unique, 'tags': [], 'folders': [], }) # Tags scoped to the project's thematics (fall back to all tags if none) from documents.models import DocumentTag from django.utils.translation import get_language thematic_codes = list(project.thematics.values_list('code', flat=True)) if thematic_codes: tags_qs = DocumentTag.objects.filter(thematics__code__in=thematic_codes).distinct() else: tags_qs = DocumentTag.objects.all() lang = get_language() tags_qs = tags_qs.order_by('name_nl' if lang == 'nl' else 'name_fr') tags_data = [ { 'slug': tag.slug, 'name': tag.name, 'is_unique': tag.is_unique, 'show_in_asset_detail': tag.show_in_asset_detail, } for tag in tags_qs ] folders = DocumentManagementService.get_subfolders_for_object(project) return JsonResponse({ 'use_managed_documents': True, 'can_add_unique_tag_documents': can_add_unique, 'tags': tags_data, 'folders': folders, }) @require_POST @project_permission_required def projects_update_status(request, project_id): """Met à jour le statut d'un projet via AJAX.""" project = get_object_or_404(Project, pk=project_id) # Autorise le changement de statut même si le projet est verrouillé côté attributs if not can_add_or_edit_project(request.user, project, ignore_status_lock=True): return JsonResponse({'success': False, 'message': _("Vous n'avez pas la permission de modifier le statut." )}, status=403) allowed_statuses = _get_allowed_statuses_for_project(request.user, project) new_status = request.POST.get('new_status') if not new_status: return JsonResponse({'success': False, 'message': _("Aucun statut fourni.")}, status=400) if new_status not in STATUS_CHOICES: return JsonResponse({'success': False, 'message': _("Statut invalide.")}, status=400) if new_status not in allowed_statuses: return JsonResponse({'success': False, 'message': _("Statut non autorisé pour ce projet.")}, status=403) if new_status == project.status: return JsonResponse({'success': True, 'message': _("Le statut du projet est déjà défini.")}) old_status = project.status project.status = new_status project.save(update_fields=['status']) return JsonResponse({ 'success': True, 'message': _("Statut mis à jour avec succès."), 'old_status': old_status, 'new_status': new_status, }) @require_POST @project_manage_members_permission_required def projects_toggle_strict_access(request, project_id): """Active ou désactive l'accès strict d'un projet via AJAX.""" project = get_object_or_404(Project, pk=project_id) strict_access = request.POST.get('strict_access') == 'true' project.strict_access = strict_access project.save(update_fields=['strict_access']) return JsonResponse({'success': True, 'strict_access': project.strict_access}) # ─── Project Template Attribute Views ──────────────────────────────────────── @require_POST @project_permission_required def projects_add_template(request, project_id): """Associe un template au projet.""" project = get_object_or_404(Project, pk=project_id) if not can_add_or_edit_project(request.user, project): raise PermissionDenied(_("Vous n'avez pas la permission de modifier ce projet.")) template_id = request.POST.get('template_id') template = get_object_or_404(ProjectTemplate, pk=template_id, is_active=True) project.templates.add(template) # Pré-créer les entrées de valeur pour les attributs du template for attr in template.attributes.all(): ProjectAttributeValue.objects.get_or_create(project=project, attribute=attr) return redirect(project.get_absolute_url()) @require_POST @project_permission_required def projects_remove_template(request, project_id, template_id): """Dissocie un template du projet (supprime aussi les valeurs).""" project = get_object_or_404(Project, pk=project_id) if not can_add_or_edit_project(request.user, project): raise PermissionDenied(_("Vous n'avez pas la permission de modifier ce projet.")) template = get_object_or_404(ProjectTemplate, pk=template_id) project.templates.remove(template) # Supprimer les valeurs liées aux attributs de ce template ProjectAttributeValue.objects.filter( project=project, attribute__template=template, ).delete() return redirect(project.get_absolute_url()) @require_POST @project_permission_required def projects_save_attribute_values(request, project_id): """Sauvegarde les valeurs des attributs de template pour un projet via AJAX.""" project = get_object_or_404(Project, pk=project_id) if not can_add_or_edit_project(request.user, project): return JsonResponse({'success': False, 'message': _("Vous n'avez pas la permission de modifier ce projet.")}, status=403) try: data = json.loads(request.body) values = data.get('values', []) except (json.JSONDecodeError, TypeError): return JsonResponse({'success': False, 'message': _("Données invalides.")}, status=400) errors = [] for entry in values: attr_id = entry.get('attribute_id') raw_value = entry.get('value') try: attr = ProjectTemplateAttribute.objects.get(pk=attr_id, template__in=project.templates.all()) except ProjectTemplateAttribute.DoesNotExist: errors.append(_("Attribut %(id)s introuvable.") % {'id': attr_id}) continue value_obj, _created = ProjectAttributeValue.objects.get_or_create(project=project, attribute=attr) # Validation : obligatoire is_empty = raw_value is None or raw_value == '' or raw_value == [] if attr.is_required and is_empty: errors.append(_("Le champ « %(name)s » est obligatoire.") % {'name': attr.name_fr}) continue # Réinitialiser tous les champs de valeur value_obj.value_text = None value_obj.value_number = None value_obj.value_boolean = None value_obj.value_choice = None value_obj.value_choices = None value_obj.value_date = None if not is_empty: t = attr.attribute_type if t == 'text': value_obj.value_text = str(raw_value) elif t == 'number': try: value_obj.value_number = float(raw_value) except (ValueError, TypeError): errors.append(_("Valeur numérique invalide pour « %(name)s ».") % {'name': attr.name_fr}) continue elif t == 'boolean': if isinstance(raw_value, bool): value_obj.value_boolean = raw_value elif str(raw_value).lower() in ('true', '1', 'yes', 'oui'): value_obj.value_boolean = True elif str(raw_value).lower() in ('false', '0', 'no', 'non'): value_obj.value_boolean = False else: errors.append(_("Valeur booléenne invalide pour « %(name)s ».") % {'name': attr.name_fr}) continue elif t == 'choice': value_obj.value_choice = str(raw_value) elif t == 'choice_multiple': if isinstance(raw_value, list): value_obj.value_choices = raw_value else: value_obj.value_choices = [str(raw_value)] elif t == 'date': from datetime import date try: from datetime import datetime as dt value_obj.value_date = dt.strptime(str(raw_value), "%Y-%m-%d").date() except ValueError: errors.append(_("Date invalide pour « %(name)s ». Format attendu : AAAA-MM-JJ.") % {'name': attr.name_fr}) continue value_obj.save() if errors: return JsonResponse({'success': False, 'errors': [str(e) for e in errors]}, status=400) return JsonResponse({'success': True, 'message': _("Valeurs sauvegardées avec succès.")})