feat: introduce modular intervention workflow view components and logic for status management

This commit is contained in:
Karl Determe 2026-08-25 15:03:20 +02:00
parent af949b8b3d
commit 6fe9908a11
5 changed files with 300 additions and 8 deletions

View file

@ -873,6 +873,263 @@ class ProviderEmailNotificationTests(TestCase):
).exists())
class PhotoRequirementWorkflowTests(TestCase):
def setUp(self):
User = get_user_model()
from datetime import date
from common.models import UserContractAccess, UserContractStatusPermission
from interventions.models import Symptom
self.role_admin, _ = Role.objects.get_or_create(name='admin')
self.role_manager, _ = Role.objects.get_or_create(name='manager')
self.role_ext_manager, _ = Role.objects.get_or_create(name='external_manager')
self.role_technician, _ = Role.objects.get_or_create(name='technician')
self.company = Company.objects.create(name='Société Tech')
self.team_photos_required = CompanyTeam.objects.create(
name='Equipe Photos Requises',
company=self.company,
photos_required=True,
)
self.team_no_photos = CompanyTeam.objects.create(
name='Equipe Sans Photos',
company=self.company,
photos_required=False,
)
# Admin user
self.admin_user = User.objects.create_user(username='admin_ph_user', password='pwd')
self.admin_config = UserConfig.objects.create(user=self.admin_user, is_intern=True)
self.admin_config.roles.add(self.role_admin)
# Manager user
self.manager_user = User.objects.create_user(username='manager_ph_user', password='pwd')
self.manager_config = UserConfig.objects.create(user=self.manager_user, is_intern=True)
self.manager_config.roles.add(self.role_manager)
# External Manager user
self.ext_manager_user = User.objects.create_user(username='ext_manager_ph_user', password='pwd')
self.ext_manager_config = UserConfig.objects.create(user=self.ext_manager_user, is_intern=False, company=self.company)
self.ext_manager_config.roles.add(self.role_ext_manager)
self.ext_manager_member = CompanyMember.objects.create(name="Ext Mgr", user=self.ext_manager_user)
self.ext_manager_member.teams.add(self.team_photos_required)
# Technician user
self.tech_user = User.objects.create_user(username='tech_ph_user', password='pwd')
self.tech_config = UserConfig.objects.create(user=self.tech_user, is_intern=False, company=self.company)
self.tech_config.roles.add(self.role_technician)
self.tech_member = CompanyMember.objects.create(name="Technicien 1", user=self.tech_user)
self.tech_member.teams.add(self.team_photos_required)
# Thematic, Symptom, Contract
self.thematic = Thematic.objects.create(code='VOI_PH', name_fr='Voirie Photo', name_nl='Wegen Photo')
self.symptom = Symptom.objects.create(code='SYM-PH', name_fr='Symptome Photo', name_nl='Symptom Photo', thematic=self.thematic)
self.contract = Contract.objects.create(
contract_number='CTR-PHOTOS-TEST',
company=self.company,
start_date=date(2025, 1, 1),
end_date=date(2030, 1, 1),
)
self.contract.thematics.add(self.thematic)
for cfg in [self.admin_config, self.manager_config, self.ext_manager_config, self.tech_config]:
cfg.thematics.add(self.thematic)
cfg.contracts.add(self.contract)
uca = UserContractAccess.objects.get(user_config=cfg, contract=self.contract)
for st in ['in_preparation', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished']:
UserContractStatusPermission.objects.create(
user_contract=uca,
status=st,
can_view=True,
can_edit=True,
can_change_status_to=True,
)
def test_is_photo_required_for_user_helper(self):
from interventions.views.helpers import _is_photo_required_for_user
intervention = Intervention.objects.create(
title='Intervention Photo Helper Test',
maintain_type='corrective',
status='in_progress',
status_order=STATUS_ORDERS['in_progress'],
contract=self.contract,
assigned_provider=self.company,
assigned_team=self.team_photos_required,
assigned_member=self.tech_member,
thematic=self.thematic,
symptom=self.symptom,
created_by=self.admin_user,
)
# Technician with team photos_required=True -> True
self.assertTrue(_is_photo_required_for_user(self.tech_user, intervention))
# Manager -> False
self.assertFalse(_is_photo_required_for_user(self.manager_user, intervention))
# External Manager -> False
self.assertFalse(_is_photo_required_for_user(self.ext_manager_user, intervention))
# Admin -> False
self.assertFalse(_is_photo_required_for_user(self.admin_user, intervention))
# If team photos_required=False -> False for technician as well
intervention.assigned_team = self.team_no_photos
intervention.save(update_fields=['assigned_team'])
self.assertFalse(_is_photo_required_for_user(self.tech_user, intervention))
def test_technician_blocked_from_finishing_without_photos(self):
from interventions.views.workflow import attempt_status_update
intervention = Intervention.objects.create(
title='Intervention Tech Blocked Test',
maintain_type='corrective',
status='in_progress',
status_order=STATUS_ORDERS['in_progress'],
contract=self.contract,
assigned_provider=self.company,
assigned_team=self.team_photos_required,
assigned_member=self.tech_member,
thematic=self.thematic,
symptom=self.symptom,
created_by=self.admin_user,
)
request = mock.Mock()
request.user = self.tech_user
request.POST = {}
request.content_type = 'application/x-www-form-urlencoded'
request.build_absolute_uri = lambda uri: f"http://localhost{uri}"
# Technician tries to finish without photos
success, message, _ = attempt_status_update(
request,
intervention,
self.tech_user,
'finished',
)
self.assertFalse(success)
self.assertIn("Au moins une photo", str(message))
intervention.refresh_from_db()
self.assertEqual(intervention.status, 'in_progress')
def test_technician_allowed_to_finish_with_photos(self):
from interventions.views.workflow import attempt_status_update
from common.models import Document
intervention = Intervention.objects.create(
title='Intervention Tech Photo Allowed Test',
maintain_type='corrective',
status='in_progress',
status_order=STATUS_ORDERS['in_progress'],
contract=self.contract,
assigned_provider=self.company,
assigned_team=self.team_photos_required,
assigned_member=self.tech_member,
thematic=self.thematic,
symptom=self.symptom,
created_by=self.admin_user,
)
# Attach document/photo
doc_file = SimpleUploadedFile("chantier.jpg", b"fake image content", content_type="image/jpeg")
InterventionDocument.objects.create(
intervention=intervention,
file=doc_file,
uploaded_by=self.tech_user,
)
request = mock.Mock()
request.user = self.tech_user
request.POST = {}
request.content_type = 'application/x-www-form-urlencoded'
request.build_absolute_uri = lambda uri: f"http://localhost{uri}"
success, message, _ = attempt_status_update(
request,
intervention,
self.tech_user,
'finished',
)
self.assertTrue(success)
intervention.refresh_from_db()
self.assertEqual(intervention.status, 'finished')
def test_manager_allowed_to_finish_without_photos(self):
from interventions.views.workflow import attempt_status_update
intervention = Intervention.objects.create(
title='Intervention Manager No Photo Test',
maintain_type='corrective',
status='in_progress',
status_order=STATUS_ORDERS['in_progress'],
contract=self.contract,
assigned_provider=self.company,
assigned_team=self.team_photos_required,
assigned_member=self.tech_member,
thematic=self.thematic,
symptom=self.symptom,
created_by=self.admin_user,
)
request = mock.Mock()
request.user = self.manager_user
request.POST = {}
request.content_type = 'application/x-www-form-urlencoded'
request.build_absolute_uri = lambda uri: f"http://localhost{uri}"
# Manager finishes intervention without any photo attached
success, message, _ = attempt_status_update(
request,
intervention,
self.manager_user,
'finished',
)
self.assertTrue(success)
intervention.refresh_from_db()
self.assertEqual(intervention.status, 'finished')
def test_external_manager_allowed_to_finish_without_photos(self):
from interventions.views.workflow import attempt_status_update
intervention = Intervention.objects.create(
title='Intervention Ext Manager No Photo Test',
maintain_type='corrective',
status='in_progress',
status_order=STATUS_ORDERS['in_progress'],
contract=self.contract,
assigned_provider=self.company,
assigned_team=self.team_photos_required,
assigned_member=self.tech_member,
thematic=self.thematic,
symptom=self.symptom,
created_by=self.admin_user,
)
request = mock.Mock()
request.user = self.ext_manager_user
request.POST = {}
request.content_type = 'application/x-www-form-urlencoded'
request.build_absolute_uri = lambda uri: f"http://localhost{uri}"
# External Manager finishes intervention without any photo attached
success, message, _ = attempt_status_update(
request,
intervention,
self.ext_manager_user,
'finished',
)
self.assertTrue(success)
intervention.refresh_from_db()
self.assertEqual(intervention.status, 'finished')

View file

@ -724,11 +724,11 @@ def intervention_detail(request, intervention_id):
'desktop_assets_max_results': MAX_MOBILE_ASSET_RESULTS,
# Droit de vérification des interventions (can_check_interventions + needs_checking)
'can_check_intervention': can_check_intervention,
# Photos obligatoires pour terminer l'intervention
'photos_required': bool(intervention.assigned_team and intervention.assigned_team.photos_required),
# Photos obligatoires pour terminer l'intervention (techniciens uniquement)
'photos_required': _is_photo_required_for_user(request.user, intervention, user_config=user_config),
'photos_required_since': (lambda s: s.isoformat() if s else '')(
_get_photos_required_since(intervention)
) if (intervention.assigned_team and intervention.assigned_team.photos_required) else '',
) if _is_photo_required_for_user(request.user, intervention, user_config=user_config) else '',
# Observations liées
'linked_observations': linked_observations,
# Observation source

View file

@ -174,6 +174,7 @@ __all__ = [
'_get_available_occupation_members',
'_get_equipment_templates_for_intervention',
'_get_photos_required_since',
'_is_photo_required_for_user',
'_get_role_names',
'_get_templates_with_equipment_filters',
'_get_user_short_name',
@ -2299,6 +2300,41 @@ def _get_photos_required_since(intervention):
def _is_photo_required_for_user(user, intervention, user_config=None):
"""
Vérifie si l'obligation d'ajouter une photo s'applique à cet utilisateur pour cette intervention.
L'obligation s'applique uniquement si :
1. L'équipe assignée à l'intervention a photos_required=True.
2. L'utilisateur est un technicien (possède le rôle 'technician' et aucun rôle privilégié de gestion/admin).
"""
if not intervention:
return False
assigned_team = getattr(intervention, 'assigned_team', None)
if not assigned_team or not getattr(assigned_team, 'photos_required', False):
return False
if not user or not getattr(user, 'is_authenticated', False):
return False
if getattr(user, 'is_superuser', False):
return False
if user_config is None:
try:
user_config = UserConfig.objects.prefetch_related('roles').get(user=user)
except UserConfig.DoesNotExist:
return False
roles = _get_role_names(user_config)
privileged_roles = {'admin', 'top_manager', 'manager', 'controller', 'operator', 'external_manager'}
# L'obligation concerne uniquement les techniciens n'ayant pas de rôle privilégié (gestionnaire, admin...)
return 'technician' in roles and not bool(roles & privileged_roles)
def _get_user_short_name(user):
if not user:
return ""

View file

@ -1121,10 +1121,10 @@ def intervention_detail_mobile(request, intervention_id):
'user_roles': user_roles,
'is_technician': is_technician,
'is_user_intern': user_config.is_intern if user_config else False,
'photos_required': bool(intervention.assigned_team and intervention.assigned_team.photos_required),
'photos_required': _is_photo_required_for_user(request.user, intervention, user_config=user_config),
'photos_required_since': (lambda s: s.isoformat() if s else '')(
_get_photos_required_since(intervention)
) if (intervention.assigned_team and intervention.assigned_team.photos_required) else '',
) if _is_photo_required_for_user(request.user, intervention, user_config=user_config) else '',
# Guided operation template
'guided_template': intervention.guided_operation_template,
'guided_operation_data': getattr(intervention, 'guided_operation_data', None),

View file

@ -403,10 +403,9 @@ def attempt_status_update(request, intervention, user, new_status, pause_reason=
if current_status == new_status:
return True, _("Statut inchangé."), False
# Photos obligatoires : si l'équipe assignée l'exige, au moins une photo post-'to_be_processed' est nécessaire
# Photos obligatoires : si l'équipe assignée l'exige, au moins une photo post-'to_be_processed' est nécessaire (techniciens uniquement)
if new_status == 'finished':
assigned_team = intervention.assigned_team
if assigned_team and assigned_team.photos_required:
if _is_photo_required_for_user(user, intervention):
photos_since = _get_photos_required_since(intervention)
if photos_since:
has_photos = intervention.documents.filter(upload_date__gte=photos_since).exists()