diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py index 331b9b1..34c8f98 100644 --- a/loko/contracts/admin.py +++ b/loko/contracts/admin.py @@ -125,9 +125,9 @@ class CompanyAdmin(admin.ModelAdmin): @admin.register(Contract) class ContractAdmin(admin.ModelAdmin): - list_display = ('contract_number', 'description', 'company', 'start_date', 'end_date', 'is_active', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'thematics_list') + list_display = ('contract_number', 'description', 'company', 'is_blocked', 'is_active', 'start_date', 'end_date', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'thematics_list') search_fields = ('contract_number', 'company__name', 'description', 'contact_phone') - list_filter = ('is_active', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'send_daily_summary') + list_filter = ('is_blocked', 'is_active', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'send_daily_summary') filter_horizontal = ('thematics',) def get_inlines(self, request, obj=None): @@ -137,6 +137,13 @@ class ContractAdmin(admin.ModelAdmin): (None, { 'fields': ('company', 'contract_number', 'description', 'start_date', 'end_date', 'is_active', 'committed_amount', 'internal_link', 'executive_officer', 'contact_phone', 'signatory_name', 'signatory_role', 'thematics'), }), + ('Blocage budgétaire / Suspension', { + 'fields': ('is_blocked', 'blocked_reason', 'blocked_at'), + 'description': ( + "Permet de bloquer temporairement l'envoi et la planification des interventions " + "vers le prestataire pour des raisons budgétaires." + ), + }), ('Notifications email', { 'fields': ('send_email_notifications', 'notification_emails', 'cc_notification_emails', 'send_daily_summary', 'daily_summary_hour', 'email_only_if_urgent'), 'description': ( diff --git a/loko/contracts/migrations/0004_contract_blocked_at_contract_blocked_reason_and_more.py b/loko/contracts/migrations/0004_contract_blocked_at_contract_blocked_reason_and_more.py new file mode 100644 index 0000000..a276ce4 --- /dev/null +++ b/loko/contracts/migrations/0004_contract_blocked_at_contract_blocked_reason_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.7 on 2026-09-02 07:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contracts', '0003_contract_allow_team_members_edit'), + ] + + operations = [ + migrations.AddField( + model_name='contract', + name='blocked_at', + field=models.DateTimeField(blank=True, help_text='Date et heure auxquelles le contrat a été marqué comme bloqué.', null=True, verbose_name='Date de blocage'), + ), + migrations.AddField( + model_name='contract', + name='blocked_reason', + field=models.TextField(blank=True, help_text='Raison du blocage temporaire du contrat.', null=True, verbose_name='Motif du blocage'), + ), + migrations.AddField( + model_name='contract', + name='is_blocked', + field=models.BooleanField(default=False, help_text="Indique si le marché est temporairement bloqué (raisons budgétaires). Empêche l'envoi d'interventions au prestataire.", verbose_name='Marché bloqué / gelé'), + ), + ] diff --git a/loko/contracts/models.py b/loko/contracts/models.py index db56dbe..fd1cd3d 100644 --- a/loko/contracts/models.py +++ b/loko/contracts/models.py @@ -346,6 +346,23 @@ class Contract(models.Model): verbose_name=_("Modification inter-membres d'équipe autorisée"), help_text=_("Si activé, les membres d'une même équipe peuvent modifier les interventions assignées à d'autres membres de leur équipe, y compris à partir du statut 'Pris en charge' ou au-delà."), ) + is_blocked = models.BooleanField( + default=False, + verbose_name=_("Marché bloqué / gelé"), + help_text=_("Indique si le marché est temporairement bloqué (raisons budgétaires). Empêche l'envoi d'interventions au prestataire."), + ) + blocked_reason = models.TextField( + blank=True, + null=True, + verbose_name=_("Motif du blocage"), + help_text=_("Raison du blocage temporaire du contrat."), + ) + blocked_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Date de blocage"), + help_text=_("Date et heure auxquelles le contrat a été marqué comme bloqué."), + ) def __str__(self): return f"Contrat {self.contract_number} - {self.company.name}" @@ -353,10 +370,11 @@ class Contract(models.Model): @property def display_name(self): desc = (self.description or '').strip() + blocked_prefix = f"[{_('BLOQUÉ')}] " if self.is_blocked else "" if desc: truncated = desc[:45] + ('…' if len(desc) > 45 else '') - return f"{self.contract_number} ({truncated})" - return self.contract_number + return f"{blocked_prefix}{self.contract_number} ({truncated})" + return f"{blocked_prefix}{self.contract_number}" class Meta: ordering = ['contract_number'] diff --git a/loko/interventions/management/commands/send_daily_intervention_summary.py b/loko/interventions/management/commands/send_daily_intervention_summary.py index d3dbb95..41abc8a 100644 --- a/loko/interventions/management/commands/send_daily_intervention_summary.py +++ b/loko/interventions/management/commands/send_daily_intervention_summary.py @@ -66,7 +66,7 @@ def _get_recipients(contract): if email_override: return [email_override] - if not contract or not getattr(contract, 'send_email_notifications', True): + if not contract or not getattr(contract, 'send_email_notifications', True) or getattr(contract, 'is_blocked', False): return [] import re @@ -85,7 +85,7 @@ def _get_cc_recipients(contract): if email_override: return [] - if not contract or not getattr(contract, 'send_email_notifications', True): + if not contract or not getattr(contract, 'send_email_notifications', True) or getattr(contract, 'is_blocked', False): return [] import re @@ -157,8 +157,8 @@ class Command(BaseCommand): if dry_run: self.stdout.write(self.style.WARNING(" [MODE SIMULATION — aucun email ne sera envoyé]\n")) - # Récupérer les contrats avec envoi groupé activé - contracts_qs = Contract.objects.filter(send_daily_summary=True, is_active=True) + # Récupérer les contrats avec envoi groupé activé (non bloqués) + contracts_qs = Contract.objects.filter(send_daily_summary=True, is_active=True, is_blocked=False) if contract_id: contracts_qs = contracts_qs.filter(pk=contract_id) diff --git a/loko/interventions/migrations/0006_intervention_is_blocked_due_to_contract.py b/loko/interventions/migrations/0006_intervention_is_blocked_due_to_contract.py new file mode 100644 index 0000000..9269507 --- /dev/null +++ b/loko/interventions/migrations/0006_intervention_is_blocked_due_to_contract.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-09-02 07:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('interventions', '0005_contractoperationtemplatepost'), + ] + + operations = [ + migrations.AddField( + model_name='intervention', + name='is_blocked_due_to_contract', + field=models.BooleanField(db_index=True, default=False, help_text='Indique que cette intervention est en attente car le marché associé est temporairement bloqué pour des raisons budgétaires.', verbose_name='En attente (marché bloqué)'), + ), + ] diff --git a/loko/interventions/models.py b/loko/interventions/models.py index 4a4e645..01ae057 100644 --- a/loko/interventions/models.py +++ b/loko/interventions/models.py @@ -452,6 +452,13 @@ class Intervention(models.Model): verbose_name=_("Vérifiée"), help_text=_("Indique si l'intervention a été vérifiée par un utilisateur habilité.") ) + # Blocage contractuel / budgétaire + is_blocked_due_to_contract = models.BooleanField( + default=False, + db_index=True, + verbose_name=_("En attente (marché bloqué)"), + help_text=_("Indique que cette intervention est en attente car le marché associé est temporairement bloqué pour des raisons budgétaires.") + ) # Résultat du contrôle de suivi FOLLOW_UP_RESULT_CHOICES = [ ('ok', _('OK')), @@ -486,6 +493,13 @@ class Intervention(models.Model): if not self.code: # Generate code only if not defined next_number = InterventionSequence.get_next_number() self.code = f"I{str(next_number).zfill(6)}" # Prefix "I" followed by the number + + # Gestion de la sécurité sur contrat bloqué + if self.contract and getattr(self.contract, 'is_blocked', False): + self.is_blocked_due_to_contract = True + if is_new and self.status in ('to_be_processed', 'assigned', 'in_progress'): + self.status = 'in_preparation' + if self.status in STATUS_ORDERS: self.status_order = STATUS_ORDERS[self.status] if is_new and self.created_by_id: @@ -722,6 +736,11 @@ class Intervention(models.Model): return self.assigned_team.company.name return "" + @property + def is_contract_blocked(self): + """Retourne True si l'intervention est marquée bloquée ou si son contrat est actuellement bloqué.""" + return bool(self.is_blocked_due_to_contract or (self.contract and getattr(self.contract, 'is_blocked', False))) + def get_status_color(self): return { "in_preparation": "secondary", diff --git a/loko/interventions/permissions.py b/loko/interventions/permissions.py index 01a029b..5942105 100644 --- a/loko/interventions/permissions.py +++ b/loko/interventions/permissions.py @@ -213,6 +213,10 @@ def can_view_intervention(user, intervention): if user_config.roles.filter(name='admin').exists(): return True + # -- Exclusion des interventions bloquées pour les utilisateurs externes (prestataires) -- + if not getattr(user_config, 'is_intern', False) and getattr(intervention, 'is_blocked_due_to_contract', False): + return False + if intervention.created_by_id == user.id: return True @@ -765,6 +769,9 @@ def filter_viewable_interventions_for_user(user, limit_to_default_thematic=False ) qs = qs.annotate(effective_begin_date=Cast(effective_begin_expr, output_field=DateField())) qs = qs.filter(effective_begin_date=today) + # Exclusion des interventions bloquées pour les utilisateurs externes (prestataires) + if not getattr(user_config, 'is_intern', False): + qs = qs.exclude(is_blocked_due_to_contract=True) return qs @@ -1172,7 +1179,11 @@ def user_allowed_transitions(user, current_status, intervention=None): def _filter_transitions(transitions): if STATUS_ORDERS.get(current_status, 0) >= STATUS_ORDERS.get('assigned', 15): - return [s for s in transitions if s != 'canceled'] + transitions = [s for s in transitions if s != 'canceled'] + if intervention is not None and getattr(intervention, 'is_contract_blocked', False): + # Le marché est bloqué : interdire les transitions vers l'exécution / le prestataire + blocked_statuses = {'to_be_processed', 'assigned', 'in_progress'} + transitions = [s for s in transitions if s not in blocked_statuses] return transitions # -- Cas avec intervention : priorité contrat > thématique -- @@ -1302,6 +1313,11 @@ def get_allowed_transitions(request, intervention): current_status = intervention.status next_candidates = user_allowed_transitions(request.user, current_status, intervention=intervention) + # Si le marché est bloqué, interdire le passage à to_be_processed, assigned, in_progress + if getattr(intervention, 'is_contract_blocked', False): + blocked_statuses = {'to_be_processed', 'assigned', 'in_progress'} + next_candidates = [s for s in next_candidates if s not in blocked_statuses] + # Si l'intervention est au statut 'to_be_approved' et qu'un contrat est renseigné, # seuls les utilisateurs avec can_approve=True (ou admin) peuvent changer le statut. if current_status == 'to_be_approved' and intervention.contract_id: diff --git a/loko/interventions/static/interventions/interventions_index.js b/loko/interventions/static/interventions/interventions_index.js index 8188e81..3646849 100644 --- a/loko/interventions/static/interventions/interventions_index.js +++ b/loko/interventions/static/interventions/interventions_index.js @@ -495,6 +495,11 @@ function statusFormatter(value, row) { const prepEntry = prepStateIcons[row.prep_state]; html += ` `; } + + // Add blocked due to contract indicator if present + if (row && (row.is_blocked_due_to_contract || (row.contract && row.contract.is_blocked))) { + html += ` `; + } return html; } diff --git a/loko/interventions/templates/interventions/_interventions_list_mobile.html b/loko/interventions/templates/interventions/_interventions_list_mobile.html index 6445426..6598054 100644 --- a/loko/interventions/templates/interventions/_interventions_list_mobile.html +++ b/loko/interventions/templates/interventions/_interventions_list_mobile.html @@ -52,6 +52,11 @@ {{ intervention.code }} {{ intervention.title }} {{ intervention.get_status_display }} + {% if intervention.is_contract_blocked %} + + {% translate "Marché bloqué" %} + + {% endif %} {% if intervention.prep_state %} {% if intervention.prep_state == "processed" %} @@ -59,7 +64,7 @@ {% elif intervention.prep_state == "not_processed" %} - + {% endif %} {% endif %} diff --git a/loko/interventions/templates/interventions/detail/card_info.html b/loko/interventions/templates/interventions/detail/card_info.html index 594c7dd..83191de 100644 --- a/loko/interventions/templates/interventions/detail/card_info.html +++ b/loko/interventions/templates/interventions/detail/card_info.html @@ -31,6 +31,11 @@ {% endif %} {% endif %} {{ intervention.title }} + {% if intervention.is_contract_blocked %} + + {% translate "Marché bloqué" %} + + {% endif %} {% if show_created_by_provider_badge %} {% translate "Créée par le prestataire" %} @@ -79,6 +84,19 @@ {% endif %} + {% if intervention.is_contract_blocked %} + + {% endif %} + @@ -89,15 +107,32 @@

{% translate "Recommandations" %}

+ {% if intervention.is_contract_blocked %} +
+ +
+ {% translate "Attention" %} : + {% translate "Le marché associé à cette intervention est bloqué. Ne pas appeler le prestataire." %} +
+
+ {% endif %}
- - + diff --git a/loko/interventions/templates/interventions/intervention_detail_mobile.html b/loko/interventions/templates/interventions/intervention_detail_mobile.html index 691f8a2..5b83d55 100644 --- a/loko/interventions/templates/interventions/intervention_detail_mobile.html +++ b/loko/interventions/templates/interventions/intervention_detail_mobile.html @@ -472,9 +472,27 @@ {% endif %} +{% if intervention.is_contract_blocked %} + +{% endif %} +
{{ intervention.code }} + {% if intervention.is_contract_blocked %} + + {% translate "Marché bloqué" %} + + {% endif %}
- -
-
+ +
+
-
+
+
+
+ + +
+
{% if to_be_planned_soon %} diff --git a/loko/interventions/tests/test_blocked_contracts.py b/loko/interventions/tests/test_blocked_contracts.py new file mode 100644 index 0000000..bf2db95 --- /dev/null +++ b/loko/interventions/tests/test_blocked_contracts.py @@ -0,0 +1,220 @@ +import json +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.utils import timezone + +from common.models import UserConfig, Role, Thematic, UserThematics, UserContractAccess +from contracts.models import Company, Contract, CompanyTeam, CompanyMember +from assets.models import AssetCategory +from observations.models import Observation +from interventions.models import Intervention, Symptom +from django.http import QueryDict +from interventions.permissions import can_view_intervention, filter_viewable_interventions_for_user, user_allowed_transitions +from interventions.views.workflow import attempt_status_update +from interventions.views.helpers import get_contract_recipients, get_contract_cc_recipients, apply_intervention_filters_from_querydict + + +class BlockedContractsTests(TestCase): + def setUp(self): + self.User = get_user_model() + self.thematic, _ = Thematic.objects.get_or_create(code='trafficlights', defaults={'name': 'Feux de signalisation'}) + self.category, _ = AssetCategory.objects.get_or_create(name_fr='Test Category', thematic=self.thematic) + + # Roles + self.admin_role, _ = Role.objects.get_or_create(name='admin') + self.operator_role, _ = Role.objects.get_or_create(name='operator') + self.external_role, _ = Role.objects.get_or_create(name='external_manager') + + # Company & Contract + self.company = Company.objects.create(name='Prestataire Alpha') + today = timezone.now().date() + self.contract_active = Contract.objects.create( + contract_number='CTR-2026-001', + company=self.company, + start_date=today, + end_date=today + timezone.timedelta(days=365), + is_active=True, + is_blocked=False, + notification_emails='prestataire@example.com', + cc_notification_emails='cc@example.com' + ) + self.contract_active.thematics.add(self.thematic) + + self.contract_blocked = Contract.objects.create( + contract_number='CTR-2026-BLOCKED', + company=self.company, + start_date=today, + end_date=today + timezone.timedelta(days=365), + is_active=True, + is_blocked=True, + blocked_reason='Gel budgétaire temporaire', + notification_emails='prestataire_blocked@example.com', + cc_notification_emails='cc_blocked@example.com' + ) + self.contract_blocked.thematics.add(self.thematic) + + # Internal user (operator) + self.internal_user = self.User.objects.create_user(username='internal_op', password='pwd') + self.internal_cfg = UserConfig.objects.create(user=self.internal_user, is_intern=True) + self.internal_cfg.roles.add(self.operator_role) + UserThematics.objects.create(user_config=self.internal_cfg, thematic=self.thematic, can_view_interventions=True, can_edit_interventions=True) + uca_active = UserContractAccess.objects.create(user_config=self.internal_cfg, contract=self.contract_active, can_view_interventions=True) + uca_blocked = UserContractAccess.objects.create(user_config=self.internal_cfg, contract=self.contract_blocked, can_view_interventions=True) + from common.models import UserContractStatusPermission + for st in ['in_preparation', 'in_progress', 'to_be_processed']: + UserContractStatusPermission.objects.create(user_contract=uca_active, status=st, can_view=True) + UserContractStatusPermission.objects.create(user_contract=uca_blocked, status=st, can_view=True) + + # External user (contractor) + self.external_user = self.User.objects.create_user(username='provider_user', password='pwd') + self.external_cfg = UserConfig.objects.create(user=self.external_user, is_intern=False, company=self.company) + self.external_cfg.roles.add(self.external_role) + self.team = CompanyTeam.objects.create(name='Equipe 1', company=self.company) + self.member = CompanyMember.objects.create(name='Jean Provider', user=self.external_user) + self.member.teams.add(self.team) + UserThematics.objects.create(user_config=self.external_cfg, thematic=self.thematic, can_view_interventions=True, can_edit_interventions=True) + UserContractAccess.objects.create(user_config=self.external_cfg, contract=self.contract_active, can_view_interventions=True) + UserContractAccess.objects.create(user_config=self.external_cfg, contract=self.contract_blocked, can_view_interventions=True) + + def test_contract_display_name_and_attributes(self): + self.assertIn('[BLOQUÉ]', self.contract_blocked.display_name) + self.assertNotIn('[BLOQUÉ]', self.contract_active.display_name) + self.assertTrue(self.contract_blocked.is_blocked) + self.assertEqual(self.contract_blocked.blocked_reason, 'Gel budgétaire temporaire') + + def test_intervention_creation_with_blocked_contract_forces_preparation(self): + itv = Intervention.objects.create( + title='Intervention Bloquée Test', + contract=self.contract_blocked, + status='to_be_processed', + thematic=self.thematic, + asset_category=self.category, + ) + # Should be forced to in_preparation and is_blocked_due_to_contract=True + self.assertEqual(itv.status, 'in_preparation') + self.assertTrue(itv.is_blocked_due_to_contract) + self.assertTrue(itv.is_contract_blocked) + + def test_intervention_permissions_visibility_for_external_provider(self): + # 1. Past/In-progress intervention on same contract (simulate existing intervention prior to block) + past_itv = Intervention.objects.create( + title='Intervention passée', + contract=self.contract_active, + assigned_provider=self.company, + status='in_progress', + thematic=self.thematic, + asset_category=self.category, + ) + Intervention.objects.filter(pk=past_itv.pk).update(contract=self.contract_blocked, is_blocked_due_to_contract=False, status='in_progress') + past_itv.refresh_from_db() + # 2. Blocked intervention + blocked_itv = Intervention.objects.create( + title='Nouvelle intervention bloquée', + contract=self.contract_blocked, + assigned_provider=self.company, + status='in_preparation', + thematic=self.thematic, + asset_category=self.category, + ) + + # External user should see past_itv but NOT blocked_itv + self.assertTrue(can_view_intervention(self.external_user, past_itv)) + self.assertFalse(can_view_intervention(self.external_user, blocked_itv)) + + # Queryset filter for external user + qs = filter_viewable_interventions_for_user(self.external_user) + self.assertIn(past_itv, qs) + self.assertNotIn(blocked_itv, qs) + + # Internal user can view both + self.assertTrue(can_view_intervention(self.internal_user, past_itv)) + self.assertTrue(can_view_intervention(self.internal_user, blocked_itv)) + internal_qs = filter_viewable_interventions_for_user(self.internal_user) + self.assertIn(past_itv, internal_qs) + self.assertIn(blocked_itv, internal_qs) + + def test_workflow_transitions_prevented_for_blocked_contract(self): + blocked_itv = Intervention.objects.create( + title='Intervention Bloquée', + contract=self.contract_blocked, + status='in_preparation', + thematic=self.thematic, + asset_category=self.category, + ) + + allowed_transitions = user_allowed_transitions(self.internal_user, 'in_preparation', intervention=blocked_itv) + self.assertNotIn('to_be_processed', allowed_transitions) + self.assertNotIn('assigned', allowed_transitions) + self.assertNotIn('in_progress', allowed_transitions) + + # Direct call to attempt_status_update + success, msg, _ = attempt_status_update(None, blocked_itv, self.internal_user, 'to_be_processed') + self.assertFalse(success) + + def test_email_recipients_disabled_for_blocked_contract(self): + # Active contract has recipients + recipients_active = get_contract_recipients(self.contract_active) + self.assertIn('prestataire@example.com', recipients_active) + + # Blocked contract returns empty list + recipients_blocked = get_contract_recipients(self.contract_blocked) + self.assertEqual(recipients_blocked, []) + + cc_blocked = get_contract_cc_recipients(self.contract_blocked) + self.assertEqual(cc_blocked, []) + + def test_observation_auto_create_intervention_with_blocked_contract(self): + symptom = Symptom.objects.create( + name_fr='Panne de feu', + thematic=self.thematic, + contract=self.contract_blocked, + auto_create_intervention_status='to_be_processed', + ) + + obs = Observation.objects.create( + thematic=self.thematic, + symptom=symptom, + category=self.category, + description='Feu tricolore éteint', + longitude=4.35, + latitude=50.85, + created_by=self.internal_user, + ) + + obs.create_auto_intervention() + self.assertIsNotNone(obs.intervention) + itv = obs.intervention + self.assertEqual(itv.status, 'in_preparation') + self.assertTrue(itv.is_blocked_due_to_contract) + self.assertTrue(itv.is_contract_blocked) + + def test_filter_by_is_blocked_due_to_contract(self): + itv_active = Intervention.objects.create( + title='Active', + contract=self.contract_active, + status='in_preparation', + thematic=self.thematic, + asset_category=self.category, + is_blocked_due_to_contract=False, + ) + itv_blocked = Intervention.objects.create( + title='Bloquée', + contract=self.contract_blocked, + status='in_preparation', + thematic=self.thematic, + asset_category=self.category, + ) + + base_qs = Intervention.objects.all() + + # Filter blocked = true + qdict_blocked = QueryDict('is_blocked_due_to_contract=1') + filtered_blocked = apply_intervention_filters_from_querydict(base_qs, qdict_blocked) + self.assertIn(itv_blocked, filtered_blocked) + self.assertNotIn(itv_active, filtered_blocked) + + # Filter blocked = false + qdict_unblocked = QueryDict('is_blocked_due_to_contract=0') + filtered_unblocked = apply_intervention_filters_from_querydict(base_qs, qdict_unblocked) + self.assertIn(itv_active, filtered_unblocked) + self.assertNotIn(itv_blocked, filtered_unblocked) diff --git a/loko/interventions/views/general.py b/loko/interventions/views/general.py index f125ee4..0bc2da8 100644 --- a/loko/interventions/views/general.py +++ b/loko/interventions/views/general.py @@ -432,6 +432,7 @@ def index(request): 'saved_sort': json.dumps(saved_sort, ensure_ascii=False), 'saved_columns': json.dumps(user_config.intervention_list_columns, ensure_ascii=False), 'is_admin': user_config.roles.filter(name='admin').exists(), + 'selected_is_blocked': request.GET.get('is_blocked') or request.GET.get('is_blocked_due_to_contract') or request.GET.get('blocked') or '', } return render(request, "interventions/interventions_index.html", context) @@ -679,6 +680,13 @@ def interventions_geojson(request): elif is_checked_filter == 'false': qs = qs.filter(is_checked=False) + # --- is_blocked_due_to_contract filter + is_blocked_filter = request.GET.get('is_blocked') or request.GET.get('is_blocked_due_to_contract') or request.GET.get('blocked') + if is_blocked_filter in ('true', '1', 'yes'): + qs = qs.filter(is_blocked_due_to_contract=True) + elif is_blocked_filter in ('false', '0', 'no'): + qs = qs.filter(is_blocked_due_to_contract=False) + # Quand is_checked est présent, restreindre aux contrats sur lesquels # l'utilisateur a explicitement le droit de vérifier les interventions. # Cela exclut les interventions sans contrat et celles des contrats non autorisés. @@ -735,6 +743,8 @@ def interventions_geojson(request): 'assigned_provider__name', 'contract__contract_number', 'contract__company__name', + 'contract__is_blocked', + 'is_blocked_due_to_contract', 'assigned_team__name', source_category_field, )[:MAX_FEATURES] @@ -761,6 +771,7 @@ def interventions_geojson(request): "assigned_provider": r['assigned_provider__name'], "contract_number": r['contract__contract_number'], "contract_provider": r['contract__company__name'], + "is_blocked_due_to_contract": bool(r['is_blocked_due_to_contract'] or r['contract__is_blocked']), "origin_type": r['origin_type_label'], "source_category": r[source_category_field] if r[source_category_field] else '', "team": r['assigned_team__name'] or '', diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py index fd0b9dc..094072a 100644 --- a/loko/interventions/views/helpers.py +++ b/loko/interventions/views/helpers.py @@ -1850,6 +1850,13 @@ def apply_intervention_filters_from_querydict(qs, params, user=None): elif is_checked_filter == 'false': qs = qs.filter(is_checked=False) + # Filtre sur les interventions bloquées pour raisons budgétaires + is_blocked_filter = params.get('is_blocked') or params.get('is_blocked_due_to_contract') or params.get('blocked') + if is_blocked_filter in ('true', '1', 'yes'): + qs = qs.filter(is_blocked_due_to_contract=True) + elif is_blocked_filter in ('false', '0', 'no'): + qs = qs.filter(is_blocked_due_to_contract=False) + if preparation_states: qs = qs.filter(prep_state__in=preparation_states) @@ -3684,6 +3691,9 @@ def get_contract_recipients(contract, intervention=None) -> List[str]: if not contract: return [] + if getattr(contract, 'is_blocked', False): + return [] + if hasattr(contract, 'send_email_notifications') and not contract.send_email_notifications: return [] @@ -3723,6 +3733,9 @@ def get_contract_cc_recipients(contract, intervention=None) -> List[str]: if not contract: return [] + if getattr(contract, 'is_blocked', False): + return [] + if hasattr(contract, 'send_email_notifications') and not contract.send_email_notifications: return [] diff --git a/loko/interventions/views/workflow.py b/loko/interventions/views/workflow.py index a05098e..b555c31 100644 --- a/loko/interventions/views/workflow.py +++ b/loko/interventions/views/workflow.py @@ -553,6 +553,11 @@ def attempt_status_update(request, intervention, user, new_status, pause_reason= if not intervention.contract: return False, _("Un contrat doit être indiqué avant de passer à ce statut."), False + # Contrôle de blocage budgétaire du contrat + if new_status in ('to_be_processed', 'assigned', 'in_progress'): + if intervention.contract and getattr(intervention.contract, 'is_blocked', False): + return False, _("Le marché associé à cette intervention est actuellement bloqué pour raisons budgétaires. Aucune transmission vers le prestataire n'est autorisée."), False + if new_status == 'to_be_planned': # Réinitialise l'équipe/membre assigné intervention.assigned_team = None @@ -1164,6 +1169,12 @@ def confirm_call(request, intervention_id): except Intervention.DoesNotExist: return JsonResponse({'success': False, 'error': 'Intervention not found'}, status=404) + if intervention.contract and getattr(intervention.contract, 'is_blocked', False): + return JsonResponse({ + 'success': False, + 'error': _("Appel non autorisé : le marché associé est actuellement bloqué pour raisons budgétaires.") + }, status=400) + try: payload = json.loads(request.body.decode() or "{}") except json.JSONDecodeError: @@ -1219,6 +1230,12 @@ def send_intervention_reminder(request, intervention_id): 'error': _("Aucun contrat n'est associé à cette intervention."), }, status=400) + if getattr(contract, 'is_blocked', False): + return JsonResponse({ + 'success': False, + 'error': _("Le marché associé à cette intervention est bloqué. Aucun rappel ne peut être envoyé."), + }, status=400) + recipients = get_contract_recipients(contract, intervention) cc_recipients = get_contract_cc_recipients(contract, intervention) if not recipients: @@ -1353,6 +1370,12 @@ def send_intervention_modification(request, intervention_id): 'error': _("Aucun contrat n'est associé à cette intervention."), }, status=400) + if getattr(contract, 'is_blocked', False): + return JsonResponse({ + 'success': False, + 'error': _("Le marché associé à cette intervention est bloqué. Aucune notification de modification ne peut être envoyée."), + }, status=400) + recipients = get_contract_recipients(contract, intervention) cc_recipients = get_contract_cc_recipients(contract, intervention) if not recipients: diff --git a/loko/observations/models.py b/loko/observations/models.py index 8270c3c..f1b67eb 100644 --- a/loko/observations/models.py +++ b/loko/observations/models.py @@ -533,7 +533,21 @@ class Observation(models.Model): from os.path import basename source_cat = SourceCategory.objects.filter(name_fr__iexact='OBSERVATION').first() - status = self.symptom.auto_create_intervention_status or 'to_be_processed' + target_contract = self.symptom.contract + assigned_provider = self.symptom.provider + if not target_contract and self.created_by: + from common.models import UserConfig + user_config = UserConfig.objects.filter(user=self.created_by).first() + if user_config and user_config.limit_interventions_to_contracts: + target_contract = user_config.get_default_contract() + if target_contract and not assigned_provider: + assigned_provider = target_contract.company + + is_contract_blocked = bool(target_contract and getattr(target_contract, 'is_blocked', False)) + if is_contract_blocked: + status = 'in_preparation' + else: + status = self.symptom.auto_create_intervention_status or 'to_be_processed' # Geometry buffer geom = None @@ -571,21 +585,11 @@ class Observation(models.Model): created_by=self.created_by, thematic=self.thematic, geom=geom, - contract=self.symptom.contract, - assigned_provider=self.symptom.provider, + contract=target_contract, + assigned_provider=assigned_provider, + is_blocked_due_to_contract=is_contract_blocked, ) - if not intervention.contract and self.created_by: - from common.models import UserConfig - user_config = UserConfig.objects.filter(user=self.created_by).first() - if user_config and user_config.limit_interventions_to_contracts: - default_contract = user_config.get_default_contract() - if default_contract: - intervention.contract = default_contract - if not intervention.assigned_provider: - intervention.assigned_provider = default_contract.company - intervention.save(update_fields=['contract', 'assigned_provider']) - # Link to observation and set observation status to 'in_progress' self.intervention = intervention self.status = 'in_progress' @@ -629,10 +633,16 @@ class Observation(models.Model): # Add comment on observation from observations.models import ObservationComment + comment_text = ( + f"Observation mise en cours suite à la création automatique de l'intervention {intervention.code} " + f"(retenue en préparation car le marché est bloqué)." + if is_contract_blocked else + f"Observation mise en cours suite à la création automatique de l'intervention {intervention.code}" + ) ObservationComment.objects.create( observation=self, author=None, - comment=f"Observation mise en cours suite à la création automatique de l'intervention {intervention.code}" + comment=comment_text ) def __str__(self):