loko/loko/projects/views.py

1118 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.

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')
status_classes = {
'in_preparation': 'secondary',
'planned': 'warning',
'in_progress': 'primary',
'finished': 'info',
'invoiced': 'dark',
'closed': 'dark',
'canceled': 'secondary',
}
status_choices_with_classes = [
(key, value, status_classes.get(key, 'secondary'))
for key, value in Project._meta.get_field('status').choices
]
context = {
'status_choices': status_choices_with_classes,
# '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}
# Visibilité
from documents.models import VisibilityScope
from documents.permissions import user_can_change_visibility
raw_visibility = request.POST.get(f'visibility_{i}', '').strip()
visibility = None
if raw_visibility and raw_visibility in VisibilityScope.values:
if user_can_change_visibility(request.user):
visibility = raw_visibility
else:
visibility = VisibilityScope.SCOPED
else:
visibility = VisibilityScope.SCOPED
DocumentManagementService.add_document(
file=file,
tag=tag,
folder=document_folder,
user=request.user,
content_object=project,
target_folder=target_folder,
unique_params=unique_params,
visibility=visibility,
)
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)
from documents.permissions import user_can_change_visibility
can_change_vis = user_can_change_visibility(user)
return JsonResponse({
'use_managed_documents': True,
'can_add_unique_tag_documents': can_add_unique,
'can_change_visibility': can_change_vis,
'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.")})