feat: introduce modular intervention workflow view components and logic for status management
This commit is contained in:
parent
af949b8b3d
commit
6fe9908a11
5 changed files with 300 additions and 8 deletions
|
|
@ -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')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue