feat: introduce contract blocking mechanism to suspend interventions and notifications for external users

This commit is contained in:
kdeterme 2026-09-02 09:42:13 +02:00
parent da038b7c4f
commit 4824415290
17 changed files with 493 additions and 36 deletions

View file

@ -125,9 +125,9 @@ class CompanyAdmin(admin.ModelAdmin):
@admin.register(Contract) @admin.register(Contract)
class ContractAdmin(admin.ModelAdmin): 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') 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',) filter_horizontal = ('thematics',)
def get_inlines(self, request, obj=None): def get_inlines(self, request, obj=None):
@ -137,6 +137,13 @@ class ContractAdmin(admin.ModelAdmin):
(None, { (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'), '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', { ('Notifications email', {
'fields': ('send_email_notifications', 'notification_emails', 'cc_notification_emails', 'send_daily_summary', 'daily_summary_hour', 'email_only_if_urgent'), 'fields': ('send_email_notifications', 'notification_emails', 'cc_notification_emails', 'send_daily_summary', 'daily_summary_hour', 'email_only_if_urgent'),
'description': ( 'description': (

View file

@ -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é'),
),
]

View file

@ -346,6 +346,23 @@ class Contract(models.Model):
verbose_name=_("Modification inter-membres d'équipe autorisée"), 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à."), 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): def __str__(self):
return f"Contrat {self.contract_number} - {self.company.name}" return f"Contrat {self.contract_number} - {self.company.name}"
@ -353,10 +370,11 @@ class Contract(models.Model):
@property @property
def display_name(self): def display_name(self):
desc = (self.description or '').strip() desc = (self.description or '').strip()
blocked_prefix = f"[{_('BLOQUÉ')}] " if self.is_blocked else ""
if desc: if desc:
truncated = desc[:45] + ('…' if len(desc) > 45 else '') truncated = desc[:45] + ('…' if len(desc) > 45 else '')
return f"{self.contract_number} ({truncated})" return f"{blocked_prefix}{self.contract_number} ({truncated})"
return self.contract_number return f"{blocked_prefix}{self.contract_number}"
class Meta: class Meta:
ordering = ['contract_number'] ordering = ['contract_number']

View file

@ -66,7 +66,7 @@ def _get_recipients(contract):
if email_override: if email_override:
return [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 [] return []
import re import re
@ -85,7 +85,7 @@ def _get_cc_recipients(contract):
if email_override: if email_override:
return [] 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 [] return []
import re import re
@ -157,8 +157,8 @@ class Command(BaseCommand):
if dry_run: if dry_run:
self.stdout.write(self.style.WARNING(" [MODE SIMULATION — aucun email ne sera envoyé]\n")) self.stdout.write(self.style.WARNING(" [MODE SIMULATION — aucun email ne sera envoyé]\n"))
# Récupérer les contrats avec envoi groupé activé # Récupérer les contrats avec envoi groupé activé (non bloqués)
contracts_qs = Contract.objects.filter(send_daily_summary=True, is_active=True) contracts_qs = Contract.objects.filter(send_daily_summary=True, is_active=True, is_blocked=False)
if contract_id: if contract_id:
contracts_qs = contracts_qs.filter(pk=contract_id) contracts_qs = contracts_qs.filter(pk=contract_id)

View file

@ -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é)'),
),
]

View file

@ -452,6 +452,13 @@ class Intervention(models.Model):
verbose_name=_("Vérifiée"), verbose_name=_("Vérifiée"),
help_text=_("Indique si l'intervention a été vérifiée par un utilisateur habilité.") 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 # Résultat du contrôle de suivi
FOLLOW_UP_RESULT_CHOICES = [ FOLLOW_UP_RESULT_CHOICES = [
('ok', _('OK')), ('ok', _('OK')),
@ -486,6 +493,13 @@ class Intervention(models.Model):
if not self.code: # Generate code only if not defined if not self.code: # Generate code only if not defined
next_number = InterventionSequence.get_next_number() next_number = InterventionSequence.get_next_number()
self.code = f"I{str(next_number).zfill(6)}" # Prefix "I" followed by the 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: if self.status in STATUS_ORDERS:
self.status_order = STATUS_ORDERS[self.status] self.status_order = STATUS_ORDERS[self.status]
if is_new and self.created_by_id: if is_new and self.created_by_id:
@ -722,6 +736,11 @@ class Intervention(models.Model):
return self.assigned_team.company.name return self.assigned_team.company.name
return "" 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): def get_status_color(self):
return { return {
"in_preparation": "secondary", "in_preparation": "secondary",

View file

@ -213,6 +213,10 @@ def can_view_intervention(user, intervention):
if user_config.roles.filter(name='admin').exists(): if user_config.roles.filter(name='admin').exists():
return True 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: if intervention.created_by_id == user.id:
return True 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.annotate(effective_begin_date=Cast(effective_begin_expr, output_field=DateField()))
qs = qs.filter(effective_begin_date=today) 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 return qs
@ -1172,7 +1179,11 @@ def user_allowed_transitions(user, current_status, intervention=None):
def _filter_transitions(transitions): def _filter_transitions(transitions):
if STATUS_ORDERS.get(current_status, 0) >= STATUS_ORDERS.get('assigned', 15): 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 return transitions
# -- Cas avec intervention : priorité contrat > thématique -- # -- Cas avec intervention : priorité contrat > thématique --
@ -1302,6 +1313,11 @@ def get_allowed_transitions(request, intervention):
current_status = intervention.status current_status = intervention.status
next_candidates = user_allowed_transitions(request.user, current_status, intervention=intervention) 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é, # 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. # seuls les utilisateurs avec can_approve=True (ou admin) peuvent changer le statut.
if current_status == 'to_be_approved' and intervention.contract_id: if current_status == 'to_be_approved' and intervention.contract_id:

View file

@ -496,6 +496,11 @@ function statusFormatter(value, row) {
html += `&nbsp;<span class="badge text-bg-${prepEntry.color}" title="${prepEntry.label}"><i class="bi ${prepEntry.icon}"></i></span>`; html += `&nbsp;<span class="badge text-bg-${prepEntry.color}" title="${prepEntry.label}"><i class="bi ${prepEntry.icon}"></i></span>`;
} }
// Add blocked due to contract indicator if present
if (row && (row.is_blocked_due_to_contract || (row.contract && row.contract.is_blocked))) {
html += `&nbsp;<span class="badge text-bg-danger" title="${gettext('Marché temporairement bloqué')}"><i class="bi bi-slash-circle"></i></span>`;
}
return html; return html;
} }

View file

@ -52,6 +52,11 @@
<a href="{% url 'mobile:intervention_detail_mobile' intervention.id %}" class="me-2">{{ intervention.code }}</a> {{ intervention.title }} <a href="{% url 'mobile:intervention_detail_mobile' intervention.id %}" class="me-2">{{ intervention.code }}</a> {{ intervention.title }}
</h6> </h6>
<span class="badge bg-{{ intervention.get_status_color }} status-{{ intervention.status }}">{{ intervention.get_status_display }}</span> <span class="badge bg-{{ intervention.get_status_color }} status-{{ intervention.status }}">{{ intervention.get_status_display }}</span>
{% if intervention.is_contract_blocked %}
<span class="badge bg-danger" title="{% translate 'Marché temporairement bloqué' %}">
<i class="bi bi-slash-circle"></i> {% translate "Marché bloqué" %}
</span>
{% endif %}
{% if intervention.prep_state %} {% if intervention.prep_state %}
{% if intervention.prep_state == "processed" %} {% if intervention.prep_state == "processed" %}
<span class="badge bg-success" title="{% translate 'Stock prêt' %}"> <span class="badge bg-success" title="{% translate 'Stock prêt' %}">
@ -59,7 +64,7 @@
</span> </span>
{% elif intervention.prep_state == "not_processed" %} {% elif intervention.prep_state == "not_processed" %}
<span class="badge bg-danger" title="{% translate 'Stock en attente' %}"> <span class="badge bg-danger" title="{% translate 'Stock en attente' %}">
<i class="bi bi-box"></i> <i class="bi bi-box"></i>
</span> </span>
{% endif %} {% endif %}
{% endif %} {% endif %}

View file

@ -31,6 +31,11 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
<span class="field-value" style="color: var(--brand-color);">{{ intervention.title }}</span> <span class="field-value" style="color: var(--brand-color);">{{ intervention.title }}</span>
{% if intervention.is_contract_blocked %}
<span class="badge bg-danger text-white ms-2" title="{% translate 'Marché temporairement bloqué pour raisons budgétaires' %}">
<i class="bi bi-slash-circle"></i> {% translate "Marché bloqué" %}
</span>
{% endif %}
{% if show_created_by_provider_badge %} {% if show_created_by_provider_badge %}
<span class="badge bg-warning text-dark ms-2" title="{% translate 'Cette intervention a été créée par le prestataire' %}"> <span class="badge bg-warning text-dark ms-2" title="{% translate 'Cette intervention a été créée par le prestataire' %}">
<i class="bi bi-person-badge"></i> {% translate "Créée par le prestataire" %} <i class="bi bi-person-badge"></i> {% translate "Créée par le prestataire" %}
@ -79,6 +84,19 @@
{% endif %} {% endif %}
</div> </div>
{% if intervention.is_contract_blocked %}
<div class="alert alert-danger d-flex align-items-center mt-3 mb-2 py-2 px-3 shadow-sm border-danger" role="alert">
<i class="bi bi-slash-circle-fill fs-4 me-2 text-danger"></i>
<div>
<strong>{% translate "Marché temporairement bloqué" %} :</strong>
{% 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 %}
<br><small class="text-muted"><em>{% translate "Motif" %} : {{ intervention.contract.blocked_reason }}</em></small>
{% endif %}
</div>
</div>
{% endif %}
@ -89,15 +107,32 @@
<h4>{% translate "Recommandations" %}</h4> <h4>{% translate "Recommandations" %}</h4>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if intervention.is_contract_blocked %}
<div class="alert alert-warning d-flex align-items-center mb-3 py-2 px-3">
<i class="bi bi-telephone-x-fill fs-4 me-2 text-danger"></i>
<div>
<strong>{% translate "Attention" %} :</strong>
{% translate "Le marché associé à cette intervention est bloqué. Ne pas appeler le prestataire." %}
</div>
</div>
{% endif %}
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
{% for recommendation in intervention.symptom.recommendations.all %} {% for recommendation in intervention.symptom.recommendations.all %}
<li class="list-group-item list-group-item-info d-flex justify-content-between align-items-center"> <li class="list-group-item list-group-item-info d-flex justify-content-between align-items-center">
<div> <div>
<i class="bi {{recommendation.icon}} me-2"></i> <i class="bi {{recommendation.icon}} me-2"></i>
{% render_recommendation recommendation intervention.contract as rendered_text %} {% if intervention.is_contract_blocked and recommendation.confirm_call %}
{{ rendered_text|safe }} <span class="text-decoration-line-through text-muted">
{% render_recommendation recommendation intervention.contract as rendered_text %}
{{ rendered_text|safe }}
</span>
<span class="badge bg-danger ms-2">{% translate "Appel interdit (marché bloqué)" %}</span>
{% else %}
{% render_recommendation recommendation intervention.contract as rendered_text %}
{{ rendered_text|safe }}
{% endif %}
</div> </div>
{% if recommendation.confirm_call %} {% if recommendation.confirm_call and not intervention.is_contract_blocked %}
<button class="btn btn-outline-success btn-sm confirm-call" <button class="btn btn-outline-success btn-sm confirm-call"
data-call-id="{{recommendation.order_number}}" data-call-id="{{recommendation.order_number}}"
data-confirmed-at="{{ intervention.init_call_confirmed_time|date:'c' }}" data-confirmed-at="{{ intervention.init_call_confirmed_time|date:'c' }}"
@ -953,8 +988,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Notes --> <!-- Notes -->

View file

@ -472,9 +472,27 @@
</div> </div>
{% endif %} {% endif %}
{% if intervention.is_contract_blocked %}
<div class="alert alert-danger d-flex align-items-center mb-3 py-2 px-3 shadow-sm border-danger" role="alert">
<i class="bi bi-slash-circle-fill fs-4 me-2 text-danger"></i>
<div>
<strong>{% translate "Marché temporairement bloqué" %} :</strong>
{% translate "Cette intervention est retenue pour des raisons budgétaires. Aucun appel ni transmission vers le prestataire n'est autorisé." %}
{% if intervention.contract.blocked_reason %}
<br><small class="text-muted"><em>{% translate "Motif" %} : {{ intervention.contract.blocked_reason }}</em></small>
{% endif %}
</div>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2"> <div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<div class="code d-flex align-items-center gap-2"> <div class="code d-flex align-items-center gap-2">
<span><i class="bi bi-tools"></i> {{ intervention.code }}</span> <span><i class="bi bi-tools"></i> {{ intervention.code }}</span>
{% if intervention.is_contract_blocked %}
<span class="badge bg-danger text-white ms-1" title="{% translate 'Marché bloqué' %}">
<i class="bi bi-slash-circle"></i> {% translate "Marché bloqué" %}
</span>
{% endif %}
<button id="subscription-btn" <button id="subscription-btn"
class="btn btn-sm {% if is_subscribed %}btn-secondary{% else %}btn-outline-secondary{% endif %} py-0 px-2" class="btn btn-sm {% if is_subscribed %}btn-secondary{% else %}btn-outline-secondary{% endif %} py-0 px-2"
onclick="toggleSubscription({{ intervention.id }})" onclick="toggleSubscription({{ intervention.id }})"

View file

@ -1017,9 +1017,9 @@
</div> </div>
</div> </div>
<!-- Options supplémentaires (En retard / Suivies) --> <!-- Options supplémentaires (En retard / Suivies / Marché bloqué) -->
<div class="row mt-3 pt-3 border-top"> <div class="row mt-3 pt-3 border-top g-3 align-items-center">
<div class="col-md-6"> <div class="col-md-4">
<div class="form-check"> <div class="form-check">
<input <input
class="form-check-input" class="form-check-input"
@ -1030,11 +1030,11 @@
{% if overdue == '1' %}checked{% endif %} {% if overdue == '1' %}checked{% endif %}
> >
<label class="form-check-label" for="overdue"> <label class="form-check-label" for="overdue">
<i class="bi bi-clock-history text-danger me-1"></i>{% translate "Interventions non traitées (en retard)" %} <i class="bi bi-clock-history text-danger me-1"></i>{% translate "Non traitées (en retard)" %}
</label> </label>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<div class="form-check"> <div class="form-check">
<input <input
class="form-check-input" class="form-check-input"
@ -1045,10 +1045,22 @@
{% if followed_only == '1' %}checked{% endif %} {% if followed_only == '1' %}checked{% endif %}
> >
<label class="form-check-label" for="followed_only"> <label class="form-check-label" for="followed_only">
<i class="bi bi-star-fill text-warning me-1"></i>{% translate "Interventions suivies uniquement" %} <i class="bi bi-star-fill text-warning me-1"></i>{% translate "Suivies uniquement" %}
</label> </label>
</div> </div>
</div> </div>
<div class="col-md-4">
<div class="d-flex align-items-center gap-2">
<label class="form-label small fw-semibold mb-0 text-nowrap" for="is_blocked_due_to_contract">
<i class="bi bi-slash-circle text-danger me-1"></i>{% translate "Marché bloqué" %} :
</label>
<select name="is_blocked_due_to_contract" id="is_blocked_due_to_contract" class="form-select form-select-sm">
<option value="" {% if not selected_is_blocked %}selected{% endif %}>{% translate "Tous" %}</option>
<option value="1" {% if selected_is_blocked == '1' or selected_is_blocked == 'true' %}selected{% endif %}>{% translate "Oui (Bloquées)" %}</option>
<option value="0" {% if selected_is_blocked == '0' or selected_is_blocked == 'false' %}selected{% endif %}>{% translate "Non (Actives)" %}</option>
</select>
</div>
</div>
</div> </div>
{% if to_be_planned_soon %} {% if to_be_planned_soon %}

