feat: introduce contract blocking mechanism to suspend interventions and notifications for external users
This commit is contained in:
parent
da038b7c4f
commit
4824415290
17 changed files with 493 additions and 36 deletions
|
|
@ -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': (
|
||||
|
|
|
|||
|
|
@ -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é'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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é)'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -496,6 +496,11 @@ function statusFormatter(value, row) {
|
|||
html += ` <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 += ` <span class="badge text-bg-danger" title="${gettext('Marché temporairement bloqué')}"><i class="bi bi-slash-circle"></i></span>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@
|
|||
<a href="{% url 'mobile:intervention_detail_mobile' intervention.id %}" class="me-2">{{ intervention.code }}</a> {{ intervention.title }}
|
||||
</h6>
|
||||
<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 == "processed" %}
|
||||
<span class="badge bg-success" title="{% translate 'Stock prêt' %}">
|
||||
|
|
@ -59,7 +64,7 @@
|
|||
</span>
|
||||
{% elif intervention.prep_state == "not_processed" %}
|
||||
<span class="badge bg-danger" title="{% translate 'Stock en attente' %}">
|
||||
<i class="bi bi-box"></i>
|
||||
<i class="bi bi-box"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@
|
|||
{% endif %}
|
||||
{% endif %}
|
||||
<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 %}
|
||||
<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" %}
|
||||
|
|
@ -79,6 +84,19 @@
|
|||
{% endif %}
|
||||
</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>
|
||||
</div>
|
||||
<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">
|
||||
{% for recommendation in intervention.symptom.recommendations.all %}
|
||||
<li class="list-group-item list-group-item-info d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi {{recommendation.icon}} me-2"></i>
|
||||
{% render_recommendation recommendation intervention.contract as rendered_text %}
|
||||
{{ rendered_text|safe }}
|
||||
{% if intervention.is_contract_blocked and recommendation.confirm_call %}
|
||||
<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>
|
||||
{% if recommendation.confirm_call %}
|
||||
{% if recommendation.confirm_call and not intervention.is_contract_blocked %}
|
||||
<button class="btn btn-outline-success btn-sm confirm-call"
|
||||
data-call-id="{{recommendation.order_number}}"
|
||||
data-confirmed-at="{{ intervention.init_call_confirmed_time|date:'c' }}"
|
||||
|
|
@ -953,8 +988,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
|
|
|
|||
|
|
@ -472,9 +472,27 @@
|
|||
</div>
|
||||
{% 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="code d-flex align-items-center gap-2">
|
||||
<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"
|
||||
class="btn btn-sm {% if is_subscribed %}btn-secondary{% else %}btn-outline-secondary{% endif %} py-0 px-2"
|
||||
onclick="toggleSubscription({{ intervention.id }})"
|
||||
|
|
|
|||
|
|
@ -1017,9 +1017,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options supplémentaires (En retard / Suivies) -->
|
||||
<div class="row mt-3 pt-3 border-top">
|
||||
<div class="col-md-6">
|
||||
<!-- Options supplémentaires (En retard / Suivies / Marché bloqué) -->
|
||||
<div class="row mt-3 pt-3 border-top g-3 align-items-center">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
|
|
@ -1030,11 +1030,11 @@
|
|||
{% if overdue == '1' %}checked{% endif %}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
|
|
@ -1045,10 +1045,22 @@
|
|||
{% if followed_only == '1' %}checked{% endif %}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{% if to_be_planned_soon %}
|
||||
|
|
|
|||
220
loko/interventions/tests/test_blocked_contracts.py
Normal file
220
loko/interventions/tests/test_blocked_contracts.py
Normal 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)
|
||||
|
|
@ -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 '',
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue