diff --git a/loko/interventions/permissions.py b/loko/interventions/permissions.py index fddff32..5c2f421 100644 --- a/loko/interventions/permissions.py +++ b/loko/interventions/permissions.py @@ -17,7 +17,7 @@ from common.models import ( UserThematicStatusPermission, UserContractStatusPermission, ) from interventions.models import Intervention, InterventionTimeLine, STATUS_ORDERS, STATUS_CHOICES -from contracts.models import CompanyMember +from contracts.models import CompanyMember, CompanyTeam # Ordered list of all status keys (used by get_allowed_statuses_for_user and admin UI) ALL_STATUS_KEYS = list(STATUS_CHOICES.keys()) @@ -199,13 +199,15 @@ def get_today_intervention_visibility_q(today): Condition Q pour filtrer les interventions actives le jour 'today' : 1. Planifiée pour aujourd'hui (date exacte ou comprise dans la période planifiée [planned_begin, planned_end]) 2. Démarrée aujourd'hui (begin_time__date == today) - 3. Travail en cours ou en pause (status in ['in_progress', 'on_pause']) - 4. Créée aujourd'hui sans planification préalable (urgences du jour) + 3. Terminée aujourd'hui (end_time__date == today) + 4. Travail en cours ou en pause (status in ['in_progress', 'on_pause']) + 5. Créée aujourd'hui sans planification préalable (urgences du jour) """ return ( Q(planned_begin_time__date=today) | Q(planned_begin_time__date__lte=today, planned_end_time__date__gte=today) | Q(begin_time__date=today) | + Q(end_time__date=today) | Q(status__in=['in_progress', 'on_pause']) | Q(planned_begin_time__isnull=True, begin_time__isnull=True, expected_begin_time__date=today) | Q(planned_begin_time__isnull=True, begin_time__isnull=True, expected_begin_time__isnull=True, creation_time__date=today) @@ -218,7 +220,8 @@ def is_intervention_active_for_today(intervention, today): 1. Travail en cours ou en pause 2. Planifiée pour aujourd'hui (date exacte ou comprise dans la période planifiée) 3. Démarrée aujourd'hui - 4. Créée aujourd'hui sans planification ni démarrage préalable (urgences du jour) + 4. Terminée aujourd'hui + 5. Créée aujourd'hui sans planification ni démarrage préalable (urgences du jour) """ if intervention.status in ('in_progress', 'on_pause'): return True @@ -237,6 +240,11 @@ def is_intervention_active_for_today(intervention, today): if b_date == today: return True + if intervention.end_time: + e_date = timezone.localtime(intervention.end_time).date() if timezone.is_aware(intervention.end_time) else intervention.end_time.date() + if e_date == today: + return True + if not intervention.planned_begin_time and not intervention.begin_time: if intervention.expected_begin_time: e_date = timezone.localtime(intervention.expected_begin_time).date() if timezone.is_aware(intervention.expected_begin_time) else intervention.expected_begin_time.date() @@ -250,6 +258,35 @@ def is_intervention_active_for_today(intervention, today): return False +def is_technician_with_daily_team_restriction(user, user_config=None) -> bool: + """ + Vérifie si l'utilisateur est un technicien (possède le rôle 'technician' et aucun + rôle privilégié de gestion/admin) appartenant à au moins une équipe ayant + la visibilité restreinte aux interventions du jour ('restrict_today_interventions_visibility'=True). + """ + if not user or not getattr(user, 'is_authenticated', False): + return False + + if user_config is None: + try: + user_config = UserConfig.objects.prefetch_related('roles').get(user=user) + except UserConfig.DoesNotExist: + return False + + user_roles = set(user_config.roles.values_list('name', flat=True)) + privileged_roles = {'admin', 'top_manager', 'manager', 'controller', 'operator', 'external_manager'} + if 'technician' not in user_roles or bool(user_roles & privileged_roles): + return False + + company_member = CompanyMember.objects.filter(user=user).first() + if company_member: + from contracts.services_teams import sync_member_current_teams + sync_member_current_teams(company_member) + return company_member.teams.filter(restrict_today_interventions_visibility=True).exists() + + return CompanyTeam.objects.filter(members__user=user, restrict_today_interventions_visibility=True).exists() + + def can_view_intervention(user, intervention): """ diff --git a/loko/interventions/templates/interventions/interventions_index_mobile.html b/loko/interventions/templates/interventions/interventions_index_mobile.html index 64a8277..71744f1 100644 --- a/loko/interventions/templates/interventions/interventions_index_mobile.html +++ b/loko/interventions/templates/interventions/interventions_index_mobile.html @@ -120,7 +120,9 @@
+ {% if not hide_filters or can_add_structures_repair or can_add_intervention_mobile %}
+ {% if not hide_filters %}
{% translate "Mes interventions" %}
-
+ {% endif %} +
{% if can_add_structures_repair %} {% endif %} - {% if status_filters %} + {% if status_filters and not hide_filters %}
- {% if status_filters %} + {% endif %} + {% if status_filters and not hide_filters %}