View file

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

View file

@ -432,6 +432,7 @@ def index(request):
'saved_sort': json.dumps(saved_sort, ensure_ascii=False), 'saved_sort': json.dumps(saved_sort, ensure_ascii=False),
'saved_columns': json.dumps(user_config.intervention_list_columns, ensure_ascii=False), 'saved_columns': json.dumps(user_config.intervention_list_columns, ensure_ascii=False),
'is_admin': user_config.roles.filter(name='admin').exists(), '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) return render(request, "interventions/interventions_index.html", context)
@ -679,6 +680,13 @@ def interventions_geojson(request):
elif is_checked_filter == 'false': elif is_checked_filter == 'false':
qs = qs.filter(is_checked=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 # Quand is_checked est présent, restreindre aux contrats sur lesquels
# l'utilisateur a explicitement le droit de vérifier les interventions. # l'utilisateur a explicitement le droit de vérifier les interventions.
# Cela exclut les interventions sans contrat et celles des contrats non autorisés. # Cela exclut les interventions sans contrat et celles des contrats non autorisés.
@ -735,6 +743,8 @@ def interventions_geojson(request):
'assigned_provider__name', 'assigned_provider__name',
'contract__contract_number', 'contract__contract_number',
'contract__company__name', 'contract__company__name',
'contract__is_blocked',
'is_blocked_due_to_contract',
'assigned_team__name', 'assigned_team__name',
source_category_field, source_category_field,
)[:MAX_FEATURES] )[:MAX_FEATURES]
@ -761,6 +771,7 @@ def interventions_geojson(request):
"assigned_provider": r['assigned_provider__name'], "assigned_provider": r['assigned_provider__name'],
"contract_number": r['contract__contract_number'], "contract_number": r['contract__contract_number'],
"contract_provider": r['contract__company__name'], "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'], "origin_type": r['origin_type_label'],
"source_category": r[source_category_field] if r[source_category_field] else '', "source_category": r[source_category_field] if r[source_category_field] else '',
"team": r['assigned_team__name'] or '', "team": r['assigned_team__name'] or '',

View file

@ -1850,6 +1850,13 @@ def apply_intervention_filters_from_querydict(qs, params, user=None):
elif is_checked_filter == 'false': elif is_checked_filter == 'false':
qs = qs.filter(is_checked=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: if preparation_states:
qs = qs.filter(prep_state__in=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: if not contract:
return [] return []
if getattr(contract, 'is_blocked', False):
return []
if hasattr(contract, 'send_email_notifications') and not contract.send_email_notifications: if hasattr(contract, 'send_email_notifications') and not contract.send_email_notifications:
return [] return []
@ -3723,6 +3733,9 @@ def get_contract_cc_recipients(contract, intervention=None) -> List[str]:
if not contract: if not contract:
return [] return []
if getattr(contract, 'is_blocked', False):
return []
if hasattr(contract, 'send_email_notifications') and not contract.send_email_notifications: if hasattr(contract, 'send_email_notifications') and not contract.send_email_notifications:
return [] return []

View file

@ -553,6 +553,11 @@ def attempt_status_update(request, intervention, user, new_status, pause_reason=
if not intervention.contract: if not intervention.contract:
return False, _("Un contrat doit être indiqué avant de passer à ce statut."), False 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': if new_status == 'to_be_planned':
# Réinitialise l'équipe/membre assigné # Réinitialise l'équipe/membre assigné
intervention.assigned_team = None intervention.assigned_team = None
@ -1164,6 +1169,12 @@ def confirm_call(request, intervention_id):
except Intervention.DoesNotExist: except Intervention.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Intervention not found'}, status=404) 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: try:
payload = json.loads(request.body.decode() or "{}") payload = json.loads(request.body.decode() or "{}")
except json.JSONDecodeError: except json.JSONDecodeError:
@ -1219,6 +1230,12 @@ def send_intervention_reminder(request, intervention_id):
'error': _("Aucun contrat n'est associé à cette intervention."), 'error': _("Aucun contrat n'est associé à cette intervention."),
}, status=400) }, 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) recipients = get_contract_recipients(contract, intervention)
cc_recipients = get_contract_cc_recipients(contract, intervention) cc_recipients = get_contract_cc_recipients(contract, intervention)
if not recipients: if not recipients:
@ -1353,6 +1370,12 @@ def send_intervention_modification(request, intervention_id):
'error': _("Aucun contrat n'est associé à cette intervention."), 'error': _("Aucun contrat n'est associé à cette intervention."),
}, status=400) }, 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) recipients = get_contract_recipients(contract, intervention)
cc_recipients = get_contract_cc_recipients(contract, intervention) cc_recipients = get_contract_cc_recipients(contract, intervention)
if not recipients: if not recipients:

View file

@ -533,7 +533,21 @@ class Observation(models.Model):
from os.path import basename from os.path import basename
source_cat = SourceCategory.objects.filter(name_fr__iexact='OBSERVATION').first() 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 # Geometry buffer
geom = None geom = None
@ -571,21 +585,11 @@ class Observation(models.Model):
created_by=self.created_by, created_by=self.created_by,
thematic=self.thematic, thematic=self.thematic,
geom=geom, geom=geom,
contract=self.symptom.contract, contract=target_contract,
assigned_provider=self.symptom.provider, 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' # Link to observation and set observation status to 'in_progress'
self.intervention = intervention self.intervention = intervention
self.status = 'in_progress' self.status = 'in_progress'
@ -629,10 +633,16 @@ class Observation(models.Model):
# Add comment on observation # Add comment on observation
from observations.models import ObservationComment 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( ObservationComment.objects.create(
observation=self, observation=self,
author=None, author=None,
comment=f"Observation mise en cours suite à la création automatique de l'intervention {intervention.code}" comment=comment_text
) )
def __str__(self): def __str__(self):