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):
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue