fix(interventions,contracts): enforce can_view_intervention on detail views and allow contract posts access for global viewers
This commit is contained in:
parent
ea02b12ea5
commit
51e6680763
6 changed files with 173 additions and 12 deletions
|
|
@ -26,6 +26,9 @@ def get_allowed_contracts_for_user(user, for_assets=False):
|
|||
except (AttributeError, UserConfig.DoesNotExist):
|
||||
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
|
||||
user_roles = set(config.roles.values_list("name", flat=True))
|
||||
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):
|
||||
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))
|
||||
allowed_roles = ALLOWED_ROLES_FOR_CONTRACT_ACCESS
|
||||
if for_assets:
|
||||
|
|
|
|||
|
|
@ -725,6 +725,8 @@ class ContractGeojsonEndpointTests(TestCase):
|
|||
|
||||
class GlobalPostsAndPricesPermissionsTests(TestCase):
|
||||
def setUp(self):
|
||||
from datetime import date
|
||||
from contracts.models import Company
|
||||
self.user = User.objects.create_user(username="viewer_user", password="password123")
|
||||
self.role_viewer, _ = Role.objects.get_or_create(name="viewer")
|
||||
self.config = UserConfig.objects.create(
|
||||
|
|
@ -733,27 +735,73 @@ class GlobalPostsAndPricesPermissionsTests(TestCase):
|
|||
can_view_all_prices=False,
|
||||
)
|
||||
self.config.roles.add(self.role_viewer)
|
||||
self.company = Company.objects.create(name="Company Global Tests")
|
||||
self.contract = Contract.objects.create(
|
||||
company=self.company,
|
||||
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):
|
||||
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_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):
|
||||
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.save()
|
||||
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):
|
||||
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.save()
|
||||
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')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from contracts.models import (
|
|||
)
|
||||
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 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.views_pdf_reports import (
|
||||
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}]}
|
||||
"""
|
||||
try:
|
||||
# Vérifier l'accès au contrat
|
||||
if not user_has_access_to_contract(request.user, contract_id):
|
||||
return JsonResponse({'success': False, 'error': _("Vous n'avez pas accès à ce contrat")}, status=403)
|
||||
# Vérifier l'accès aux postes du contrat
|
||||
if not user_has_access_to_contract_posts(request.user, contract_id):
|
||||
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)
|
||||
posts = ContractPost.objects.filter(contract=contract).order_by('order_number')
|
||||
can_view_prices = user_can_view_contract_prices(request.user, contract_id)
|
||||
|
||||
posts_data = []
|
||||
for post in posts:
|
||||
|
|
@ -849,7 +850,7 @@ def contract_posts_list(request, contract_id):
|
|||
'description_fr': post.description_fr,
|
||||
'description_nl': post.description_nl,
|
||||
'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 '',
|
||||
'is_coefficient_multipliable': post.is_coefficient_multipliable,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1121,6 +1121,107 @@ class MobileDailyRestrictedTechnicianTests(TestCase):
|
|||
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')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from django.shortcuts import render, get_object_or_404, redirect
|
|||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse, NoReverseMatch
|
||||
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.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON, Distance
|
||||
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
|
||||
|
|
@ -134,6 +134,10 @@ logger = logging.getLogger(__name__)
|
|||
from interventions.views.helpers import *
|
||||
|
||||
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
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', '').lower()
|
||||
# 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):
|
||||
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()
|
||||
# user_config = get_object_or_404(UserConfig, user=request.user)
|
||||
user_config = (UserConfig.objects
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from django.shortcuts import render, get_object_or_404, redirect
|
|||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse, NoReverseMatch
|
||||
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.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON, Distance
|
||||
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):
|
||||
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()
|
||||
|
||||
user_config = (UserConfig.objects
|
||||
|
|
|
|||
Loading…
Reference in a new issue