diff --git a/loko/contracts/permissions.py b/loko/contracts/permissions.py index 5dc5427..d7cba64 100644 --- a/loko/contracts/permissions.py +++ b/loko/contracts/permissions.py @@ -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: diff --git a/loko/contracts/tests.py b/loko/contracts/tests.py index f9dcd1e..03fefea 100644 --- a/loko/contracts/tests.py +++ b/loko/contracts/tests.py @@ -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') + + diff --git a/loko/contracts/views.py b/loko/contracts/views.py index d170c47..5f8a287 100644 --- a/loko/contracts/views.py +++ b/loko/contracts/views.py @@ -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, }) diff --git a/loko/interventions/tests/test_visibility_permissions.py b/loko/interventions/tests/test_visibility_permissions.py index 87b346c..645a424 100644 --- a/loko/interventions/tests/test_visibility_permissions.py +++ b/loko/interventions/tests/test_visibility_permissions.py @@ -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') + + + + diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index 7f38987..257eb11 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -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 diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index 2939bbb..0f255b0 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -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