feat: add viewer role permissions for projects, observations, and inspections access

This commit is contained in:
kdeterme 2026-09-04 10:44:40 +02:00
parent 9e2b2a83e6
commit 7f257a2ca3
12 changed files with 452 additions and 38 deletions

View file

@ -385,12 +385,16 @@ def can_delete_location(user, location):
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."""
if asset is 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 = {
'naturerwiz': 'water',
@ -422,10 +426,9 @@ def _get_asset_thematic(asset):
class_name = asset.__name__.lower()
for prefix, thematic_code in thematic_mappings.items():
if class_name.startswith(prefix):
try:
return Thematic.objects.get(code=thematic_code)
except Thematic.DoesNotExist:
pass
th = _resolve_thematic(thematic_code)
if th:
return th
# Fallback: check AssetCategory via ContentType
from django.contrib.contenttypes.models import ContentType
from assets.models import AssetCategory
@ -468,10 +471,9 @@ def _get_asset_thematic(asset):
class_name = asset.__class__.__name__.lower()
for prefix, thematic_code in thematic_mappings.items():
if class_name.startswith(prefix):
try:
return Thematic.objects.get(code=thematic_code)
except Thematic.DoesNotExist:
pass
th = _resolve_thematic(thematic_code)
if th:
return th
return None

View file

@ -17,7 +17,7 @@ class InspectionsConfig(AppConfig):
if getattr(user, 'is_superuser', False) or getattr(user, 'is_staff', False):
return True
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
from inspections.permissions import can_view_inspection
return can_view_inspection(user, getattr(obj, 'inspection', None))

View file

@ -9,13 +9,17 @@ from django.contrib.contenttypes.models import ContentType
def is_user_internal_or_admin(user):
"""
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:
return False
if getattr(user, 'is_superuser', False) or getattr(user, 'is_staff', False):
return True
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 False
@ -45,10 +49,52 @@ def can_edit_inspection(user, inspection):
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):
"""
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 :
1. L'utilisateur est l'auteur/inspecteur de l'inspection.
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:
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
if inspection.inspector_id == user.id:
return True
@ -198,6 +257,63 @@ def filter_viewable_inspections_for_user(user, qs=None):
return qs
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 = []
# 1. Auteur / Inspecteur de l'inspection

View file

@ -1022,3 +1022,122 @@ class ExternalUserInspectionVisibilityTestCase(TestCase):
self.assertEqual(detail_res.status_code, 200)
data = detail_res.json()
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)

View file

@ -44,7 +44,8 @@ def _filter_inspections_queryset(request):
from .permissions import filter_viewable_inspections_for_user
qs = filter_viewable_inspections_for_user(request.user)
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')
thematics = request.GET.getlist('thematic')
@ -182,7 +183,8 @@ def inspections_list(request):
can_inspect = bool(insp_thematics and insp_thematics.exists())
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', '')
status_filter = request.GET.get('status', '')
nature_filter = request.GET.get('nature', '')
@ -193,7 +195,8 @@ def inspections_list(request):
from .permissions import filter_viewable_inspections_for_user
qs = filter_viewable_inspections_for_user(request.user)
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')
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
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:
qs = qs.filter(mission_intervention_id=mission_id)
@ -1056,6 +1062,11 @@ def inspections_geojson(request):
features = []
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)
must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
accessible_contract_ids = []
@ -1091,7 +1102,9 @@ def inspections_geojson(request):
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:
continue
@ -1111,7 +1124,7 @@ def inspections_geojson(request):
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]
target_obj = top_insp.asset
target_obj = asset_obj_map.get(asset_id)
inspections_data = []
for insp in insp_list:
@ -1124,6 +1137,8 @@ def inspections_geojson(request):
'is_image': doc.is_image
})
can_edit = is_admin_user or (insp.inspector_id == request.user.id)
inspections_data.append({
'id': insp.id,
'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 '',
'target_display': insp.target_display,
'mobile_asset_url': insp.mobile_asset_url,
'can_edit': insp.detailed_inspection.can_edit(request.user),
'can_edit': can_edit,
'documents': docs_data,
})
@ -1170,6 +1185,14 @@ def inspections_geojson(request):
qs = _filter_inspections_queryset(request)[:5000]
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 = {}
for insp in inspections_list:
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))
)
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:
assets_by_ct_and_id[(ct_id, asset_obj.id)] = asset_obj
except Exception:
@ -1208,7 +1231,7 @@ def inspections_geojson(request):
if lon is not None and lat is not None:
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_code = th.code if th else ''
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}"
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({
"type": "Feature",
"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 '',
"verified_obsolescence": insp.verified_obsolescence or '',
"is_cancelled": insp.is_cancelled,
"documents_count": insp.documents.count(),
"can_edit": insp.detailed_inspection.can_edit(request.user),
"documents_count": len(insp.documents.all()),
"can_edit": can_edit,
}
})
@ -1279,6 +1304,7 @@ def inspections_export_excel(request):
qs = _filter_inspections_queryset(request)[:5000]
inspections_list = list(qs)
thematics_by_code = {t.code: t for t in Thematic.objects.all()}
ct_to_ids = {}
for insp in inspections_list:
@ -1292,7 +1318,7 @@ def inspections_export_excel(request):
if ModelClass:
asset_qs = ModelClass.objects.filter(id__in=obj_ids)
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:
assets_by_ct_and_id[(ct_id, asset_obj.id)] = asset_obj
except Exception:

View file

@ -100,7 +100,7 @@
</div>
{% 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">
<a class="card-link d-block" href="{% url 'mobile:projects_index' %}">
<div class="icon-wrapper">
@ -112,7 +112,7 @@
</div>
{% 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">
<a class="card-link d-block" href="{% url 'mobile:observations_index_mobile' %}">
<div class="icon-wrapper">
@ -124,7 +124,7 @@
</div>
{% endif %}
{% if can_inspect %}
{% if can_inspect or can_view_inspections %}
<div class="col-12">
<a class="card-link d-block" href="{% url 'mobile:inspections_list_mobile' %}">
<div class="icon-wrapper">

View file

@ -111,6 +111,7 @@ def index(request):
break
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", {
'can_edit_green_surfaces': can_edit_green_surfaces,
@ -120,6 +121,7 @@ def index(request):
'is_inspector': is_inspector,
'inspection_configs': inspection_configs,
'can_inspect': can_inspect,
'can_view_inspections': can_view_inspections,
})
@login_not_required

View file

@ -132,16 +132,20 @@ def get_observation_access_context(user, user_config=None) -> Optional[Observati
except UserConfig.DoesNotExist:
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(
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']}
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)
return ObservationAccessContext(

View file

@ -572,3 +572,70 @@ class ObservationPermissionTests(TestCase):
self.assertEqual(total_interventions, 1)
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))

View file

@ -36,9 +36,9 @@ def can_view_project(user, project):
if project.strict_access:
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
if thematics and user_config.is_intern:
if thematics and (user_config.is_intern or user_config.has_role('viewer')):
if UserThematics.objects.filter(
user_config=user_config,
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)
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(
user_config=user_config,
can_view_projects=True

View file

@ -50,7 +50,8 @@ class ProjectFiltersPreferencesTest(TestCase):
# Check that thematic choices are populated
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):
# Without filter, return all (within permissions limit)
@ -129,4 +130,81 @@ class ProjectFiltersPreferencesTest(TestCase):
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)

View file

@ -333,7 +333,7 @@
<span class="ms-2 text d-none d-md-inline">{% translate "Assets" %}</span>
</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 %}">
<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>
@ -354,7 +354,7 @@
</a>
{% 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 %}">
<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>