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 %}
+
+
+
+ {% translate "Marché temporairement bloqué" %} :
+ {% translate "Cette intervention est retenue pour des raisons budgétaires. Aucun envoi de fiche ni contact téléphonique avec le prestataire ne doit être effectué." %}
+ {% if intervention.contract.blocked_reason %}
+ {% translate "Motif" %} : {{ intervention.contract.blocked_reason }}
+ {% endif %}
+
+
+ {% 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 %}
{% for recommendation in intervention.symptom.recommendations.all %}