From 8b13236a26052b38e1f32cd4818eb6c74ca85676 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 28 Sep 2026 21:01:59 +0200 Subject: [PATCH] fix(interventions,mobile): allow multi-thematic visibility for contractor teams and prevent persistent search query --- loko/interventions/permissions.py | 12 +- .../tests/test_visibility_permissions.py | 122 +++++++++++++++++- loko/interventions/views/mobile.py | 19 ++- 3 files changed, 146 insertions(+), 7 deletions(-) diff --git a/loko/interventions/permissions.py b/loko/interventions/permissions.py index 7b83232..fddff32 100644 --- a/loko/interventions/permissions.py +++ b/loko/interventions/permissions.py @@ -773,7 +773,17 @@ def filter_viewable_interventions_for_user(user, limit_to_default_thematic=False qs = Intervention.objects.filter(q) if limit_to_default_thematic and user_config.default_thematic: - qs = qs.filter(thematic=user_config.default_thematic) + if company_member: + assigned_q = ( + Q(assigned_member=company_member) | + Q(assigned_team__in=list(company_member.teams.all())) | + Q(contract__allow_team_members_edit=True, assigned_member__teams__in=list(company_member.teams.all())) | + Q(assigned_control_team__in=list(company_member.teams.all())) | + Q(assigned_controller=company_member) + ) + qs = qs.filter(Q(thematic=user_config.default_thematic) | assigned_q) + else: + qs = qs.filter(thematic=user_config.default_thematic) if company_member: qs = apply_maintain_type_filter(qs, user) diff --git a/loko/interventions/tests/test_visibility_permissions.py b/loko/interventions/tests/test_visibility_permissions.py index 8619ed5..5843080 100644 --- a/loko/interventions/tests/test_visibility_permissions.py +++ b/loko/interventions/tests/test_visibility_permissions.py @@ -8,7 +8,7 @@ from django.contrib import messages from django.contrib.messages import get_messages from django.core.files.uploadedfile import SimpleUploadedFile -from common.models import UserConfig, Role, Thematic, UserThematics +from common.models import UserConfig, Role, Thematic, UserThematics, UserContractAccess from contracts.models import Company, CompanyTeam, CompanyMember, Contract, ContractPost, ContractOrder, ContractOrderQuote, ContractOrderQuoteItem from assets.models import AssetCategory from interventions.models import ( @@ -804,6 +804,126 @@ class QuickProviderContractUpdateTests(TestCase): self.assertTrue(can_edit_intervention(self.user, self.intervention)) +class TeamMultiThematicVisibilityTests(TestCase): + def setUp(self): + self.User = get_user_model() + self.tech_role, _ = Role.objects.get_or_create(name='technician') + + self.thematic_structures = Thematic.objects.create(code='structures', name_fr="Ouvrages d'art", name_nl='Kunstwerken') + self.thematic_its = Thematic.objects.create(code='its', name_fr='ITS', name_nl='ITS') + + self.company = Company.objects.create(name='Eiffage') + self.team = CompanyTeam.objects.create(name='Equipe de jour Tunnels', company=self.company) + self.team.thematics.add(self.thematic_structures, self.thematic_its) + + self.contract = Contract.objects.create( + contract_number='CNT-TUNNELS', + company=self.company, + start_date=timezone.now().date() - timedelta(days=10), + end_date=timezone.now().date() + timedelta(days=365), + is_active=True, + ) + self.contract.thematics.add(self.thematic_structures, self.thematic_its) + + self.user = self.User.objects.create_user(username='tech-zidan', password='pwd') + self.user_config = UserConfig.objects.create( + user=self.user, + is_intern=False, + default_thematic=self.thematic_structures, + limit_interventions_to_contracts=True, + ) + self.user_config.roles.add(self.tech_role) + + UserThematics.objects.create( + user_config=self.user_config, + thematic=self.thematic_structures, + can_view_interventions=True, + can_edit_interventions=True, + ) + UserThematics.objects.create( + user_config=self.user_config, + thematic=self.thematic_its, + can_view_interventions=True, + can_edit_interventions=True, + ) + + UserContractAccess.objects.create( + user_config=self.user_config, + contract=self.contract, + can_view_interventions=True, + ) + + self.member = CompanyMember.objects.create(user=self.user, name='Zidane') + self.member.teams.add(self.team) + + # Intervention 1: thematic ITS, assigned to the technician's team + self.itv_its = Intervention.objects.create( + title='Camera defect', + thematic=self.thematic_its, + status='to_be_processed', + assigned_provider=self.company, + assigned_team=self.team, + contract=self.contract, + ) + + # Intervention 2: thematic structures, assigned to the technician's team + self.itv_struct = Intervention.objects.create( + title='Lighting defect', + thematic=self.thematic_structures, + status='to_be_processed', + assigned_provider=self.company, + assigned_team=self.team, + contract=self.contract, + ) + + def test_filter_viewable_keeps_team_assigned_intervention_even_with_limit_to_default_thematic(self): + from interventions.permissions import filter_viewable_interventions_for_user + + # Even if limit_to_default_thematic is True, the ITS intervention assigned to their team must remain visible + qs_limited = filter_viewable_interventions_for_user(self.user, limit_to_default_thematic=True) + self.assertTrue(qs_limited.filter(id=self.itv_its.id).exists()) + self.assertTrue(qs_limited.filter(id=self.itv_struct.id).exists()) + + def test_filter_viewable_without_limit_shows_all_contract_thematics(self): + from interventions.permissions import filter_viewable_interventions_for_user + + qs = filter_viewable_interventions_for_user(self.user, limit_to_default_thematic=False) + self.assertTrue(qs.filter(id=self.itv_its.id).exists()) + self.assertTrue(qs.filter(id=self.itv_struct.id).exists()) + + def test_mobile_index_does_not_persist_search_query_q(self): + self.client.login(username='tech-zidan', password='pwd') + url = reverse('mobile:interventions_index_mobile') + + # Visit mobile index with a search query + response = self.client.get(f"{url}?q=Camera&status=to_be_processed") + self.assertEqual(response.status_code, 200) + + self.user_config.refresh_from_db() + saved = self.user_config.mobile_intervention_list_filters + self.assertNotIn('q', saved) + self.assertEqual(saved.get('status'), ['to_be_processed']) + + def test_mobile_index_does_not_restore_stale_search_query_q(self): + # Simulate an old search query trapped in user_config + self.user_config.mobile_intervention_list_filters = { + 'q': 'stale_search', + 'status': ['to_be_processed'], + } + self.user_config.save() + + self.client.login(username='tech-zidan', password='pwd') + url = reverse('mobile:interventions_index_mobile') + + # Access mobile index without GET params -> redirects with saved filters + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + # Verify redirect target does NOT include q=stale_search + self.assertNotIn('q=', response.url) + self.assertIn('status=to_be_processed', response.url) + + + diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index 0742e35..5e2de98 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -148,6 +148,8 @@ def interventions_index_mobile(request): if saved_filters: for key, value in saved_filters.items(): + if key == 'q': + continue if isinstance(value, list): query_params.setlist(key, value) elif value is not None: @@ -173,10 +175,7 @@ def interventions_index_mobile(request): from contracts.services_teams import sync_member_current_teams sync_member_current_teams(company_member_current) - # permissions - interventions = filter_viewable_interventions_for_user(request.user, limit_to_default_thematic=True) - - # Get user config and company member for maintain type tabs logic + # Get user config and company member for permissions and maintain type tabs logic user_config = None company_member = None try: @@ -187,10 +186,20 @@ def interventions_index_mobile(request): if user_config and not user_config.is_intern: company_member = CompanyMember.objects.prefetch_related('teams__company', 'visible_maintain_types').filter(user=request.user).first() - # Save the current filters to user config + # permissions + # Les utilisateurs internes avec une thématique par défaut sont restreints à celle-ci. + # Pour les prestataires externes (membres d'équipes dont le contrat/métier couvre souvent + # plusieurs thématiques comme Ouvrages d'art + ITS + Centres de contrôle pour les Tunnels), + # ne pas restreindre à une seule thématique par défaut afin qu'ils voient toutes les interventions de leur contrat/équipes. + limit_thematic = bool(user_config and user_config.is_intern) + interventions = filter_viewable_interventions_for_user(request.user, limit_to_default_thematic=limit_thematic) + + # Save the current filters to user config (excluding temporary search query 'q') if user_config and request.GET: filters_to_save = {} for key in request.GET: + if key == 'q': + continue values = request.GET.getlist(key) if key == 'status': filters_to_save[key] = values