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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue