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)
|
@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': (
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
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']
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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>`;
|
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;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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 }})"
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
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_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 '',
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue