fix(interventions,contracts): enforce can_view_intervention on detail views and allow contract posts access for global viewers

This commit is contained in:
kdeterme 2026-10-07 14:15:53 +02:00
parent ea02b12ea5
commit 51e6680763
6 changed files with 173 additions and 12 deletions

View file

@ -26,6 +26,9 @@ def get_allowed_contracts_for_user(user, for_assets=False):
except (AttributeError, UserConfig.DoesNotExist): except (AttributeError, UserConfig.DoesNotExist):
return Contract.objects.none() return Contract.objects.none()
if getattr(config, 'can_view_all_posts', False) or getattr(config, 'can_view_all_prices', False):
return Contract.objects.all()
# Vérifie les rôles # Vérifie les rôles
user_roles = set(config.roles.values_list("name", flat=True)) user_roles = set(config.roles.values_list("name", flat=True))
allowed_roles = ALLOWED_ROLES_FOR_CONTRACT_ACCESS allowed_roles = ALLOWED_ROLES_FOR_CONTRACT_ACCESS
@ -66,6 +69,9 @@ def user_has_access_to_contract(user, contract_id, for_assets=False):
except (AttributeError, UserConfig.DoesNotExist): except (AttributeError, UserConfig.DoesNotExist):
return False return False
if getattr(config, 'can_view_all_posts', False) or getattr(config, 'can_view_all_prices', False):
return True
user_roles = set(config.roles.values_list("name", flat=True)) user_roles = set(config.roles.values_list("name", flat=True))
allowed_roles = ALLOWED_ROLES_FOR_CONTRACT_ACCESS allowed_roles = ALLOWED_ROLES_FOR_CONTRACT_ACCESS
if for_assets: if for_assets:

View file

@ -725,6 +725,8 @@ class ContractGeojsonEndpointTests(TestCase):
class GlobalPostsAndPricesPermissionsTests(TestCase): class GlobalPostsAndPricesPermissionsTests(TestCase):
def setUp(self): def setUp(self):
from datetime import date
from contracts.models import Company
self.user = User.objects.create_user(username="viewer_user", password="password123") self.user = User.objects.create_user(username="viewer_user", password="password123")
self.role_viewer, _ = Role.objects.get_or_create(name="viewer") self.role_viewer, _ = Role.objects.get_or_create(name="viewer")
self.config = UserConfig.objects.create( self.config = UserConfig.objects.create(
@ -733,27 +735,73 @@ class GlobalPostsAndPricesPermissionsTests(TestCase):
can_view_all_prices=False, can_view_all_prices=False,
) )
self.config.roles.add(self.role_viewer) self.config.roles.add(self.role_viewer)
self.company = Company.objects.create(name="Company Global Tests")
self.contract = Contract.objects.create( self.contract = Contract.objects.create(
company=self.company,
contract_number="CTR-TEST-001", contract_number="CTR-TEST-001",
description="Test Contract" description="Test Contract",
start_date=date(2025, 1, 1),
end_date=date(2026, 1, 1),
) )
def test_default_viewer_cannot_view_posts_or_prices(self): def test_default_viewer_cannot_view_posts_or_prices(self):
from contracts.permissions import user_can_view_contract_prices, user_has_access_to_contract_posts from contracts.permissions import user_can_view_contract_prices, user_has_access_to_contract_posts, user_has_access_to_contract
self.assertFalse(user_can_view_contract_prices(self.user, self.contract.id)) self.assertFalse(user_can_view_contract_prices(self.user, self.contract.id))
self.assertFalse(user_has_access_to_contract_posts(self.user, self.contract.id)) self.assertFalse(user_has_access_to_contract_posts(self.user, self.contract.id))
self.assertFalse(user_has_access_to_contract(self.user, self.contract.id))
def test_viewer_with_can_view_all_posts(self): def test_viewer_with_can_view_all_posts(self):
from contracts.permissions import user_has_access_to_contract_posts from contracts.permissions import user_has_access_to_contract_posts, user_has_access_to_contract
self.config.can_view_all_posts = True self.config.can_view_all_posts = True
self.config.save() self.config.save()
self.assertTrue(user_has_access_to_contract_posts(self.user, self.contract.id)) self.assertTrue(user_has_access_to_contract_posts(self.user, self.contract.id))
self.assertTrue(user_has_access_to_contract(self.user, self.contract.id))
def test_viewer_with_can_view_all_prices(self): def test_viewer_with_can_view_all_prices(self):
from contracts.permissions import user_can_view_contract_prices from contracts.permissions import user_can_view_contract_prices, user_has_access_to_contract
self.config.can_view_all_prices = True self.config.can_view_all_prices = True
self.config.save() self.config.save()
self.assertTrue(user_can_view_contract_prices(self.user, self.contract.id)) self.assertTrue(user_can_view_contract_prices(self.user, self.contract.id))
self.assertTrue(user_has_access_to_contract(self.user, self.contract.id))
def test_contract_posts_list_view_permissions(self):
from django.urls import reverse
from contracts.models import ContractPost
ContractPost.objects.create(
contract=self.contract,
code="P01",
order_number=1,
unit="u",
unit_price=10.50,
description_fr="Poste 1",
description_nl="Post 1",
)
url = reverse('contracts:contract_posts_list', kwargs={'contract_id': self.contract.id})
self.client.force_login(self.user)
# Par défaut, le viewer ne peut pas accéder aux postes
resp = self.client.get(url)
self.assertEqual(resp.status_code, 403)
# Avec can_view_all_posts, accès autorisé mais prix masqués (can_view_all_prices=False)
self.config.can_view_all_posts = True
self.config.save()
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertTrue(data['success'])
self.assertEqual(len(data['posts']), 1)
self.assertEqual(data['posts'][0]['unit_price'], '0.00')
# Avec can_view_all_prices, le prix est visible
self.config.can_view_all_prices = True
self.config.save()
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data['posts'][0]['unit_price'], '10.50')

View file

@ -30,7 +30,7 @@ from contracts.models import (
) )
from contracts.forms import ContractOrderForm, ContractOrderQuoteQuantityForm, ClaimDeclarationAttachmentForm, ContractOrderAttachmentForm from contracts.forms import ContractOrderForm, ContractOrderQuoteQuantityForm, ClaimDeclarationAttachmentForm, ContractOrderAttachmentForm
from contracts.permissions import get_allowed_contracts_for_user, contract_view_required, order_view_required, user_can_generate_order_documents from contracts.permissions import get_allowed_contracts_for_user, contract_view_required, order_view_required, user_can_generate_order_documents
from contracts.permissions import user_can_add_order, user_can_add_order_for_contract, user_has_access_to_contract, user_has_access_to_contract_posts, user_has_access_to_contract_order, user_can_manage_direct_order_posts, user_can_validate_direct_order_posts from contracts.permissions import user_can_add_order, user_can_add_order_for_contract, user_has_access_to_contract, user_has_access_to_contract_posts, user_has_access_to_contract_order, user_can_manage_direct_order_posts, user_can_validate_direct_order_posts, user_can_view_contract_prices
from contracts.permissions import user_can_manage_quote_for_contract from contracts.permissions import user_can_manage_quote_for_contract
from contracts.views_pdf_reports import ( from contracts.views_pdf_reports import (
preview_claim_declaration_pdf, preview_claim_declaration_pdf,
@ -833,12 +833,13 @@ def contract_posts_list(request, contract_id):
Format JSON: {success: bool, posts: [{id, order_number, code, description_fr, description_nl, unit, unit_price}]} Format JSON: {success: bool, posts: [{id, order_number, code, description_fr, description_nl, unit, unit_price}]}
""" """
try: try:
# Vérifier l'accès au contrat # Vérifier l'accès aux postes du contrat
if not user_has_access_to_contract(request.user, contract_id): if not user_has_access_to_contract_posts(request.user, contract_id):
return JsonResponse({'success': False, 'error': _("Vous n'avez pas accès à ce contrat")}, status=403) return JsonResponse({'success': False, 'error': _("Vous n'avez pas accès aux postes de ce contrat")}, status=403)
contract = get_object_or_404(Contract, id=contract_id) contract = get_object_or_404(Contract, id=contract_id)
posts = ContractPost.objects.filter(contract=contract).order_by('order_number') posts = ContractPost.objects.filter(contract=contract).order_by('order_number')
can_view_prices = user_can_view_contract_prices(request.user, contract_id)
posts_data = [] posts_data = []
for post in posts: for post in posts:
@ -849,7 +850,7 @@ def contract_posts_list(request, contract_id):
'description_fr': post.description_fr, 'description_fr': post.description_fr,
'description_nl': post.description_nl, 'description_nl': post.description_nl,
'unit': post.unit, 'unit': post.unit,
'unit_price': str(post.unit_price) if post.unit_price else '0.00', 'unit_price': str(post.unit_price) if can_view_prices and post.unit_price else '0.00',
'post_type': post.post_type or '', 'post_type': post.post_type or '',
'is_coefficient_multipliable': post.is_coefficient_multipliable, 'is_coefficient_multipliable': post.is_coefficient_multipliable,
}) })

View file

@ -1121,6 +1121,107 @@ class MobileDailyRestrictedTechnicianTests(TestCase):
self.assertEqual(search_ids, [itv_today_finished.id]) self.assertEqual(search_ids, [itv_today_finished.id])
class InterventionDetailViewPermissionTests(TestCase):
def setUp(self):
from datetime import date
self.User = get_user_model()
self.thematic = Thematic.objects.create(code='lights_test', name_fr="Feux tricolores", name_nl="Verkeerslichten")
self.other_thematic = Thematic.objects.create(code='roads_test_2', name_fr="Roads", name_nl="Wegen")
self.company_eiffage = Company.objects.create(name="Eiffage")
self.company_a2 = Company.objects.create(name="A2")
self.contract_eiffage = Contract.objects.create(
company=self.company_eiffage,
contract_number="CTR-EIFFAGE",
description="Contrat Eiffage",
start_date=date(2025, 1, 1),
end_date=date(2026, 1, 1),
)
self.intervention = Intervention.objects.create(
title="Intervention Feux",
code="ITV-FEUX-01",
status="processed",
contract=self.contract_eiffage,
thematic=self.thematic,
assigned_provider=self.company_eiffage,
)
# Unauthorized external manager (A2, only Roads)
self.ext_role, _ = Role.objects.get_or_create(name="external_manager")
self.unauthorized_user = self.User.objects.create_user(username="a2manager", password="pwd")
self.unauth_config = UserConfig.objects.create(user=self.unauthorized_user, is_intern=False)
self.unauth_config.roles.add(self.ext_role)
UserThematics.objects.create(user_config=self.unauth_config, thematic=self.other_thematic)
# Authorized admin
self.admin_role, _ = Role.objects.get_or_create(name="admin")
self.admin_user = self.User.objects.create_user(username="admin_user", password="pwd")
self.admin_config = UserConfig.objects.create(user=self.admin_user, is_intern=True)
self.admin_config.roles.add(self.admin_role)
def test_unauthorized_user_forbidden_on_intervention_detail(self):
self.client.force_login(self.unauthorized_user)
url_desktop = reverse('interventions:intervention_detail', kwargs={'intervention_id': self.intervention.id})
url_mobile = reverse('interventions:intervention_detail_mobile', kwargs={'intervention_id': self.intervention.id})
resp_desktop = self.client.get(f"{url_desktop}?force_desktop=1")
self.assertEqual(resp_desktop.status_code, 403)
resp_mobile = self.client.get(url_mobile)
self.assertEqual(resp_mobile.status_code, 403)
def test_authorized_user_allowed_on_intervention_detail(self):
self.client.force_login(self.admin_user)
url_desktop = reverse('interventions:intervention_detail', kwargs={'intervention_id': self.intervention.id})
resp_desktop = self.client.get(f"{url_desktop}?force_desktop=1")
self.assertEqual(resp_desktop.status_code, 200)
def test_viewer_with_can_view_all_posts_sees_posts_on_detail(self):
# Viewer in Legal Office with thematic access to Feux tricolores
viewer_role, _ = Role.objects.get_or_create(name="viewer")
viewer_user = self.User.objects.create_user(username="mahejij_test", password="pwd")
viewer_config = UserConfig.objects.create(
user=viewer_user,
is_intern=True,
can_view_all_posts=True,
can_view_all_prices=True,
)
viewer_config.roles.add(viewer_role)
ut = UserThematics.objects.create(user_config=viewer_config, thematic=self.thematic)
from common.models import UserThematicStatusPermission
UserThematicStatusPermission.objects.create(user_thematic=ut, status=self.intervention.status, can_view=True)
# Encode a post on the intervention
post = ContractPost.objects.create(
contract=self.contract_eiffage,
code="D.2",
order_number=492,
unit="St.",
unit_price=Decimal("33.74"),
description_fr="Prestation urgente",
description_nl="Dringende prestatie",
)
InterventionContractPost.objects.create(
intervention=self.intervention,
contract_post=post,
quantity=Decimal("1.00"),
)
self.client.force_login(viewer_user)
url = reverse('interventions:intervention_detail', kwargs={'intervention_id': self.intervention.id})
resp = self.client.get(f"{url}?force_desktop=1")
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.context['can_view_posts'])
self.assertTrue(resp.context['can_view_prices'])
self.assertContains(resp, 'id="posts-section"')
self.assertContains(resp, 'D.2')
self.assertContains(resp, '33.74')

