feat: add daily intervention visibility restrictions and mobile filters for technicians with tests

This commit is contained in:
kdeterme 2026-09-30 09:47:45 +02:00
parent 79154085ee
commit dc6638c4a5
4 changed files with 297 additions and 46 deletions

View file

@ -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):
"""

View file

@ -120,7 +120,9 @@
<!-- Filtres -->
<form method="get" class="d-flex flex-column gap-2 mb-3" data-no-overlay>
{% if not hide_filters or can_add_structures_repair or can_add_intervention_mobile %}
<div class="d-flex justify-content-between align-items-center mb-2">
{% if not hide_filters %}
<div class="form-check m-0">
<input
type="checkbox"
@ -135,7 +137,8 @@
<i class="bi bi-person"></i> {% translate "Mes interventions" %}
</label>
</div>
<div class="d-flex align-items-center gap-2">
{% endif %}
<div class="d-flex align-items-center gap-2{% if hide_filters %} ms-auto{% endif %}">
{% if can_add_structures_repair %}
<a href="{% url 'mobile:add_repair_intervention_mobile' %}"
class="btn btn-primary btn-sm"
@ -150,7 +153,7 @@
<i class="bi bi-plus-lg"></i>
</a>
{% endif %}
{% if status_filters %}
{% if status_filters and not hide_filters %}
<button id="toggle-status-filter"
class="btn btn-outline-secondary btn-sm"
type="button"
@ -163,7 +166,8 @@
{% endif %}
</div>
</div>
{% if status_filters %}
{% endif %}
{% if status_filters and not hide_filters %}
<div id="statusFilterPanel" class="collapse mt-2">
<p class="mb-1 fw-semibold small text-uppercase text-muted">{% translate "Statuts" %}</p>
<div class="d-flex flex-wrap gap-2">
@ -209,7 +213,9 @@
</button>
</div>
<input type="hidden" name="sort" id="sort-hidden" value="{{ current_sort|default:'' }}">
{% if not hide_filters %}
<input type="hidden" name="maintain_type" id="maintain-type-hidden" value="{{ selected_maintain_type|default:'' }}">
{% endif %}
</form>
{% if show_maintain_type_tabs %}

View file

@ -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])

View file

@ -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),