loko/streetup/common/views.py

590 lines
22 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.http import JsonResponse, FileResponse, Http404
from django.shortcuts import render, redirect
from django.views.decorators.http import require_GET, require_POST
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.contrib import messages
from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point
from django.contrib.auth import login as auth_login
from django.contrib.auth.decorators import login_not_required
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth import update_session_auth_hash
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext as _
from django.conf import settings
from django.urls import reverse
import re
import os
from urllib.parse import urlencode
from common.context_processors import (
SESSION_ALLOWED_IDS_KEY,
SESSION_ORIGIN_ID_KEY,
compute_initial_allowed_ids,
)
from common.models import AdrStreet, AdrPoint, UserConfig
from assets.models import AssetCategory
@login_not_required
@require_GET
def autocomplete_street(request):
query = request.GET.get('q', '').strip()
if len(query) < 2:
return JsonResponse([], safe=False)
# Regex patterns to detect number and street in different positions
patterns = [
("number_first", r'^(\d+\w*)[\s,]+(.+)$'), # ex: "12A rue de la Loi"
("street_first", r'^(.+)[\s,]+(\d+\w*)$'), # ex: "rue de la Loi 12A"
]
number_part = None
street_part = None
for mode, pat in patterns:
match = re.match(pat, query)
if match:
g1, g2 = match.groups()
if mode == "number_first":
number_part, street_part = g1.strip(), g2.strip()
else:
street_part, number_part = g1.strip(), g2.strip()
break
if number_part and street_part:
# Recherche par AdrPoint avec numéro ET nom de rue
points = (
AdrPoint.objects
.filter(number__iexact=number_part)
.filter(
Q(street_name__name_fr__icontains=street_part) |
Q(street_name__name_nl__icontains=street_part)
)
.select_related('street_name', 'municipality')[:10]
)
data = [{
"id": point.fid,
"label": f"{point.number} {point.street_name.name_fr} / {point.street_name.name_nl}"
+ (f" ({point.municipality.name})" if point.municipality else ""),
"lon": point.lon,
"lat": point.lat
} for point in points]
else:
# Recherche standard par rue uniquement
streets = (
AdrStreet.objects
.filter(Q(name_fr__icontains=query) | Q(name_nl__icontains=query))
.select_related('municipality')[:10]
)
data = [{
"id": street.fid,
"label": f"{street.name_fr} / {street.name_nl}"
+ (f" ({street.municipality.name})" if street.municipality else ""),
"lon": street.lon,
"lat": street.lat
} for street in streets]
return JsonResponse(data, safe=False)
@login_not_required
@require_GET
def get_nearest_address(request):
try:
lon = float(request.GET.get("lon"))
lat = float(request.GET.get("lat"))
except (TypeError, ValueError):
return JsonResponse({"error": _("Coordonnées invalides")}, status=400)
input_point = Point(lon, lat, srid=4326)
input_point.transform(3812)
# Étape 1 : Trouver la rue la plus proche
nearest_street = (
AdrStreet.objects
.filter(geom__isnull=False)
.annotate(distance=Distance("geom", input_point))
.filter(distance__lte=50) # filtre à 50 mètres
.order_by("distance")
.first()
)
if not nearest_street:
return JsonResponse({"error": _("Aucune rue trouvée")}, status=404)
# Étape 2 : Rechercher un point dans un rayon de 50 m pour cette rue
nearest_point = (
AdrPoint.objects
.filter(street_name=nearest_street, geom__isnull=False)
.annotate(distance=Distance("geom", input_point))
.filter(distance__lte=50) # filtre à 50 mètres
.order_by("distance")
.first()
)
# Étape 3 : Si un point est trouvé dans les 50 m, on le retourne
if nearest_point:
return JsonResponse({
"id": nearest_point.id,
"label": f"{nearest_point.number} {nearest_point.street_name.name_fr} / {nearest_point.street_name.name_nl}"
+ (f" ({nearest_point.municipality.name})" if nearest_point.municipality else "")
})
# Sinon : on retourne uniquement le nom de la rue
return JsonResponse({
"id": nearest_street.id,
"label": f"{nearest_street.name_fr} / {nearest_street.name_nl}"
+ (f" ({nearest_street.municipality.name})" if nearest_street.municipality else ""),
})
@login_not_required
@require_GET
def get_regional_roads_geojson(request):
file_path = os.path.join(settings.BASE_DIR, 'common', 'static', 'geojson', 'regional_roads_simplified_4326.geojson')
if not os.path.exists(file_path):
raise Http404("GeoJSON file not found")
return FileResponse(open(file_path, 'rb'), content_type='application/json')
def get_categories_for_thematics(request):
ids_string = request.GET.get('thematic_ids', '')
if ids_string:
try:
thematic_ids = [int(x) for x in ids_string.split(',') if x.strip().isdigit()]
except ValueError:
return JsonResponse({"results": []})
else:
thematic_ids = []
categories = AssetCategory.objects.filter(thematic__id__in=thematic_ids).distinct()
results = [
{"id": cat.id, "text": cat.get_name(), "parent_id": cat.parent_id} for cat in categories
]
return JsonResponse({"results": results})
def _safe_origin_next(request):
# 1) prendre 'next' si présent à l’arrivée
origin_next = request.GET.get("origin_next") or request.GET.get("next")
# 2) sinon, si c’est un POST (ex: retour form), reprendre la valeur transmise
origin_next = origin_next or request.POST.get("origin_next")
# 3) safe-guard (évite open redirect)
if origin_next and not url_has_allowed_host_and_scheme(
url=origin_next,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
origin_next = None
return origin_next
@login_required
def account_info(request):
origin_next = _safe_origin_next(request) # on capture une fois, puis on la véhicule
open_password_modal = False
if request.method == "POST" and "password_change" in request.POST:
form = PasswordChangeForm(request.user, request.POST)
if form.is_valid():
user = form.save()
update_session_auth_hash(request, user)
messages.success(request, _("Mot de passe mis à jour avec succès."))
# On redirige vers account_info en conservant origin_next + flag
params = {"password_changed": "1"}
if origin_next:
params["origin_next"] = origin_next
return redirect(f"{request.path}?{urlencode(params)}")
else:
open_password_modal = True
else:
form = PasswordChangeForm(request.user)
if request.GET.get("password_changed") == "1":
open_password_modal = True
return render(request, "common/account_info.html", {
"form": form,
"open_password_modal": open_password_modal,
"origin_next": origin_next, # <- clé : toujours renvoyer au template
})
@login_required
@require_POST
def switch_account(request):
origin_next = _safe_origin_next(request)
target_user_id = request.POST.get("target_user_id")
try:
current_config: UserConfig = request.user.config
except UserConfig.DoesNotExist:
messages.error(request, _("Aucun compte lié n'est disponible."))
return redirect(origin_next or reverse("home:index"))
if not target_user_id:
messages.error(request, _("Aucun compte cible n'a été sélectionné."))
return redirect(origin_next or reverse("home:index"))
if str(request.user.pk) == str(target_user_id):
messages.info(request, _("Ce compte est déjà actif."))
return redirect(origin_next or reverse("home:index"))
session = request.session
allowed_ids = session.get(SESSION_ALLOWED_IDS_KEY)
origin_user_id = session.get(SESSION_ORIGIN_ID_KEY)
if allowed_ids is None:
allowed_ids = compute_initial_allowed_ids(current_config)
if origin_user_id is None:
origin_user_id = request.user.pk
allowed_ids = list(dict.fromkeys(int(pk) for pk in allowed_ids))
if request.user.pk not in allowed_ids:
allowed_ids.append(request.user.pk)
try:
target_user_pk = int(target_user_id)
except (TypeError, ValueError):
messages.error(request, _("Vous ne pouvez pas accéder à ce compte."))
return redirect(origin_next or reverse("home:index"))
if target_user_pk not in allowed_ids:
messages.error(request, _("Vous ne pouvez pas accéder à ce compte."))
return redirect(origin_next or reverse("home:index"))
session[SESSION_ALLOWED_IDS_KEY] = allowed_ids
session[SESSION_ORIGIN_ID_KEY] = origin_user_id
try:
target_config = (
UserConfig.objects.select_related("user").get(user__pk=target_user_pk)
)
except UserConfig.DoesNotExist:
messages.error(request, _("Vous ne pouvez pas accéder à ce compte."))
return redirect(origin_next or reverse("home:index"))
target_user = target_config.user
target_user.backend = "django.contrib.auth.backends.ModelBackend"
auth_login(request, target_user)
messages.success(request, _("Changement de compte effectué."))
session[SESSION_ALLOWED_IDS_KEY] = allowed_ids
session[SESSION_ORIGIN_ID_KEY] = origin_user_id
return redirect(origin_next or reverse("home:index"))
@login_required
@require_POST
def save_intervention_list_layout(request):
"""API endpoint to save the user's preferred layout for the interventions list."""
import json
try:
data = json.loads(request.body)
layout = data.get('layout')
except (json.JSONDecodeError, KeyError):
return JsonResponse({'error': _('Données invalides')}, status=400)
valid_layouts = ['both', 'table_only', 'map_only']
if layout not in valid_layouts:
return JsonResponse({'error': _('Layout invalide')}, status=400)
try:
user_config = request.user.config
user_config.intervention_list_layout = layout
user_config.save(update_fields=['intervention_list_layout'])
return JsonResponse({'success': True, 'layout': layout})
except UserConfig.DoesNotExist:
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
@login_required
@require_POST
def save_assets_list_layout(request):
"""API endpoint to save the user's preferred layout for the assets list."""
import json
try:
data = json.loads(request.body)
layout = data.get('layout')
except (json.JSONDecodeError, KeyError):
return JsonResponse({'error': _('Données invalides')}, status=400)
valid_layouts = ['both', 'table_only', 'map_only']
if layout not in valid_layouts:
return JsonResponse({'error': _('Layout invalide')}, status=400)
try:
user_config = request.user.config
user_config.assets_list_layout = layout
user_config.save(update_fields=['assets_list_layout'])
return JsonResponse({'success': True, 'layout': layout})
except UserConfig.DoesNotExist:
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
@login_required
@require_POST
def save_intervention_list_preferences(request):
"""API endpoint to save the user's preferred filters and sort for the interventions list."""
import json
try:
data = json.loads(request.body)
filters = data.get('filters', {})
sort = data.get('sort', {})
columns = data.get('columns', None)
except (json.JSONDecodeError, KeyError):
return JsonResponse({'error': _('Données invalides')}, status=400)
try:
user_config = request.user.config
# Validate filters structure (should be a dict with filter names as keys)
if not isinstance(filters, dict):
return JsonResponse({'error': _('Format de filtres invalide')}, status=400)
# Validate sort structure (should have sortName and sortOrder)
if not isinstance(sort, dict):
return JsonResponse({'error': _('Format de tri invalide')}, status=400)
update_fields = ['intervention_list_filters', 'intervention_list_sort']
user_config.intervention_list_filters = filters
user_config.intervention_list_sort = sort
if columns is not None:
if not isinstance(columns, list):
return JsonResponse({'error': _('Format de colonnes invalide')}, status=400)
user_config.intervention_list_columns = columns
update_fields.append('intervention_list_columns')
user_config.save(update_fields=update_fields)
return JsonResponse({'success': True, 'filters': filters, 'sort': sort})
except UserConfig.DoesNotExist:
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
@login_required
@require_POST
def save_observation_list_preferences(request):
"""API endpoint to save the user's preferred filters and sort for the observations list."""
import json
try:
data = json.loads(request.body)
filters = data.get('filters', {})
sort = data.get('sort', {})
except (json.JSONDecodeError, KeyError):
return JsonResponse({'error': _('Données invalides')}, status=400)
try:
user_config = request.user.config
if not isinstance(filters, dict):
return JsonResponse({'error': _('Format de filtres invalide')}, status=400)
if not isinstance(sort, dict):
return JsonResponse({'error': _('Format de tri invalide')}, status=400)
user_config.observation_list_filters = filters
user_config.observation_list_sort = sort
user_config.save(update_fields=['observation_list_filters', 'observation_list_sort'])
return JsonResponse({'success': True, 'filters': filters, 'sort': sort})
except UserConfig.DoesNotExist:
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
@login_required
@require_POST
def save_assets_index_preferences(request):
"""API endpoint to save the user's preferences for the unified assets index page."""
import json
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'error': _('Données invalides')}, status=400)
thematics = data.get('thematics', [])
categories = data.get('categories', [])
models = data.get('models', [])
conditional_filters = data.get('conditional_filters', {})
visible_columns = data.get('visible_columns')
type_filter = data.get('type_filter', 'all')
status_filters = data.get('status_filters', [])
validation_status_filters = data.get('validation_status_filters', [])
include_no_geom = data.get('include_no_geom', True)
map_center = data.get('map_center')
map_zoom = data.get('map_zoom')
if not isinstance(thematics, list):
return JsonResponse({'error': _('Format de thématiques invalide')}, status=400)
if not isinstance(categories, list):
return JsonResponse({'error': _('Format de catégories invalide')}, status=400)
if not isinstance(models, list):
return JsonResponse({'error': _('Format de modèles invalide')}, status=400)
if not isinstance(conditional_filters, dict):
return JsonResponse({'error': _('Format de filtres thématiques invalide')}, status=400)
if visible_columns is not None and not isinstance(visible_columns, list):
return JsonResponse({'error': _('Format de colonnes visibles invalide')}, status=400)
if not isinstance(type_filter, str):
return JsonResponse({'error': _('Format de filtre type invalide')}, status=400)
if not isinstance(status_filters, list):
return JsonResponse({'error': _('Format de filtres statut invalide')}, status=400)
if not isinstance(validation_status_filters, list):
return JsonResponse({'error': _('Format de filtres statut de validation invalide')}, status=400)
try:
user_config, _ = UserConfig.objects.get_or_create(user=request.user)
existing_prefs = user_config.assets_index_preferences or {}
existing_visible_columns = existing_prefs.get('visible_columns', []) if isinstance(existing_prefs, dict) else []
if not isinstance(existing_visible_columns, list):
existing_visible_columns = []
user_config.assets_index_preferences = {
'thematics': thematics,
'categories': categories,
'models': models,
'conditional_filters': conditional_filters,
'visible_columns': visible_columns if visible_columns is not None else existing_visible_columns,
'type_filter': type_filter,
'status_filters': status_filters,
'validation_status_filters': validation_status_filters,
'include_no_geom': include_no_geom,
'map_center': map_center,
'map_zoom': map_zoom,
}
user_config.save(update_fields=['assets_index_preferences'])
return JsonResponse({'success': True})
except UserConfig.DoesNotExist:
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
@login_required
@require_POST
def save_calendar_preferences(request):
"""API endpoint to save the user's preferred filters, view_mode, zoom and backlog states for the calendar."""
import json
try:
data = json.loads(request.body)
preferences = data.get('preferences', {})
except (json.JSONDecodeError, KeyError):
return JsonResponse({'error': _('Données invalides')}, status=400)
try:
user_config = request.user.config
if not isinstance(preferences, dict):
return JsonResponse({'error': _('Format de préférences invalide')}, status=400)
user_config.calendar_preferences = preferences
user_config.save(update_fields=['calendar_preferences'])
return JsonResponse({'success': True, 'preferences': preferences})
except UserConfig.DoesNotExist:
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
@login_required
@require_POST
def save_project_list_preferences(request):
"""API endpoint to save the user's preferred filters and sort for the projects list."""
import json
try:
data = json.loads(request.body)
filters = data.get('filters', {})
sort = data.get('sort', {})
except (json.JSONDecodeError, KeyError):
return JsonResponse({'error': _('Données invalides')}, status=400)
try:
user_config = request.user.config
if not isinstance(filters, dict):
return JsonResponse({'error': _('Format de filtres invalide')}, status=400)
if not isinstance(sort, dict):
return JsonResponse({'error': _('Format de tri invalide')}, status=400)
user_config.project_list_filters = filters
user_config.project_list_sort = sort
user_config.save(update_fields=['project_list_filters', 'project_list_sort'])
return JsonResponse({'success': True, 'filters': filters, 'sort': sort})
except UserConfig.DoesNotExist:
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
@login_required
@require_GET
def get_help_document(request, doc_name):
"""View to securely load and render Markdown help documents based on user roles and permissions."""
import markdown
from django.utils import translation
from django.http import HttpResponseForbidden
ALLOWED_DOCS = ['prise_en_main', 'concepts', 'assets', 'approuver_interventions', 'gerer_projets', 'statuts_intervention', 'creer_controle']
if doc_name not in ALLOWED_DOCS:
return JsonResponse({'error': _('Document non trouvé')}, status=404)
try:
user_config = request.user.config
except UserConfig.DoesNotExist:
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
# Permission check for specific guides
if doc_name == 'approuver_interventions':
if not user_config.can_approve_any_intervention:
return HttpResponseForbidden(_('Accès interdit'))
elif doc_name in ['gerer_projets', 'creer_controle']:
has_project_role = user_config.roles.filter(name__in=["admin", "manager", "controller", "external_manager", "top_manager"]).exists()
if not (has_project_role or request.user.is_superuser):
return HttpResponseForbidden(_('Accès interdit'))
# Determine language directory
lang = translation.get_language()
if lang and lang.startswith('nl'):
lang_dir = 'nl'
else:
lang_dir = 'fr'
# Resolve file path
file_path = settings.BASE_DIR / 'common' / 'help_docs' / lang_dir / f'{doc_name}.md'
if not os.path.exists(file_path):
return JsonResponse({'error': _('Document non trouvé')}, status=404)
try:
with open(file_path, 'r', encoding='utf-8') as f:
md_content = f.read()
except Exception:
return JsonResponse({'error': _('Erreur lors de la lecture du document')}, status=500)
# Convert Markdown to HTML
html_content = markdown.markdown(md_content, extensions=['extra', 'nl2br'])
return JsonResponse({'html': html_content})