View file

@ -3,7 +3,7 @@ from django.shortcuts import render, get_object_or_404, redirect
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse, NoReverseMatch from django.urls import reverse, NoReverseMatch
from django.core.serializers import serialize from django.core.serializers import serialize
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist, PermissionDenied
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON, Distance from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON, Distance
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
@ -134,6 +134,10 @@ logger = logging.getLogger(__name__)
from interventions.views.helpers import * from interventions.views.helpers import *
def intervention_detail(request, intervention_id): def intervention_detail(request, intervention_id):
intervention = get_object_or_404(Intervention, pk=intervention_id)
if not can_view_intervention(request.user, intervention):
raise PermissionDenied(_("Vous n'avez pas la permission de voir cette intervention."))
# Redirection mobile si User-Agent correspond et pas de force_desktop # Redirection mobile si User-Agent correspond et pas de force_desktop
user_agent = request.META.get('HTTP_USER_AGENT', '').lower() user_agent = request.META.get('HTTP_USER_AGENT', '').lower()
# Mots-clés courants pour mobiles et tablettes # Mots-clés courants pour mobiles et tablettes
@ -142,7 +146,6 @@ def intervention_detail(request, intervention_id):
if not request.GET.get('force_desktop') and any(re.search(pattern, user_agent) for pattern in mobile_patterns): if not request.GET.get('force_desktop') and any(re.search(pattern, user_agent) for pattern in mobile_patterns):
return redirect('interventions:intervention_detail_mobile', intervention_id=intervention_id) return redirect('interventions:intervention_detail_mobile', intervention_id=intervention_id)
intervention = get_object_or_404(Intervention, pk=intervention_id)
intervention.prep_state = intervention.get_preparation_orders_status() intervention.prep_state = intervention.get_preparation_orders_status()
# user_config = get_object_or_404(UserConfig, user=request.user) # user_config = get_object_or_404(UserConfig, user=request.user)
user_config = (UserConfig.objects user_config = (UserConfig.objects

View file

@ -3,7 +3,7 @@ from django.shortcuts import render, get_object_or_404, redirect
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse, NoReverseMatch from django.urls import reverse, NoReverseMatch
from django.core.serializers import serialize from django.core.serializers import serialize
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist, PermissionDenied
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON, Distance from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON, Distance
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
@ -454,6 +454,8 @@ def interventions_index_mobile(request):
def intervention_detail_mobile(request, intervention_id): def intervention_detail_mobile(request, intervention_id):
intervention = get_object_or_404(Intervention, pk=intervention_id) intervention = get_object_or_404(Intervention, pk=intervention_id)
if not can_view_intervention(request.user, intervention):
raise PermissionDenied(_("Vous n'avez pas la permission de voir cette intervention."))
intervention.prep_state = intervention.get_preparation_orders_status() intervention.prep_state = intervention.get_preparation_orders_status()
user_config = (UserConfig.objects user_config = (UserConfig.objects