1086 lines
42 KiB
Python
1086 lines
42 KiB
Python
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.")})
|