{% translate "Statuts" %}

@@ -209,7 +213,9 @@
+ {% if not hide_filters %} + {% endif %} {% if show_maintain_type_tabs %} diff --git a/loko/interventions/tests/test_visibility_permissions.py b/loko/interventions/tests/test_visibility_permissions.py index 5843080..87b346c 100644 --- a/loko/interventions/tests/test_visibility_permissions.py +++ b/loko/interventions/tests/test_visibility_permissions.py @@ -923,6 +923,205 @@ class TeamMultiThematicVisibilityTests(TestCase): self.assertIn('status=to_be_processed', response.url) +class MobileDailyRestrictedTechnicianTests(TestCase): + def setUp(self): + from common.models import Role, UserConfig, Thematic, UserThematics, UserContractAccess + from contracts.models import Company, CompanyTeam, CompanyMember, Contract + from datetime import time, timedelta + + self.User = get_user_model() + self.tech_role, _ = Role.objects.get_or_create(name='technician') + self.admin_role, _ = Role.objects.get_or_create(name='admin') + + self.company = Company.objects.create(name='EquipCo') + + # Restricted team + self.team_restricted = CompanyTeam.objects.create( + name='Daily Restricted Team', + company=self.company, + restrict_today_interventions_visibility=True, + visibility_start_hour=time(0, 0), + ) + + # Non-restricted team + self.team_unrestricted = CompanyTeam.objects.create( + name='Standard Team', + company=self.company, + restrict_today_interventions_visibility=False, + ) + + # Restricted technician + self.tech_user = self.User.objects.create_user(username='tech-restricted', password='pwd') + self.tech_config = UserConfig.objects.create( + user=self.tech_user, + is_intern=False, + ) + self.tech_config.roles.add(self.tech_role) + self.member_restricted = CompanyMember.objects.create(user=self.tech_user, name='Tech Restricted') + self.member_restricted.teams.add(self.team_restricted) + + # Unrestricted technician + self.tech_unrestricted_user = self.User.objects.create_user(username='tech-unrestricted', password='pwd') + self.tech_unrestricted_config = UserConfig.objects.create( + user=self.tech_unrestricted_user, + is_intern=False, + ) + self.tech_unrestricted_config.roles.add(self.tech_role) + self.member_unrestricted = CompanyMember.objects.create(user=self.tech_unrestricted_user, name='Tech Unrestricted') + self.member_unrestricted.teams.add(self.team_unrestricted) + + # Admin user + 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) + + self.thematic = Thematic.objects.create(code='roads', name_fr='Voirie', name_nl='Wegen') + UserThematics.objects.create(user_config=self.tech_config, thematic=self.thematic, can_view_interventions=True, can_edit_interventions=True) + UserThematics.objects.create(user_config=self.tech_unrestricted_config, thematic=self.thematic, can_view_interventions=True, can_edit_interventions=True) + + self.contract = Contract.objects.create( + contract_number='C-DAILY-TEST', + company=self.company, + start_date=timezone.now().date() - timedelta(days=10), + end_date=timezone.now().date() + timedelta(days=365), + is_active=True, + ) + UserContractAccess.objects.create(user_config=self.tech_config, contract=self.contract, can_view_interventions=True) + UserContractAccess.objects.create(user_config=self.tech_unrestricted_config, contract=self.contract, can_view_interventions=True) + + def test_is_technician_with_daily_team_restriction(self): + from interventions.permissions import is_technician_with_daily_team_restriction + + self.assertTrue(is_technician_with_daily_team_restriction(self.tech_user, self.tech_config)) + self.assertFalse(is_technician_with_daily_team_restriction(self.tech_unrestricted_user, self.tech_unrestricted_config)) + self.assertFalse(is_technician_with_daily_team_restriction(self.admin_user, self.admin_config)) + + # If restricted technician also has admin role, should return False + self.tech_config.roles.add(self.admin_role) + self.assertFalse(is_technician_with_daily_team_restriction(self.tech_user, self.tech_config)) + + def test_mobile_view_no_redirect_and_hidden_filters(self): + self.client.login(username='tech-restricted', password='pwd') + url = reverse('mobile:interventions_index_mobile') + + response = self.client.get(url) + # Should render 200 directly without 302 redirect + self.assertEqual(response.status_code, 200) + + # Context checks + self.assertTrue(response.context['hide_filters']) + self.assertTrue(response.context['is_daily_restricted_tech']) + self.assertTrue(response.context['show_mine_only']) + self.assertFalse(response.context['show_maintain_type_tabs']) + self.assertEqual(response.context['status_filters'], []) + self.assertEqual( + response.context['selected_statuses'], + ['to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished'] + ) + + # HTML checks: filter controls should be suppressed + content = response.content.decode() + self.assertNotIn('id="filter-mine"', content) + self.assertNotIn('id="toggle-status-filter"', content) + self.assertNotIn('id="statusFilterPanel"', content) + self.assertNotIn('id="maintainTypeTabs"', content) + # Search input should still be present + self.assertIn('id="searchInput"', content) + + def test_mobile_view_displays_only_today_and_allowed_statuses(self): + from datetime import datetime, timedelta + from interventions.models import Intervention + today = timezone.localtime(timezone.now()).date() + today_dt = timezone.make_aware(datetime.combine(today, datetime.min.time().replace(hour=10))) + yesterday_dt = today_dt - timedelta(days=1) + + # 1. Today, assigned to tech, status to_be_processed -> should appear + itv_today_to_be_processed = Intervention.objects.create( + title='Today to be processed', + code='ITV-TBP', + status='to_be_processed', + contract=self.contract, + thematic=self.thematic, + assigned_provider=self.company, + assigned_team=self.team_restricted, + assigned_member=self.member_restricted, + planned_begin_time=today_dt, + maintain_type='corrective', + ) + + # 2. Today, assigned to team, status finished -> should appear + itv_today_finished = Intervention.objects.create( + title='Today finished', + code='ITV-FIN', + status='finished', + contract=self.contract, + thematic=self.thematic, + assigned_provider=self.company, + assigned_team=self.team_restricted, + planned_begin_time=today_dt, + maintain_type='preventive', + ) + + # 3. Today, assigned to tech, status closed -> should NOT appear (status closed not in 5 statuses) + itv_today_closed = Intervention.objects.create( + title='Today closed', + code='ITV-CLS', + status='closed', + contract=self.contract, + thematic=self.thematic, + assigned_provider=self.company, + assigned_team=self.team_restricted, + assigned_member=self.member_restricted, + planned_begin_time=today_dt, + ) + + # 4. Yesterday, assigned to tech, status to_be_processed -> should NOT appear (not today) + itv_yesterday = Intervention.objects.create( + title='Yesterday task', + code='ITV-YEST', + status='to_be_processed', + contract=self.contract, + thematic=self.thematic, + assigned_provider=self.company, + assigned_team=self.team_restricted, + assigned_member=self.member_restricted, + planned_begin_time=yesterday_dt, + ) + + # 5. Today, assigned to other team -> should NOT appear (not "mine") + itv_other_team = Intervention.objects.create( + title='Other team task', + code='ITV-OTHER', + status='to_be_processed', + contract=self.contract, + thematic=self.thematic, + assigned_provider=self.company, + assigned_team=self.team_unrestricted, + planned_begin_time=today_dt, + ) + + self.client.login(username='tech-restricted', password='pwd') + url = reverse('mobile:interventions_index_mobile') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + interventions = list(response.context['interventions']) + itv_ids = [itv.id for itv in interventions] + + self.assertIn(itv_today_to_be_processed.id, itv_ids) + self.assertIn(itv_today_finished.id, itv_ids) + self.assertNotIn(itv_today_closed.id, itv_ids) + self.assertNotIn(itv_yesterday.id, itv_ids) + self.assertNotIn(itv_other_team.id, itv_ids) + + # Search query check + response_search = self.client.get(f"{url}?q=ITV-FIN") + self.assertEqual(response_search.status_code, 200) + search_ids = [itv.id for itv in response_search.context['interventions']] + self.assertEqual(search_ids, [itv_today_finished.id]) + + + diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index 5e2de98..2939bbb 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -79,7 +79,8 @@ from interventions.permissions import (intervention_permission_required, interve can_add_intervention, can_add_repair_intervention, can_duplicate_intervention, intervention_manage_occupations_required, get_contracts_for_intervention_creation, is_external_manager_with_contract_creation_rights, - should_show_created_by_provider_badge, can_view_correction_messages) + should_show_created_by_provider_badge, can_view_correction_messages, + is_technician_with_daily_team_restriction, get_today_intervention_visibility_q) from interventions.permissions import ALLOWED_TRANSITIONS, get_allowed_transitions, user_allowed_transitions from interventions.views.drafts import InterventionDraftManager from interventions.utils import resolve_intervention_localisations @@ -133,17 +134,29 @@ from interventions.views.helpers import * def interventions_index_mobile(request): + # Synchroniser les équipes du jour du membre avec l'historique planifié + company_member_current = CompanyMember.objects.filter(user=request.user).first() + if company_member_current: + from contracts.services_teams import sync_member_current_teams + sync_member_current_teams(company_member_current) + + # Get user config and company member for permissions and maintain type tabs logic + user_config = None + company_member = None + try: + user_config = UserConfig.objects.prefetch_related('roles').get(user=request.user) + except UserConfig.DoesNotExist: + pass + + 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() + + is_daily_restricted_tech = is_technician_with_daily_team_restriction(request.user, user_config=user_config) + # Redirect if no filter parameters are provided in GET (and it's a standard, non-AJAX request) - if not request.GET and request.headers.get('x-requested-with') != 'XMLHttpRequest': + # (Uniquement pour les utilisateurs normaux : les techniciens avec restriction quotidienne ont une vue fixe sans filtres) + if not is_daily_restricted_tech and not request.GET and request.headers.get('x-requested-with') != 'XMLHttpRequest': query_params = request.GET.copy() - - # Load user config to get saved filters - user_config = None - try: - user_config = UserConfig.objects.get(user=request.user) - except UserConfig.DoesNotExist: - pass - saved_filters = user_config.mobile_intervention_list_filters if user_config else {} if saved_filters: @@ -169,23 +182,6 @@ def interventions_index_mobile(request): return HttpResponseRedirect(f"{request.path}?{query_params.urlencode()}") - # Synchroniser les équipes du jour du membre avec l'historique planifié - company_member_current = CompanyMember.objects.filter(user=request.user).first() - if company_member_current: - from contracts.services_teams import sync_member_current_teams - sync_member_current_teams(company_member_current) - - # Get user config and company member for permissions and maintain type tabs logic - user_config = None - company_member = None - try: - user_config = UserConfig.objects.get(user=request.user) - except UserConfig.DoesNotExist: - pass - - 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() - # 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 @@ -194,8 +190,13 @@ def interventions_index_mobile(request): limit_thematic = bool(user_config and user_config.is_intern) interventions = filter_viewable_interventions_for_user(request.user, limit_to_default_thematic=limit_thematic) + if is_daily_restricted_tech: + local_now = timezone.localtime(timezone.now()) + today = local_now.date() + interventions = interventions.filter(get_today_intervention_visibility_q(today)) + # Save the current filters to user config (excluding temporary search query 'q') - if user_config and request.GET: + if not is_daily_restricted_tech and user_config and request.GET: filters_to_save = {} for key in request.GET: if key == 'q': @@ -238,7 +239,13 @@ def interventions_index_mobile(request): # Save base interventions for counting BEFORE any filtering base_interventions_for_counts = interventions - if not allowed_statuses: + if is_daily_restricted_tech: + daily_target_statuses = ['to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished'] + selected_statuses = [s for s in daily_target_statuses if s in allowed_statuses] + if not selected_statuses: + selected_statuses = daily_target_statuses + interventions = interventions.filter(status__in=selected_statuses) + elif not allowed_statuses: interventions = interventions.none() else: default_statuses = [ @@ -265,15 +272,15 @@ def interventions_index_mobile(request): user_teams = CompanyTeam.objects.filter(members__user=request.user) user_team_ids = list(user_teams.values_list('id', flat=True)) - show_mine_only = request.GET.get('mine') == '1' + if is_daily_restricted_tech: + show_mine_only = True + else: + show_mine_only = request.GET.get('mine') == '1' if show_mine_only: - interventions = interventions.filter( - Q(assigned_member__user=request.user) | Q(assigned_team_id__in=user_team_ids) - ) - base_interventions_for_counts = base_interventions_for_counts.filter( - Q(assigned_member__user=request.user) | Q(assigned_team_id__in=user_team_ids) - ) + mine_filter = Q(assigned_member__user=request.user) | Q(assigned_team_id__in=user_team_ids) + interventions = interventions.filter(mine_filter) + base_interventions_for_counts = base_interventions_for_counts.filter(mine_filter) q = request.GET.get('q') if q: @@ -341,7 +348,7 @@ def interventions_index_mobile(request): visible_maintain_types = None # None means all visible allowed_maintain_types = ['corrective', 'preventive', 'ameliorative'] # Default for internal users - if user_config: + if not is_daily_restricted_tech and user_config: if user_config.is_intern: # Internal users always see tabs show_maintain_type_tabs = True @@ -368,7 +375,7 @@ def interventions_index_mobile(request): maintain_type_counts[item['maintain_type']] = item['count'] # NOW determine the selected maintain_type (after counting) - selected_maintain_type = request.GET.get('maintain_type', '') + selected_maintain_type = '' if is_daily_restricted_tech else request.GET.get('maintain_type', '') if show_maintain_type_tabs and not selected_maintain_type: # Set corrective as default selected_maintain_type = 'corrective' if 'corrective' in allowed_maintain_types else allowed_maintain_types[0] @@ -422,7 +429,9 @@ def interventions_index_mobile(request): { 'interventions': interventions_list, 'show_mine_only': show_mine_only, - 'status_filters': [ + 'hide_filters': is_daily_restricted_tech, + 'is_daily_restricted_tech': is_daily_restricted_tech, + 'status_filters': [] if is_daily_restricted_tech else [ { 'key': status, 'label': STATUS_CHOICES.get(status, status),