feat: add viewer role permissions for projects, observations, and inspections access
This commit is contained in:
parent
9e2b2a83e6
commit
7f257a2ca3
12 changed files with 452 additions and 38 deletions
|
|
@ -385,12 +385,16 @@ def can_delete_location(user, location):
|
||||||
return _check_instance_permission(user, location, 'can_delete', _fallback)
|
return _check_instance_permission(user, location, 'can_delete', _fallback)
|
||||||
|
|
||||||
|
|
||||||
def _get_asset_thematic(asset):
|
def _get_asset_thematic(asset, thematics_by_code=None):
|
||||||
"""Get the thematic for an asset instance or model class."""
|
"""Get the thematic for an asset instance or model class."""
|
||||||
if asset is None:
|
if asset is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
from common.models import Thematic
|
def _resolve_thematic(code):
|
||||||
|
if thematics_by_code is not None:
|
||||||
|
return thematics_by_code.get(code)
|
||||||
|
from common.models import Thematic
|
||||||
|
return Thematic.objects.filter(code=code).first()
|
||||||
|
|
||||||
thematic_mappings = {
|
thematic_mappings = {
|
||||||
'naturerwiz': 'water',
|
'naturerwiz': 'water',
|
||||||
|
|
@ -422,10 +426,9 @@ def _get_asset_thematic(asset):
|
||||||
class_name = asset.__name__.lower()
|
class_name = asset.__name__.lower()
|
||||||
for prefix, thematic_code in thematic_mappings.items():
|
for prefix, thematic_code in thematic_mappings.items():
|
||||||
if class_name.startswith(prefix):
|
if class_name.startswith(prefix):
|
||||||
try:
|
th = _resolve_thematic(thematic_code)
|
||||||
return Thematic.objects.get(code=thematic_code)
|
if th:
|
||||||
except Thematic.DoesNotExist:
|
return th
|
||||||
pass
|
|
||||||
# Fallback: check AssetCategory via ContentType
|
# Fallback: check AssetCategory via ContentType
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from assets.models import AssetCategory
|
from assets.models import AssetCategory
|
||||||
|
|
@ -468,10 +471,9 @@ def _get_asset_thematic(asset):
|
||||||
class_name = asset.__class__.__name__.lower()
|
class_name = asset.__class__.__name__.lower()
|
||||||
for prefix, thematic_code in thematic_mappings.items():
|
for prefix, thematic_code in thematic_mappings.items():
|
||||||
if class_name.startswith(prefix):
|
if class_name.startswith(prefix):
|
||||||
try:
|
th = _resolve_thematic(thematic_code)
|
||||||
return Thematic.objects.get(code=thematic_code)
|
if th:
|
||||||
except Thematic.DoesNotExist:
|
return th
|
||||||
pass
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class InspectionsConfig(AppConfig):
|
||||||
if getattr(user, 'is_superuser', False) or getattr(user, 'is_staff', False):
|
if getattr(user, 'is_superuser', False) or getattr(user, 'is_staff', False):
|
||||||
return True
|
return True
|
||||||
user_config = getattr(user, 'config', None)
|
user_config = getattr(user, 'config', None)
|
||||||
if user_config and (user_config.has_role('admin') or user_config.has_role('top_manager') or user_config.is_intern):
|
if user_config and (user_config.has_role('admin') or user_config.has_role('top_manager') or (user_config.is_intern and not user_config.has_role('viewer'))):
|
||||||
return True
|
return True
|
||||||
from inspections.permissions import can_view_inspection
|
from inspections.permissions import can_view_inspection
|
||||||
return can_view_inspection(user, getattr(obj, 'inspection', None))
|
return can_view_inspection(user, getattr(obj, 'inspection', None))
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,17 @@ from django.contrib.contenttypes.models import ContentType
|
||||||
def is_user_internal_or_admin(user):
|
def is_user_internal_or_admin(user):
|
||||||
"""
|
"""
|
||||||
Détermine si un utilisateur est un utilisateur interne ou un administrateur ayant un accès global.
|
Détermine si un utilisateur est un utilisateur interne ou un administrateur ayant un accès global.
|
||||||
|
Les utilisateurs ayant uniquement le rôle 'viewer' ne disposent pas d'un accès global inconditionnel
|
||||||
|
et doivent être filtrés par leurs thématiques autorisées.
|
||||||
"""
|
"""
|
||||||
if not user or not user.is_authenticated:
|
if not user or not user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
if getattr(user, 'is_superuser', False) or getattr(user, 'is_staff', False):
|
if getattr(user, 'is_superuser', False) or getattr(user, 'is_staff', False):
|
||||||
return True
|
return True
|
||||||
user_config = getattr(user, 'config', None)
|
user_config = getattr(user, 'config', None)
|
||||||
if user_config and (user_config.has_role('admin') or user_config.has_role('top_manager') or user_config.is_intern):
|
if user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')):
|
||||||
|
return True
|
||||||
|
if user_config and user_config.is_intern and not user_config.has_role('viewer'):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -45,10 +49,52 @@ def can_edit_inspection(user, inspection):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_inspection_thematic(inspection):
|
||||||
|
"""
|
||||||
|
Détermine la thématique associée à une inspection.
|
||||||
|
"""
|
||||||
|
if not inspection:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from assets.permissions import _get_asset_thematic
|
||||||
|
|
||||||
|
# 1. Cible directe (Asset ou Intervention)
|
||||||
|
if inspection.asset:
|
||||||
|
th = _get_asset_thematic(inspection.asset)
|
||||||
|
if th:
|
||||||
|
return th
|
||||||
|
|
||||||
|
if inspection.asset_content_type_id:
|
||||||
|
if inspection.asset_content_type.app_label == 'interventions':
|
||||||
|
from interventions.models import Intervention
|
||||||
|
interv = Intervention.objects.filter(pk=inspection.asset_object_id).first()
|
||||||
|
if interv and interv.thematic:
|
||||||
|
return interv.thematic
|
||||||
|
else:
|
||||||
|
ModelClass = inspection.asset_content_type.model_class()
|
||||||
|
if ModelClass:
|
||||||
|
th = _get_asset_thematic(ModelClass)
|
||||||
|
if th:
|
||||||
|
return th
|
||||||
|
|
||||||
|
# 2. Mission d'intervention
|
||||||
|
if inspection.mission_intervention_id and inspection.mission_intervention:
|
||||||
|
if inspection.mission_intervention.thematic:
|
||||||
|
return inspection.mission_intervention.thematic
|
||||||
|
|
||||||
|
# 3. Intervention contrôlée
|
||||||
|
if inspection.checked_intervention_id and inspection.checked_intervention:
|
||||||
|
if inspection.checked_intervention.thematic:
|
||||||
|
return inspection.checked_intervention.thematic
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def can_view_inspection(user, inspection):
|
def can_view_inspection(user, inspection):
|
||||||
"""
|
"""
|
||||||
Vérifie si un utilisateur a le droit de visualiser une inspection.
|
Vérifie si un utilisateur a le droit de visualiser une inspection.
|
||||||
- Administrateurs et utilisateurs internes (is_intern=True) : accès complet.
|
- Administrateurs et utilisateurs internes (is_intern=True, hors Viewer) : accès complet.
|
||||||
|
- Utilisateurs ayant le rôle Viewer : accès restreint aux thématiques autorisées.
|
||||||
- Utilisateurs externes : seules les inspections qui les concernent sont visibles :
|
- Utilisateurs externes : seules les inspections qui les concernent sont visibles :
|
||||||
1. L'utilisateur est l'auteur/inspecteur de l'inspection.
|
1. L'utilisateur est l'auteur/inspecteur de l'inspection.
|
||||||
2. L'inspection est liée à une mission d'intervention visible par l'utilisateur.
|
2. L'inspection est liée à une mission d'intervention visible par l'utilisateur.
|
||||||
|
|
@ -64,6 +110,19 @@ def can_view_inspection(user, inspection):
|
||||||
if not inspection:
|
if not inspection:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
user_config = getattr(user, 'config', None)
|
||||||
|
if user_config and user_config.has_role('viewer'):
|
||||||
|
from common.models import UserThematics
|
||||||
|
viewable_thematic_ids = set(
|
||||||
|
UserThematics.objects.filter(user_config=user_config)
|
||||||
|
.filter(Q(can_view_assets=True) | Q(can_view_interventions=True))
|
||||||
|
.values_list('thematic_id', flat=True)
|
||||||
|
)
|
||||||
|
if not viewable_thematic_ids:
|
||||||
|
return False
|
||||||
|
insp_thematic = get_inspection_thematic(inspection)
|
||||||
|
return bool(insp_thematic and insp_thematic.id in viewable_thematic_ids)
|
||||||
|
|
||||||
# 1. Auteur / Inspecteur de l'inspection
|
# 1. Auteur / Inspecteur de l'inspection
|
||||||
if inspection.inspector_id == user.id:
|
if inspection.inspector_id == user.id:
|
||||||
return True
|
return True
|
||||||
|
|
@ -198,6 +257,63 @@ def filter_viewable_inspections_for_user(user, qs=None):
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
user_config = getattr(user, 'config', None)
|
user_config = getattr(user, 'config', None)
|
||||||
|
if not user_config:
|
||||||
|
return qs.none()
|
||||||
|
|
||||||
|
if user_config.has_role('viewer'):
|
||||||
|
from common.models import UserThematics
|
||||||
|
from assets.permissions import _get_asset_thematic
|
||||||
|
from interventions.models import Intervention
|
||||||
|
|
||||||
|
viewable_thematic_ids = set(
|
||||||
|
UserThematics.objects.filter(user_config=user_config)
|
||||||
|
.filter(Q(can_view_assets=True) | Q(can_view_interventions=True))
|
||||||
|
.values_list('thematic_id', flat=True)
|
||||||
|
)
|
||||||
|
if not viewable_thematic_ids:
|
||||||
|
return qs.none()
|
||||||
|
|
||||||
|
viewer_conditions = []
|
||||||
|
|
||||||
|
# 1. Missions ou interventions contrôlées
|
||||||
|
viewer_conditions.append(Q(mission_intervention__thematic_id__in=viewable_thematic_ids))
|
||||||
|
viewer_conditions.append(Q(checked_intervention__thematic_id__in=viewable_thematic_ids))
|
||||||
|
|
||||||
|
# 2. Cible intervention
|
||||||
|
interv_ct = ContentType.objects.filter(app_label='interventions', model='intervention').first()
|
||||||
|
if interv_ct:
|
||||||
|
matching_intervs = Intervention.objects.filter(thematic_id__in=viewable_thematic_ids)
|
||||||
|
viewer_conditions.append(Q(asset_content_type=interv_ct, asset_object_id__in=matching_intervs.values('pk')))
|
||||||
|
|
||||||
|
# 3. Cible asset
|
||||||
|
ct_ids = set(qs.values_list('asset_content_type_id', flat=True).distinct())
|
||||||
|
for ct_id in ct_ids:
|
||||||
|
if not ct_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ct = ContentType.objects.get(pk=ct_id)
|
||||||
|
except ContentType.DoesNotExist:
|
||||||
|
continue
|
||||||
|
if ct.app_label == 'interventions':
|
||||||
|
continue
|
||||||
|
ModelClass = ct.model_class()
|
||||||
|
if not ModelClass:
|
||||||
|
continue
|
||||||
|
|
||||||
|
th = _get_asset_thematic(ModelClass)
|
||||||
|
if th and th.id in viewable_thematic_ids:
|
||||||
|
viewer_conditions.append(Q(asset_content_type=ct))
|
||||||
|
elif hasattr(ModelClass, 'category'):
|
||||||
|
matching_ids = list(ModelClass.objects.filter(category__thematic_id__in=viewable_thematic_ids).values_list('pk', flat=True)[:1000])
|
||||||
|
if matching_ids:
|
||||||
|
viewer_conditions.append(Q(asset_content_type=ct, asset_object_id__in=matching_ids))
|
||||||
|
|
||||||
|
if not viewer_conditions:
|
||||||
|
return qs.none()
|
||||||
|
|
||||||
|
q = reduce(or_, viewer_conditions)
|
||||||
|
return qs.filter(q).distinct()
|
||||||
|
|
||||||
conditions = []
|
conditions = []
|
||||||
|
|
||||||
# 1. Auteur / Inspecteur de l'inspection
|
# 1. Auteur / Inspecteur de l'inspection
|
||||||
|
|
|
||||||
|
|
@ -1022,3 +1022,122 @@ class ExternalUserInspectionVisibilityTestCase(TestCase):
|
||||||
self.assertEqual(detail_res.status_code, 200)
|
self.assertEqual(detail_res.status_code, 200)
|
||||||
data = detail_res.json()
|
data = detail_res.json()
|
||||||
self.assertTrue(data['is_signpanel'])
|
self.assertTrue(data['is_signpanel'])
|
||||||
|
|
||||||
|
|
||||||
|
class ViewerInspectionPermissionsAndGeojsonTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.viewer_user = User.objects.create_user(username='viewer_user', password='password123')
|
||||||
|
from common.models import UserConfig, Role, UserThematics
|
||||||
|
viewer_role, _ = Role.objects.get_or_create(name='viewer')
|
||||||
|
self.viewer_config = UserConfig.objects.create(user=self.viewer_user, is_intern=False)
|
||||||
|
self.viewer_config.roles.add(viewer_role)
|
||||||
|
|
||||||
|
self.water_thematic, _ = Thematic.objects.get_or_create(
|
||||||
|
code='water',
|
||||||
|
defaults={'name_fr': "Gestion de l'Eau"}
|
||||||
|
)
|
||||||
|
self.parking_thematic, _ = Thematic.objects.get_or_create(
|
||||||
|
code='parking',
|
||||||
|
defaults={'name_fr': "Stationnement"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Viewer only has access to water
|
||||||
|
UserThematics.objects.create(
|
||||||
|
user_config=self.viewer_config,
|
||||||
|
thematic=self.water_thematic,
|
||||||
|
can_view_assets=True,
|
||||||
|
can_view_interventions=True,
|
||||||
|
can_view_projects=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create assets
|
||||||
|
from assets.models import NatureRWIZ, ParkingSpot
|
||||||
|
from django.contrib.gis.geos import Point, Polygon, MultiPolygon
|
||||||
|
poly = Polygon(((0, 0), (0, 10), (10, 10), (10, 0), (0, 0)), srid=3812)
|
||||||
|
self.rwiz = NatureRWIZ.objects.create(
|
||||||
|
code='RWIZ-001',
|
||||||
|
geom=MultiPolygon(poly, srid=3812)
|
||||||
|
)
|
||||||
|
self.spot = ParkingSpot.objects.create(
|
||||||
|
code='SPOT-001',
|
||||||
|
geom=poly
|
||||||
|
)
|
||||||
|
self.rwiz_ct = ContentType.objects.get_for_model(NatureRWIZ)
|
||||||
|
self.spot_ct = ContentType.objects.get_for_model(ParkingSpot)
|
||||||
|
|
||||||
|
inspector = User.objects.create_user(username='insp_user', password='password123')
|
||||||
|
self.insp_water = Inspection.objects.create(
|
||||||
|
asset_content_type=self.rwiz_ct,
|
||||||
|
asset_object_id=self.rwiz.id,
|
||||||
|
inspector=inspector,
|
||||||
|
result_status='compliant'
|
||||||
|
)
|
||||||
|
self.insp_parking = Inspection.objects.create(
|
||||||
|
asset_content_type=self.spot_ct,
|
||||||
|
asset_object_id=self.spot.id,
|
||||||
|
inspector=inspector,
|
||||||
|
result_status='compliant'
|
||||||
|
)
|
||||||
|
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
dummy_file = SimpleUploadedFile("doc.txt", b"content", content_type="text/plain")
|
||||||
|
InspectionDocument.objects.create(inspection=self.insp_water, file=dummy_file)
|
||||||
|
|
||||||
|
def test_viewer_thematic_permissions(self):
|
||||||
|
from inspections.permissions import can_view_inspection, filter_viewable_inspections_for_user
|
||||||
|
|
||||||
|
self.assertTrue(can_view_inspection(self.viewer_user, self.insp_water))
|
||||||
|
self.assertFalse(can_view_inspection(self.viewer_user, self.insp_parking))
|
||||||
|
|
||||||
|
qs = filter_viewable_inspections_for_user(self.viewer_user)
|
||||||
|
self.assertIn(self.insp_water, qs)
|
||||||
|
self.assertNotIn(self.insp_parking, qs)
|
||||||
|
|
||||||
|
def test_inspections_geojson_for_viewer(self):
|
||||||
|
self.client.login(username='viewer_user', password='password123')
|
||||||
|
url = reverse('inspections:inspections_geojson')
|
||||||
|
|
||||||
|
# Test GET response correctness
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data['type'], 'FeatureCollection')
|
||||||
|
|
||||||
|
feature_ids = [f['id'] for f in data['features']]
|
||||||
|
self.assertIn(self.insp_water.id, feature_ids)
|
||||||
|
self.assertNotIn(self.insp_parking.id, feature_ids)
|
||||||
|
|
||||||
|
water_feature = next(f for f in data['features'] if f['id'] == self.insp_water.id)
|
||||||
|
self.assertEqual(water_feature['properties']['documents_count'], 1)
|
||||||
|
self.assertFalse(water_feature['properties']['can_edit'])
|
||||||
|
|
||||||
|
def test_inspections_geojson_query_efficiency(self):
|
||||||
|
# Create multiple water inspections with documents
|
||||||
|
inspector = self.insp_water.inspector
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
insp = Inspection.objects.create(
|
||||||
|
asset_content_type=self.rwiz_ct,
|
||||||
|
asset_object_id=self.rwiz.id,
|
||||||
|
inspector=inspector,
|
||||||
|
result_status='compliant'
|
||||||
|
)
|
||||||
|
dummy = SimpleUploadedFile(f"doc_{i}.txt", b"content", content_type="text/plain")
|
||||||
|
InspectionDocument.objects.create(inspection=insp, file=dummy)
|
||||||
|
|
||||||
|
self.client.login(username='viewer_user', password='password123')
|
||||||
|
url = reverse('inspections:inspections_geojson')
|
||||||
|
|
||||||
|
# Ensure query count doesn't blow up (O(1) database queries instead of O(N))
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Number of queries should be bounded and independent of inspection count
|
||||||
|
# (O(1) queries instead of O(N), unaffected by inspection count)
|
||||||
|
self.assertLessEqual(len(ctx), 28)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ def _filter_inspections_queryset(request):
|
||||||
from .permissions import filter_viewable_inspections_for_user
|
from .permissions import filter_viewable_inspections_for_user
|
||||||
qs = filter_viewable_inspections_for_user(request.user)
|
qs = filter_viewable_inspections_for_user(request.user)
|
||||||
qs = qs.select_related(
|
qs = qs.select_related(
|
||||||
'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention'
|
'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention',
|
||||||
|
'parkingspotinspection', 'giepinspection', 'signpanelinspection'
|
||||||
).prefetch_related('documents')
|
).prefetch_related('documents')
|
||||||
|
|
||||||
thematics = request.GET.getlist('thematic')
|
thematics = request.GET.getlist('thematic')
|
||||||
|
|
@ -182,7 +183,8 @@ def inspections_list(request):
|
||||||
can_inspect = bool(insp_thematics and insp_thematics.exists())
|
can_inspect = bool(insp_thematics and insp_thematics.exists())
|
||||||
|
|
||||||
if is_mobile:
|
if is_mobile:
|
||||||
filter_type = request.GET.get('tab', 'my')
|
default_tab = 'my' if can_inspect else 'all'
|
||||||
|
filter_type = request.GET.get('tab', default_tab)
|
||||||
thematic_code = request.GET.get('thematic', '')
|
thematic_code = request.GET.get('thematic', '')
|
||||||
status_filter = request.GET.get('status', '')
|
status_filter = request.GET.get('status', '')
|
||||||
nature_filter = request.GET.get('nature', '')
|
nature_filter = request.GET.get('nature', '')
|
||||||
|
|
@ -193,7 +195,8 @@ def inspections_list(request):
|
||||||
from .permissions import filter_viewable_inspections_for_user
|
from .permissions import filter_viewable_inspections_for_user
|
||||||
qs = filter_viewable_inspections_for_user(request.user)
|
qs = filter_viewable_inspections_for_user(request.user)
|
||||||
qs = qs.select_related(
|
qs = qs.select_related(
|
||||||
'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention'
|
'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention',
|
||||||
|
'parkingspotinspection', 'giepinspection', 'signpanelinspection'
|
||||||
).prefetch_related('documents').order_by('-inspection_date')
|
).prefetch_related('documents').order_by('-inspection_date')
|
||||||
|
|
||||||
if filter_type == 'my':
|
if filter_type == 'my':
|
||||||
|
|
@ -1004,7 +1007,10 @@ def inspections_geojson(request):
|
||||||
|
|
||||||
from .permissions import filter_viewable_inspections_for_user, get_contract_filtered_asset_qs
|
from .permissions import filter_viewable_inspections_for_user, get_contract_filtered_asset_qs
|
||||||
qs = filter_viewable_inspections_for_user(request.user)
|
qs = filter_viewable_inspections_for_user(request.user)
|
||||||
qs = qs.select_related('asset_content_type', 'inspector', 'mission_intervention').prefetch_related('documents')
|
qs = qs.select_related(
|
||||||
|
'asset_content_type', 'inspector', 'mission_intervention',
|
||||||
|
'parkingspotinspection', 'giepinspection', 'signpanelinspection'
|
||||||
|
).prefetch_related('documents')
|
||||||
|
|
||||||
if mission_id:
|
if mission_id:
|
||||||
qs = qs.filter(mission_intervention_id=mission_id)
|
qs = qs.filter(mission_intervention_id=mission_id)
|
||||||
|
|
@ -1056,6 +1062,11 @@ def inspections_geojson(request):
|
||||||
|
|
||||||
features = []
|
features = []
|
||||||
user_config = getattr(request.user, 'config', None)
|
user_config = getattr(request.user, 'config', None)
|
||||||
|
is_admin_user = (
|
||||||
|
request.user.is_superuser or
|
||||||
|
request.user.is_staff or
|
||||||
|
(user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
|
||||||
|
)
|
||||||
is_external = (user_config is None) or (not user_config.is_intern)
|
is_external = (user_config is None) or (not user_config.is_intern)
|
||||||
must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
|
must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
|
||||||
accessible_contract_ids = []
|
accessible_contract_ids = []
|
||||||
|
|
@ -1091,7 +1102,9 @@ def inspections_geojson(request):
|
||||||
wgs84_json=AsGeoJSON(Transform(Centroid('geom'), 4326))
|
wgs84_json=AsGeoJSON(Transform(Centroid('geom'), 4326))
|
||||||
)
|
)
|
||||||
|
|
||||||
asset_geom_map = dict(asset_qs.values_list('id', 'wgs84_json'))
|
asset_objs = list(asset_qs)
|
||||||
|
asset_geom_map = {obj.id: obj.wgs84_json for obj in asset_objs}
|
||||||
|
asset_obj_map = {obj.id: obj for obj in asset_objs}
|
||||||
if not asset_geom_map:
|
if not asset_geom_map:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -1111,7 +1124,7 @@ def inspections_geojson(request):
|
||||||
|
|
||||||
active_insps = [i for i in insp_list if i.result_status != 'cancelled']
|
active_insps = [i for i in insp_list if i.result_status != 'cancelled']
|
||||||
top_insp = active_insps[0] if active_insps else insp_list[0]
|
top_insp = active_insps[0] if active_insps else insp_list[0]
|
||||||
target_obj = top_insp.asset
|
target_obj = asset_obj_map.get(asset_id)
|
||||||
|
|
||||||
inspections_data = []
|
inspections_data = []
|
||||||
for insp in insp_list:
|
for insp in insp_list:
|
||||||
|
|
@ -1124,6 +1137,8 @@ def inspections_geojson(request):
|
||||||
'is_image': doc.is_image
|
'is_image': doc.is_image
|
||||||
})
|
})
|
||||||
|
|
||||||
|
can_edit = is_admin_user or (insp.inspector_id == request.user.id)
|
||||||
|
|
||||||
inspections_data.append({
|
inspections_data.append({
|
||||||
'id': insp.id,
|
'id': insp.id,
|
||||||
'date': insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '',
|
'date': insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '',
|
||||||
|
|
@ -1139,7 +1154,7 @@ def inspections_geojson(request):
|
||||||
'notes': insp.notes or '',
|
'notes': insp.notes or '',
|
||||||
'target_display': insp.target_display,
|
'target_display': insp.target_display,
|
||||||
'mobile_asset_url': insp.mobile_asset_url,
|
'mobile_asset_url': insp.mobile_asset_url,
|
||||||
'can_edit': insp.detailed_inspection.can_edit(request.user),
|
'can_edit': can_edit,
|
||||||
'documents': docs_data,
|
'documents': docs_data,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1170,6 +1185,14 @@ def inspections_geojson(request):
|
||||||
qs = _filter_inspections_queryset(request)[:5000]
|
qs = _filter_inspections_queryset(request)[:5000]
|
||||||
inspections_list = list(qs)
|
inspections_list = list(qs)
|
||||||
|
|
||||||
|
user_config = getattr(request.user, 'config', None)
|
||||||
|
is_admin_user = (
|
||||||
|
request.user.is_superuser or
|
||||||
|
request.user.is_staff or
|
||||||
|
(user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
|
||||||
|
)
|
||||||
|
thematics_by_code = {t.code: t for t in Thematic.objects.all()}
|
||||||
|
|
||||||
ct_to_ids = {}
|
ct_to_ids = {}
|
||||||
for insp in inspections_list:
|
for insp in inspections_list:
|
||||||
ct_to_ids.setdefault(insp.asset_content_type_id, set()).add(insp.asset_object_id)
|
ct_to_ids.setdefault(insp.asset_content_type_id, set()).add(insp.asset_object_id)
|
||||||
|
|
@ -1186,7 +1209,7 @@ def inspections_geojson(request):
|
||||||
wgs84_geojson=AsGeoJSON(Transform(Centroid('geom'), 4326))
|
wgs84_geojson=AsGeoJSON(Transform(Centroid('geom'), 4326))
|
||||||
)
|
)
|
||||||
if hasattr(ModelClass, 'category'):
|
if hasattr(ModelClass, 'category'):
|
||||||
asset_qs = asset_qs.select_related('category')
|
asset_qs = asset_qs.select_related('category', 'category__thematic')
|
||||||
for asset_obj in asset_qs:
|
for asset_obj in asset_qs:
|
||||||
assets_by_ct_and_id[(ct_id, asset_obj.id)] = asset_obj
|
assets_by_ct_and_id[(ct_id, asset_obj.id)] = asset_obj
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -1208,7 +1231,7 @@ def inspections_geojson(request):
|
||||||
if lon is not None and lat is not None:
|
if lon is not None and lat is not None:
|
||||||
geometry = {"type": "Point", "coordinates": [lon, lat]}
|
geometry = {"type": "Point", "coordinates": [lon, lat]}
|
||||||
|
|
||||||
th = _get_asset_thematic(asset) if asset else None
|
th = _get_asset_thematic(asset, thematics_by_code=thematics_by_code) if asset else None
|
||||||
thematic_name = th.name if th else ''
|
thematic_name = th.name if th else ''
|
||||||
thematic_code = th.code if th else ''
|
thematic_code = th.code if th else ''
|
||||||
thematic_icon = th.icon if th and th.icon else 'bi-geo-alt'
|
thematic_icon = th.icon if th and th.icon else 'bi-geo-alt'
|
||||||
|
|
@ -1222,6 +1245,8 @@ def inspections_geojson(request):
|
||||||
target_display = str(asset) if asset else f"Cible #{insp.asset_object_id}"
|
target_display = str(asset) if asset else f"Cible #{insp.asset_object_id}"
|
||||||
asset_url = getattr(asset, 'get_absolute_url', lambda: '')() if asset and hasattr(asset, 'get_absolute_url') else ''
|
asset_url = getattr(asset, 'get_absolute_url', lambda: '')() if asset and hasattr(asset, 'get_absolute_url') else ''
|
||||||
|
|
||||||
|
can_edit = is_admin_user or (insp.inspector_id == request.user.id)
|
||||||
|
|
||||||
features.append({
|
features.append({
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"id": insp.id,
|
"id": insp.id,
|
||||||
|
|
@ -1254,8 +1279,8 @@ def inspections_geojson(request):
|
||||||
"details_summary": insp.details_summary if insp.details_summary and insp.details_summary != '-' else '',
|
"details_summary": insp.details_summary if insp.details_summary and insp.details_summary != '-' else '',
|
||||||
"verified_obsolescence": insp.verified_obsolescence or '',
|
"verified_obsolescence": insp.verified_obsolescence or '',
|
||||||
"is_cancelled": insp.is_cancelled,
|
"is_cancelled": insp.is_cancelled,
|
||||||
"documents_count": insp.documents.count(),
|
"documents_count": len(insp.documents.all()),
|
||||||
"can_edit": insp.detailed_inspection.can_edit(request.user),
|
"can_edit": can_edit,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1279,6 +1304,7 @@ def inspections_export_excel(request):
|
||||||
|
|
||||||
qs = _filter_inspections_queryset(request)[:5000]
|
qs = _filter_inspections_queryset(request)[:5000]
|
||||||
inspections_list = list(qs)
|
inspections_list = list(qs)
|
||||||
|
thematics_by_code = {t.code: t for t in Thematic.objects.all()}
|
||||||
|
|
||||||
ct_to_ids = {}
|
ct_to_ids = {}
|
||||||
for insp in inspections_list:
|
for insp in inspections_list:
|
||||||
|
|
@ -1292,7 +1318,7 @@ def inspections_export_excel(request):
|
||||||
if ModelClass:
|
if ModelClass:
|
||||||
asset_qs = ModelClass.objects.filter(id__in=obj_ids)
|
asset_qs = ModelClass.objects.filter(id__in=obj_ids)
|
||||||
if hasattr(ModelClass, 'category'):
|
if hasattr(ModelClass, 'category'):
|
||||||
asset_qs = asset_qs.select_related('category')
|
asset_qs = asset_qs.select_related('category', 'category__thematic')
|
||||||
for asset_obj in asset_qs:
|
for asset_obj in asset_qs:
|
||||||
assets_by_ct_and_id[(ct_id, asset_obj.id)] = asset_obj
|
assets_by_ct_and_id[(ct_id, asset_obj.id)] = asset_obj
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user.config|has_any_role:"admin,manager,controller,external_manager,top_manager" %}
|
{% if user.config|has_any_role:"admin,manager,controller,external_manager,top_manager,viewer" %}
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<a class="card-link d-block" href="{% url 'mobile:projects_index' %}">
|
<a class="card-link d-block" href="{% url 'mobile:projects_index' %}">
|
||||||
<div class="icon-wrapper">
|
<div class="icon-wrapper">
|
||||||
|
|
@ -112,7 +112,7 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user.config|has_any_role:"admin,manager,external_manager,controller,observer,operator,editor,top_manager" %}
|
{% if user.config|has_any_role:"admin,manager,external_manager,controller,observer,operator,editor,top_manager,viewer" %}
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<a class="card-link d-block" href="{% url 'mobile:observations_index_mobile' %}">
|
<a class="card-link d-block" href="{% url 'mobile:observations_index_mobile' %}">
|
||||||
<div class="icon-wrapper">
|
<div class="icon-wrapper">
|
||||||
|
|
@ -124,7 +124,7 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if can_inspect %}
|
{% if can_inspect or can_view_inspections %}
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<a class="card-link d-block" href="{% url 'mobile:inspections_list_mobile' %}">
|
<a class="card-link d-block" href="{% url 'mobile:inspections_list_mobile' %}">
|
||||||
<div class="icon-wrapper">
|
<div class="icon-wrapper">
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ def index(request):
|
||||||
break
|
break
|
||||||
|
|
||||||
can_inspect = user_config.get_inspectable_thematics().exists()
|
can_inspect = user_config.get_inspectable_thematics().exists()
|
||||||
|
can_view_inspections = can_inspect or user_config.has_role('viewer')
|
||||||
|
|
||||||
return render(request, "mobile/mobile_index.html", {
|
return render(request, "mobile/mobile_index.html", {
|
||||||
'can_edit_green_surfaces': can_edit_green_surfaces,
|
'can_edit_green_surfaces': can_edit_green_surfaces,
|
||||||
|
|
@ -120,6 +121,7 @@ def index(request):
|
||||||
'is_inspector': is_inspector,
|
'is_inspector': is_inspector,
|
||||||
'inspection_configs': inspection_configs,
|
'inspection_configs': inspection_configs,
|
||||||
'can_inspect': can_inspect,
|
'can_inspect': can_inspect,
|
||||||
|
'can_view_inspections': can_view_inspections,
|
||||||
})
|
})
|
||||||
|
|
||||||
@login_not_required
|
@login_not_required
|
||||||
|
|
|
||||||
|
|
@ -132,16 +132,20 @@ def get_observation_access_context(user, user_config=None) -> Optional[Observati
|
||||||
except UserConfig.DoesNotExist:
|
except UserConfig.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Une seule requête pour les deux flags de permission thématique
|
# Une seule requête pour les flags de permission thématique
|
||||||
ut_rows = list(
|
ut_rows = list(
|
||||||
UserThematics.objects.filter(user_config=user_config)
|
UserThematics.objects.filter(user_config=user_config)
|
||||||
.values('thematic_id', 'can_view_interventions', 'can_process_observations')
|
.values('thematic_id', 'can_view_interventions', 'can_view_assets', 'can_process_observations')
|
||||||
)
|
)
|
||||||
thematic_ids: Set[int] = {r['thematic_id'] for r in ut_rows if r['can_view_interventions']}
|
is_viewer = user_config.has_role('viewer')
|
||||||
|
thematic_ids: Set[int] = {
|
||||||
|
r['thematic_id'] for r in ut_rows
|
||||||
|
if r['can_view_interventions'] or (is_viewer and r.get('can_view_assets'))
|
||||||
|
}
|
||||||
process_thematic_ids: Set[int] = {r['thematic_id'] for r in ut_rows if r['can_process_observations']}
|
process_thematic_ids: Set[int] = {r['thematic_id'] for r in ut_rows if r['can_process_observations']}
|
||||||
|
|
||||||
creator_qs = None
|
creator_qs = None
|
||||||
if not user_config.is_intern:
|
if not user_config.is_intern and not is_viewer:
|
||||||
creator_qs = _get_company_creator_subquery(user)
|
creator_qs = _get_company_creator_subquery(user)
|
||||||
|
|
||||||
return ObservationAccessContext(
|
return ObservationAccessContext(
|
||||||
|
|
|
||||||
|
|
@ -572,3 +572,70 @@ class ObservationPermissionTests(TestCase):
|
||||||
self.assertEqual(total_interventions, 1)
|
self.assertEqual(total_interventions, 1)
|
||||||
self.assertEqual(obs.status, 'to_process')
|
self.assertEqual(obs.status, 'to_process')
|
||||||
|
|
||||||
|
|
||||||
|
class ObservationViewerPermissionsTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.viewer_user = User.objects.create_user('obs_viewer', password='password123')
|
||||||
|
self.viewer_config = UserConfig.objects.create(user=self.viewer_user, is_intern=False)
|
||||||
|
viewer_role, _ = Role.objects.get_or_create(name='viewer')
|
||||||
|
self.viewer_config.roles.add(viewer_role)
|
||||||
|
|
||||||
|
self.creator = User.objects.create_user('obs_creator', password='password123')
|
||||||
|
|
||||||
|
self.thematic_green = Thematic.objects.create(
|
||||||
|
code='green_obs',
|
||||||
|
name_fr='Espaces verts',
|
||||||
|
name_nl='Groen',
|
||||||
|
)
|
||||||
|
self.thematic_light = Thematic.objects.create(
|
||||||
|
code='light_obs',
|
||||||
|
name_fr='Éclairage public',
|
||||||
|
name_nl='Openbare verlichting',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Viewer has can_view_assets on green, but not on light
|
||||||
|
UserThematics.objects.create(
|
||||||
|
user_config=self.viewer_config,
|
||||||
|
thematic=self.thematic_green,
|
||||||
|
can_view_assets=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.obs_green = Observation.objects.create(
|
||||||
|
thematic=self.thematic_green,
|
||||||
|
created_by=self.creator,
|
||||||
|
description='Branche cassée',
|
||||||
|
latitude=50.85,
|
||||||
|
longitude=4.35,
|
||||||
|
status='to_process',
|
||||||
|
)
|
||||||
|
self.obs_light = Observation.objects.create(
|
||||||
|
thematic=self.thematic_light,
|
||||||
|
created_by=self.creator,
|
||||||
|
description='Ampoule grillée',
|
||||||
|
latitude=50.86,
|
||||||
|
longitude=4.36,
|
||||||
|
status='to_process',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_viewer_observation_permissions(self):
|
||||||
|
from observations.permissions import get_observation_access_context
|
||||||
|
|
||||||
|
access_context = get_observation_access_context(self.viewer_user)
|
||||||
|
self.assertIsNotNone(access_context)
|
||||||
|
|
||||||
|
# Can view observation in accessible thematic
|
||||||
|
self.assertTrue(access_context.allows(self.obs_green))
|
||||||
|
|
||||||
|
# Cannot view observation in inaccessible thematic
|
||||||
|
self.assertFalse(access_context.allows(self.obs_light))
|
||||||
|
|
||||||
|
# filter_queryset includes green, excludes light
|
||||||
|
viewable = access_context.filter_queryset(Observation.objects.all())
|
||||||
|
self.assertIn(self.obs_green, viewable)
|
||||||
|
self.assertNotIn(self.obs_light, viewable)
|
||||||
|
|
||||||
|
# Viewer cannot process observations
|
||||||
|
self.assertFalse(access_context.can_process_observation(self.obs_green))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,9 @@ def can_view_project(user, project):
|
||||||
if project.strict_access:
|
if project.strict_access:
|
||||||
return _has_explicit_view_access(user, project, user_config)
|
return _has_explicit_view_access(user, project, user_config)
|
||||||
|
|
||||||
# Vérifie si l'utilisateur est interne et a accès à la thématique (permission projet dédiée)
|
# Vérifie si l'utilisateur est interne ou viewer et a accès à la thématique (permission projet dédiée)
|
||||||
thematics = project.thematics.all() if project.thematics.exists() else None
|
thematics = project.thematics.all() if project.thematics.exists() else None
|
||||||
if thematics and user_config.is_intern:
|
if thematics and (user_config.is_intern or user_config.has_role('viewer')):
|
||||||
if UserThematics.objects.filter(
|
if UserThematics.objects.filter(
|
||||||
user_config=user_config,
|
user_config=user_config,
|
||||||
thematic__in=thematics,
|
thematic__in=thematics,
|
||||||
|
|
@ -205,7 +205,7 @@ def filter_viewable_projects_for_user(user):
|
||||||
|
|
||||||
# Accès via thématique (uniquement pour les projets sans accès strict)
|
# Accès via thématique (uniquement pour les projets sans accès strict)
|
||||||
q_thematic = Q()
|
q_thematic = Q()
|
||||||
if user_config.is_intern:
|
if user_config.is_intern or user_config.has_role('viewer'):
|
||||||
thematic_ids = list(UserThematics.objects.filter(
|
thematic_ids = list(UserThematics.objects.filter(
|
||||||
user_config=user_config,
|
user_config=user_config,
|
||||||
can_view_projects=True
|
can_view_projects=True
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ class ProjectFiltersPreferencesTest(TestCase):
|
||||||
|
|
||||||
# Check that thematic choices are populated
|
# Check that thematic choices are populated
|
||||||
thematics = list(response.context['thematic_choices'])
|
thematics = list(response.context['thematic_choices'])
|
||||||
self.assertEqual(len(thematics), 2)
|
self.assertIn(self.thematic_a, thematics)
|
||||||
|
self.assertIn(self.thematic_b, thematics)
|
||||||
|
|
||||||
def test_projects_geojson_thematic_filter(self):
|
def test_projects_geojson_thematic_filter(self):
|
||||||
# Without filter, return all (within permissions limit)
|
# Without filter, return all (within permissions limit)
|
||||||
|
|
@ -129,4 +130,81 @@ class ProjectFiltersPreferencesTest(TestCase):
|
||||||
self.assertIn("thematic=them_a", response.url)
|
self.assertIn("thematic=them_a", response.url)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectViewerPermissionsTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from common.models import Role, UserThematics
|
||||||
|
from projects.permissions import can_view_project, can_add_or_edit_project, filter_viewable_projects_for_user
|
||||||
|
|
||||||
|
self.viewer_user = User.objects.create_user(username="viewer_user", password="password123")
|
||||||
|
self.viewer_config = UserConfig.objects.create(user=self.viewer_user, is_intern=False)
|
||||||
|
viewer_role, _ = Role.objects.get_or_create(name='viewer')
|
||||||
|
self.viewer_config.roles.add(viewer_role)
|
||||||
|
|
||||||
|
self.thematic_roads = Thematic.objects.create(code="roads", name_fr="Voirie", name_nl="Wegen")
|
||||||
|
self.thematic_water = Thematic.objects.create(code="water_proj", name_fr="Eau", name_nl="Water")
|
||||||
|
|
||||||
|
# Viewer has can_view_projects on roads, but NOT on water
|
||||||
|
UserThematics.objects.create(
|
||||||
|
user_config=self.viewer_config,
|
||||||
|
thematic=self.thematic_roads,
|
||||||
|
can_view_projects=True,
|
||||||
|
can_view_assets=True
|
||||||
|
)
|
||||||
|
UserThematics.objects.create(
|
||||||
|
user_config=self.viewer_config,
|
||||||
|
thematic=self.thematic_water,
|
||||||
|
can_view_projects=False,
|
||||||
|
can_view_assets=True
|
||||||
|
)
|
||||||
|
|
||||||
|
creator = User.objects.create_user(username="creator", password="password123")
|
||||||
|
|
||||||
|
# Project 1: roads, non-strict
|
||||||
|
self.proj_roads = Project.objects.create(
|
||||||
|
name="Projet Voirie",
|
||||||
|
created_by=creator,
|
||||||
|
strict_access=False
|
||||||
|
)
|
||||||
|
self.proj_roads.thematics.add(self.thematic_roads)
|
||||||
|
|
||||||
|
# Project 2: water, non-strict
|
||||||
|
self.proj_water = Project.objects.create(
|
||||||
|
name="Projet Eau",
|
||||||
|
created_by=creator,
|
||||||
|
strict_access=False
|
||||||
|
)
|
||||||
|
self.proj_water.thematics.add(self.thematic_water)
|
||||||
|
|
||||||
|
# Project 3: roads, strict access
|
||||||
|
self.proj_roads_strict = Project.objects.create(
|
||||||
|
name="Projet Voirie Strict",
|
||||||
|
created_by=creator,
|
||||||
|
strict_access=True
|
||||||
|
)
|
||||||
|
self.proj_roads_strict.thematics.add(self.thematic_roads)
|
||||||
|
|
||||||
|
def test_viewer_project_permissions(self):
|
||||||
|
from projects.permissions import can_view_project, can_add_or_edit_project, filter_viewable_projects_for_user
|
||||||
|
|
||||||
|
# Can view roads project (non-strict, can_view_projects=True)
|
||||||
|
self.assertTrue(can_view_project(self.viewer_user, self.proj_roads))
|
||||||
|
|
||||||
|
# Cannot view water project (can_view_projects=False)
|
||||||
|
self.assertFalse(can_view_project(self.viewer_user, self.proj_water))
|
||||||
|
|
||||||
|
# Cannot view strict project without explicit access
|
||||||
|
self.assertFalse(can_view_project(self.viewer_user, self.proj_roads_strict))
|
||||||
|
|
||||||
|
# Viewer cannot add or edit projects
|
||||||
|
self.assertFalse(can_add_or_edit_project(self.viewer_user, self.proj_roads))
|
||||||
|
self.assertFalse(can_add_or_edit_project(self.viewer_user))
|
||||||
|
|
||||||
|
# filter_viewable_projects_for_user returns only proj_roads
|
||||||
|
viewable = filter_viewable_projects_for_user(self.viewer_user)
|
||||||
|
self.assertIn(self.proj_roads, viewable)
|
||||||
|
self.assertNotIn(self.proj_water, viewable)
|
||||||
|
self.assertNotIn(self.proj_roads_strict, viewable)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,7 @@
|
||||||
<span class="ms-2 text d-none d-md-inline">{% translate "Assets" %}</span>
|
<span class="ms-2 text d-none d-md-inline">{% translate "Assets" %}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if user.config|has_any_role:"admin,manager,external_manager,controller,observer,operator,editor,top_manager" %}
|
{% if user.config|has_any_role:"admin,manager,external_manager,controller,observer,operator,editor,top_manager,viewer" %}
|
||||||
<a href="/observations/" class="nav-link d-flex align-items-center p-2 {% if request.path|slice:':13' == '/observations/' %}active{% endif %}">
|
<a href="/observations/" class="nav-link d-flex align-items-center p-2 {% if request.path|slice:':13' == '/observations/' %}active{% endif %}">
|
||||||
<i class="bi bi-eye" data-bs-toggle="tooltip" data-bs-placement="right" title="{% translate 'Observations' %}"></i>
|
<i class="bi bi-eye" data-bs-toggle="tooltip" data-bs-placement="right" title="{% translate 'Observations' %}"></i>
|
||||||
<span class="ms-2 text d-none d-md-inline">{% translate "Observations" %}</span>
|
<span class="ms-2 text d-none d-md-inline">{% translate "Observations" %}</span>
|
||||||
|
|
@ -354,7 +354,7 @@
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user.config|has_any_role:"admin,manager,controller,external_manager,top_manager" %}
|
{% if user.config|has_any_role:"admin,manager,controller,external_manager,top_manager,viewer" %}
|
||||||
<a href="/projects/" class="nav-link d-flex align-items-center p-2 {% if request.path == '/projects/' %}active{% endif %}">
|
<a href="/projects/" class="nav-link d-flex align-items-center p-2 {% if request.path == '/projects/' %}active{% endif %}">
|
||||||
<i class="bi bi-wrench-adjustable" data-bs-toggle="tooltip" data-bs-placement="right" title="{% translate 'Projets' %}"></i>
|
<i class="bi bi-wrench-adjustable" data-bs-toggle="tooltip" data-bs-placement="right" title="{% translate 'Projets' %}"></i>
|
||||||
<span class="ms-2 text d-none d-md-inline">{% translate "Projets" %}</span>
|
<span class="ms-2 text d-none d-md-inline">{% translate "Projets" %}</span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue