feat: implement discussion flagging system with status filtering and UI updates

This commit is contained in:
kdeterme 2026-09-09 17:09:49 +02:00
parent 79697f392c
commit 9657111a1c
20 changed files with 1080 additions and 63 deletions

View file

@ -485,6 +485,21 @@
border: 2px solid #fff; border: 2px solid #fff;
line-height: 1; line-height: 1;
} }
#discussion-fab .fab-flag-badge {
position: absolute;
top: -4px;
left: -4px;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: .75rem;
border: 2px solid #fff;
color: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,.2);
}
#discussion-panel { #discussion-panel {
position: fixed; position: fixed;
bottom: 92px; bottom: 92px;
@ -541,6 +556,11 @@
{% if discussion_messages %} {% if discussion_messages %}
<span class="fab-badge" id="discussion-fab-badge">{{ discussion_messages|length }}</span> <span class="fab-badge" id="discussion-fab-badge">{{ discussion_messages|length }}</span>
{% endif %} {% endif %}
<span class="fab-flag-badge {% if not discussion or not discussion.flag %}d-none{% endif %}" id="discussion-fab-flag-badge"
style="background-color: {{ discussion.flag_info.color|default:'#dc3545' }};"
title="{{ discussion.flag_info.label }}">
<i class="bi {{ discussion.flag_info.icon|default:'bi-flag-fill' }}"></i>
</span>
</button> </button>
<div id="discussion-panel" role="dialog" aria-label="{% translate 'Discussion' %}"> <div id="discussion-panel" role="dialog" aria-label="{% translate 'Discussion' %}">
@ -548,15 +568,45 @@
<!-- En-tête --> <!-- En-tête -->
<div id="discussion-panel-header"> <div id="discussion-panel-header">
<span class="fw-semibold d-flex align-items-center gap-2 small"> <span class="fw-semibold d-flex align-items-center gap-2 small">
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion liée à cette intervention" %} <i class="bi bi-chat-dots-fill"></i> {% translate "Discussion" %}
</span> </span>
<div class="d-flex align-items-center gap-2">
{% if discussion %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-light py-0 px-2 rounded-pill d-flex align-items-center gap-1 border-0 bg-white bg-opacity-20 dropdown-toggle"
type="button" id="interventionDiscussionFlagBtn" data-bs-toggle="dropdown" aria-expanded="false" style="font-size: 0.75rem;">
<span id="intervention-flag-icon-display">
{% if discussion.flag == 'red' %}🚩{% elif discussion.flag == 'orange' %}🟧{% elif discussion.flag == 'green' %}🟩{% else %}🏳️{% endif %}
</span>
<span id="intervention-flag-label-display">
{{ discussion.flag_info.label|default:_("Statut") }}
</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="interventionDiscussionFlagBtn" style="font-size: 0.8rem; z-index: 1070;">
<li><h6 class="dropdown-header small text-muted py-1">{% translate "Statut de suivi" %}</h6></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 intervention-flag-opt" data-flag="red"><i class="bi bi-flag-fill text-danger"></i> {% translate "Important / À traiter" %}</button></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 intervention-flag-opt" data-flag="orange"><i class="bi bi-flag-fill text-warning"></i> {% translate "En cours" %}</button></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 intervention-flag-opt" data-flag="green"><i class="bi bi-flag-fill text-success"></i> {% translate "Traité / Fait" %}</button></li>
<li><hr class="dropdown-divider my-1"></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 intervention-flag-opt text-muted" data-flag=""><i class="bi bi-flag"></i> {% translate "Aucun drapeau" %}</button></li>
</ul>
</div>
{% endif %}
<button type="button" class="btn-close btn-close-white btn-sm" onclick="toggleDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button> <button type="button" class="btn-close btn-close-white btn-sm" onclick="toggleDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button>
</div> </div>
</div>
<!-- Fil de messages --> <!-- Fil de messages -->
<div id="discussion-thread"> <div id="discussion-thread">
{% if discussion_messages %} {% if discussion_messages %}
{% for msg in discussion_messages %} {% for msg in discussion_messages %}
{% if msg.is_system %}
<div class="d-flex justify-content-center my-1">
<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
{{ msg.content }}
</span>
</div>
{% else %}
<div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}"> <div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}">
{% if msg.author != request.user %} {% if msg.author != request.user %}
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0" <div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0"
@ -594,6 +644,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
{% else %} {% else %}
<p class="text-muted small text-center py-3 mb-0"> <p class="text-muted small text-center py-3 mb-0">

View file

@ -1020,6 +1020,74 @@ document.addEventListener('DOMContentLoaded', function () {
})(); })();
// ---- fin @mention autocomplete ---- // ---- fin @mention autocomplete ----
// ---- Changement de drapeau de la discussion ----
{% if discussion %}
(function() {
document.querySelectorAll('.intervention-flag-opt').forEach(btn => {
btn.addEventListener('click', function() {
const flag = this.getAttribute('data-flag');
const url = "{% url 'notifications:discussion_set_flag' discussion.pk %}";
const formData = new FormData();
formData.append('flag', flag);
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
fetch(url, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
})
.then(res => res.json())
.then(data => {
if (data.ok) {
const fabFlagBadge = document.getElementById('discussion-fab-flag-badge');
const headerIcon = document.getElementById('intervention-flag-icon-display');
const headerLabel = document.getElementById('intervention-flag-label-display');
if (data.flag) {
if (fabFlagBadge) {
fabFlagBadge.classList.remove('d-none');
fabFlagBadge.style.backgroundColor = data.flag_info.color;
fabFlagBadge.title = data.flag_info.label;
fabFlagBadge.innerHTML = `<i class="bi ${data.flag_info.icon}"></i>`;
}
} else {
if (fabFlagBadge) {
fabFlagBadge.classList.add('d-none');
}
}
if (headerIcon) {
if (data.flag === 'red') headerIcon.textContent = '🚩';
else if (data.flag === 'orange') headerIcon.textContent = '🟧';
else if (data.flag === 'green') headerIcon.textContent = '🟩';
else headerIcon.textContent = '🏳️';
}
if (headerLabel) {
headerLabel.textContent = data.flag_info.label || '{% translate "Statut" %}';
}
if (data.system_message) {
const thread = document.getElementById('discussion-thread');
if (thread) {
const sysDiv = document.createElement('div');
sysDiv.className = 'd-flex justify-content-center my-1';
sysDiv.innerHTML = `<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
${data.system_message.content}
</span>`;
thread.appendChild(sysDiv);
thread.scrollTop = thread.scrollHeight;
}
}
}
})
.catch(err => console.error('Error changing discussion flag:', err));
});
});
})();
{% endif %}
function toggleSubscription(interventionId) { function toggleSubscription(interventionId) {
const btn = document.getElementById('subscription-btn'); const btn = document.getElementById('subscription-btn');
const icon = document.getElementById('subscription-icon'); const icon = document.getElementById('subscription-icon');

View file

@ -342,6 +342,21 @@
border: 2px solid #fff; border: 2px solid #fff;
line-height: 1; line-height: 1;
} }
#discussion-mob-fab .mob-fab-flag-badge {
position: absolute;
top: -3px;
left: -3px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: .7rem;
border: 2px solid #fff;
color: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,.2);
}
#discussion-mob-panel { #discussion-mob-panel {
position: fixed; position: fixed;
top: 10.5rem; top: 10.5rem;
@ -2542,6 +2557,11 @@
{% if discussion_messages %} {% if discussion_messages %}
<span class="mob-fab-badge" id="discussion-mob-badge">{{ discussion_messages|length }}</span> <span class="mob-fab-badge" id="discussion-mob-badge">{{ discussion_messages|length }}</span>
{% endif %} {% endif %}
<span class="mob-fab-flag-badge {% if not discussion or not discussion.flag %}d-none{% endif %}" id="discussion-mob-flag-badge"
style="background-color: {{ discussion.flag_info.color|default:'#dc3545' }};"
title="{{ discussion.flag_info.label }}">
<i class="bi {{ discussion.flag_info.icon|default:'bi-flag-fill' }}"></i>
</span>
</button> </button>
<div id="discussion-mob-panel" role="dialog" aria-label="{% translate 'Discussion' %}"> <div id="discussion-mob-panel" role="dialog" aria-label="{% translate 'Discussion' %}">
@ -2550,13 +2570,43 @@
<span class="fw-semibold d-flex align-items-center gap-2 small"> <span class="fw-semibold d-flex align-items-center gap-2 small">
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion" %} <i class="bi bi-chat-dots-fill"></i> {% translate "Discussion" %}
</span> </span>
<div class="d-flex align-items-center gap-2">
{% if discussion %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-light py-0 px-2 rounded-pill d-flex align-items-center gap-1 border-0 bg-white bg-opacity-20 dropdown-toggle"
type="button" id="mobDiscussionFlagBtn" data-bs-toggle="dropdown" aria-expanded="false" style="font-size: 0.75rem;">
<span id="mob-flag-icon-display">
{% if discussion.flag == 'red' %}🚩{% elif discussion.flag == 'orange' %}🟧{% elif discussion.flag == 'green' %}🟩{% else %}🏳️{% endif %}
</span>
<span id="mob-flag-label-display">
{{ discussion.flag_info.label|default:_("Statut") }}
</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="mobDiscussionFlagBtn" style="font-size: 0.8rem; z-index: 1070;">
<li><h6 class="dropdown-header small text-muted py-1">{% translate "Statut de suivi" %}</h6></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 mob-flag-opt" data-flag="red"><i class="bi bi-flag-fill text-danger"></i> {% translate "Important / À traiter" %}</button></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 mob-flag-opt" data-flag="orange"><i class="bi bi-flag-fill text-warning"></i> {% translate "En cours" %}</button></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 mob-flag-opt" data-flag="green"><i class="bi bi-flag-fill text-success"></i> {% translate "Traité / Fait" %}</button></li>
<li><hr class="dropdown-divider my-1"></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 mob-flag-opt text-muted" data-flag=""><i class="bi bi-flag"></i> {% translate "Aucun drapeau" %}</button></li>
</ul>
</div>
{% endif %}
<button type="button" class="btn-close btn-close-white btn-sm" <button type="button" class="btn-close btn-close-white btn-sm"
onclick="toggleMobDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button> onclick="toggleMobDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button>
</div> </div>
</div>
<div id="discussion-mob-thread"> <div id="discussion-mob-thread">
{% if discussion_messages %} {% if discussion_messages %}
{% for msg in discussion_messages %} {% for msg in discussion_messages %}
{% if msg.is_system %}
<div class="d-flex justify-content-center my-1">
<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
{{ msg.content }}
</span>
</div>
{% else %}
<div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}"> <div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}">
{% if msg.author != request.user %} {% if msg.author != request.user %}
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0" <div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0"
@ -2594,6 +2644,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
{% else %} {% else %}
<p class="text-muted small text-center py-3 mb-0"> <p class="text-muted small text-center py-3 mb-0">
@ -6640,6 +6691,74 @@ document.addEventListener('DOMContentLoaded', function () {
setTimeout(() => { textarea.value = ''; realInput.value = ''; mentions = []; }, 0); setTimeout(() => { textarea.value = ''; realInput.value = ''; mentions = []; }, 0);
}); });
})(); })();
// ---- Changement de drapeau de la discussion (mobile) ----
{% if discussion %}
(function() {
document.querySelectorAll('.mob-flag-opt').forEach(btn => {
btn.addEventListener('click', function() {
const flag = this.getAttribute('data-flag');
const url = "{% url 'notifications:discussion_set_flag' discussion.pk %}";
const formData = new FormData();
formData.append('flag', flag);
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
fetch(url, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
})
.then(res => res.json())
.then(data => {
if (data.ok) {
const fabFlagBadge = document.getElementById('discussion-mob-flag-badge');
const headerIcon = document.getElementById('mob-flag-icon-display');
const headerLabel = document.getElementById('mob-flag-label-display');
if (data.flag) {
if (fabFlagBadge) {
fabFlagBadge.classList.remove('d-none');
fabFlagBadge.style.backgroundColor = data.flag_info.color;
fabFlagBadge.title = data.flag_info.label;
fabFlagBadge.innerHTML = `<i class="bi ${data.flag_info.icon}"></i>`;
}
} else {
if (fabFlagBadge) {
fabFlagBadge.classList.add('d-none');
}
}
if (headerIcon) {
if (data.flag === 'red') headerIcon.textContent = '🚩';
else if (data.flag === 'orange') headerIcon.textContent = '🟧';
else if (data.flag === 'green') headerIcon.textContent = '🟩';
else headerIcon.textContent = '🏳️';
}
if (headerLabel) {
headerLabel.textContent = data.flag_info.label || '{% translate "Statut" %}';
}
if (data.system_message) {
const thread = document.getElementById('discussion-mob-thread');
if (thread) {
const sysDiv = document.createElement('div');
sysDiv.className = 'd-flex justify-content-center my-1';
sysDiv.innerHTML = `<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
${data.system_message.content}
</span>`;
thread.appendChild(sysDiv);
thread.scrollTop = thread.scrollHeight;
}
}
}
})
.catch(err => console.error('Error changing mobile discussion flag:', err));
});
});
})();
{% endif %}
</script> </script>
<script> <script>

View file

@ -742,6 +742,7 @@ def intervention_detail(request, intervention_id):
# Observation source # Observation source
'source_observation': source_observation, 'source_observation': source_observation,
# Discussion # Discussion
'discussion': discussion,
'discussion_messages': discussion_messages, 'discussion_messages': discussion_messages,
'can_post_discussion_message': can_post_discussion_message, 'can_post_discussion_message': can_post_discussion_message,
# Sequenced notes & documents # Sequenced notes & documents

View file

@ -1059,6 +1059,7 @@ def intervention_detail_mobile(request, intervention_id):
linked_observations = [obs for obs in all_observations if obs_access_context.allows(obs)] linked_observations = [obs for obs in all_observations if obs_access_context.allows(obs)]
# Discussion liée à cette intervention (mobile) # Discussion liée à cette intervention (mobile)
discussion = None
discussion_messages_mobile = [] discussion_messages_mobile = []
can_post_discussion_message_mobile = False can_post_discussion_message_mobile = False
try: try:
@ -1066,15 +1067,15 @@ def intervention_detail_mobile(request, intervention_id):
from notifications.models import Discussion as _Discussion from notifications.models import Discussion as _Discussion
from notifications.services import mark_discussion_seen as _mark_seen, can_post_intervention_message as _can_post from notifications.services import mark_discussion_seen as _mark_seen, can_post_intervention_message as _can_post
_ct = _CT.objects.get_for_model(intervention) _ct = _CT.objects.get_for_model(intervention)
_discussion = _Discussion.objects.filter(content_type=_ct, object_id=intervention.pk).first() discussion = _Discussion.objects.filter(content_type=_ct, object_id=intervention.pk).first()
if _discussion: if discussion:
discussion_messages_mobile = list( discussion_messages_mobile = list(
_discussion.messages.filter(is_deleted=False) discussion.messages.filter(is_deleted=False)
.select_related('author') .select_related('author')
.prefetch_related('attachments') .prefetch_related('attachments')
.order_by('created_at') .order_by('created_at')
) )
_mark_seen(_discussion, user=request.user) _mark_seen(discussion, user=request.user)
can_post_discussion_message_mobile = _can_post(request.user, intervention) can_post_discussion_message_mobile = _can_post(request.user, intervention)
except Exception: except Exception:
pass pass
@ -1170,6 +1171,7 @@ def intervention_detail_mobile(request, intervention_id):
# Observations liées # Observations liées
'linked_observations': linked_observations, 'linked_observations': linked_observations,
# Discussion # Discussion
'discussion': discussion,
'discussion_messages': discussion_messages_mobile, 'discussion_messages': discussion_messages_mobile,
'can_post_discussion_message': can_post_discussion_message_mobile, 'can_post_discussion_message': can_post_discussion_message_mobile,
} }

View file

@ -1099,6 +1099,7 @@ def stock_preparation_order_detail_mobile(request, pk):
'has_executed_outputs': has_executed_outputs, 'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened, 'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return, 'deleted_items_to_return': deleted_items_to_return,
'discussion': _discussion,
'discussion_messages': discussion_messages_mobile, 'discussion_messages': discussion_messages_mobile,
'can_post_discussion_message': can_post_discussion_message_mobile, 'can_post_discussion_message': can_post_discussion_message_mobile,
}) })

View file

@ -0,0 +1,36 @@
# Generated by Django 6.0.7 on 2026-09-09 14:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0003_webpushsubscription'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='discussion',
name='flag',
field=models.CharField(blank=True, choices=[('', 'Aucun'), ('red', 'Important / À traiter'), ('orange', 'En cours'), ('green', 'Traité / Fait')], default='', max_length=20, verbose_name='Flag'),
),
migrations.AddField(
model_name='discussion',
name='flag_set_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Flag défini le'),
),
migrations.AddField(
model_name='discussion',
name='flag_set_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='flagged_discussions', to=settings.AUTH_USER_MODEL, verbose_name='Flag défini par'),
),
migrations.AddField(
model_name='message',
name='is_system',
field=models.BooleanField(default=False, help_text='Indique si le message a été généré automatiquement par le système.', verbose_name='Message système'),
),
]

View file

@ -350,6 +350,46 @@ class Discussion(models.Model):
) )
content_object = GenericForeignKey('content_type', 'object_id') content_object = GenericForeignKey('content_type', 'object_id')
# Statuts de suivi (Flags)
FLAG_NONE = ''
FLAG_RED = 'red'
FLAG_ORANGE = 'orange'
FLAG_GREEN = 'green'
FLAG_CHOICES = [
(FLAG_NONE, _('Aucun')),
(FLAG_RED, _('Important / À traiter')),
(FLAG_ORANGE, _('En cours')),
(FLAG_GREEN, _('Traité / Fait')),
]
FLAG_META = {
FLAG_RED: {'label': _('Important / À traiter'), 'short_label': _('Important'), 'color': 'danger', 'icon': 'bi-flag-fill'},
FLAG_ORANGE: {'label': _('En cours'), 'short_label': _('En cours'), 'color': 'warning', 'icon': 'bi-flag-fill'},
FLAG_GREEN: {'label': _('Traité / Fait'), 'short_label': _('Traité'), 'color': 'success', 'icon': 'bi-flag-fill'},
}
flag = models.CharField(
max_length=20,
choices=FLAG_CHOICES,
default=FLAG_NONE,
blank=True,
verbose_name=_("Flag"),
)
flag_set_by = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='flagged_discussions',
verbose_name=_("Flag défini par"),
)
flag_set_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Flag défini le"),
)
# URL et libellé pré-calculés pour éviter de résoudre le FK générique en template # URL et libellé pré-calculés pour éviter de résoudre le FK générique en template
object_url = models.CharField( object_url = models.CharField(
max_length=500, blank=True, verbose_name=_("URL objet lié") max_length=500, blank=True, verbose_name=_("URL objet lié")
@ -361,6 +401,16 @@ class Discussion(models.Model):
def __str__(self): def __str__(self):
return self.title or f"Discussion #{self.pk}" return self.title or f"Discussion #{self.pk}"
@property
def flag_info(self):
if not self.flag or self.flag not in self.FLAG_META:
return None
info = dict(self.FLAG_META[self.flag])
info['flag'] = self.flag
info['set_by'] = self.flag_set_by
info['set_at'] = self.flag_set_at
return info
def get_absolute_url(self): def get_absolute_url(self):
from django.urls import reverse from django.urls import reverse
return reverse('notifications:discussion_detail', args=[self.pk]) return reverse('notifications:discussion_detail', args=[self.pk])
@ -455,6 +505,11 @@ class Message(models.Model):
content = models.TextField(verbose_name=_("Contenu")) content = models.TextField(verbose_name=_("Contenu"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Envoyé le")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Envoyé le"))
is_deleted = models.BooleanField(default=False, verbose_name=_("Supprimé")) is_deleted = models.BooleanField(default=False, verbose_name=_("Supprimé"))
is_system = models.BooleanField(
default=False,
verbose_name=_("Message système"),
help_text=_("Indique si le message a été généré automatiquement par le système."),
)
def __str__(self): def __str__(self):
author_name = self.author.username if self.author else 'système' author_name = self.author.username if self.author else 'système'

View file

@ -280,7 +280,7 @@ def get_unread_count(user):
# Discussion / Messaging services # Discussion / Messaging services
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
def post_message(discussion, author, content, files=None): def post_message(discussion, author, content, files=None, flag=None):
""" """
Poste un message dans une discussion. Poste un message dans une discussion.
@ -289,6 +289,7 @@ def post_message(discussion, author, content, files=None):
participants (la réponse annule les alertes précédentes). participants (la réponse annule les alertes précédentes).
- Crée une nouvelle notification pour tous les participants sauf l'auteur. - Crée une nouvelle notification pour tous les participants sauf l'auteur.
- Met à jour discussion.updated_at pour la faire remonter dans les listes. - Met à jour discussion.updated_at pour la faire remonter dans les listes.
- Si `flag` est renseigné et différent du flag actuel, met à jour le flag de la discussion.
Retourne le Message créé. Retourne le Message créé.
""" """
@ -300,6 +301,9 @@ def post_message(discussion, author, content, files=None):
for f in files: for f in files:
MessageAttachment.objects.create(message=message, file=f, name=f.name) MessageAttachment.objects.create(message=message, file=f, name=f.name)
if flag is not None and flag != discussion.flag:
set_discussion_flag(discussion, flag, user=author)
# Effacer les anciennes notifications non lues de cette discussion # Effacer les anciennes notifications non lues de cette discussion
NotificationRecipient.objects.filter( NotificationRecipient.objects.filter(
notification__source_discussion=discussion, notification__source_discussion=discussion,
@ -456,6 +460,58 @@ def mark_discussion_seen(discussion, user=None):
qs.update(read_at=timezone.now()) qs.update(read_at=timezone.now())
def set_discussion_flag(discussion, flag, user=None):
"""
Définit ou retire le flag de suivi sur une discussion.
Génère un message système dans le fil pour tracer la modification.
Met à jour updated_at sur la discussion.
Retourne le message système créé (ou None si inchangé).
"""
from django.utils.translation import gettext as _
from .models import Message
old_flag = discussion.flag or ''
new_flag = flag if flag in dict(discussion.FLAG_CHOICES) else ''
if old_flag == new_flag:
return None
user_name = user.get_full_name().strip() or user.username if user else _("Système")
now = timezone.now()
discussion.flag = new_flag
discussion.flag_set_by = user
discussion.flag_set_at = now if new_flag else None
discussion.save(update_fields=['flag', 'flag_set_by', 'flag_set_at', 'updated_at'])
# Créer un message système dans le fil
if new_flag:
flag_label = dict(discussion.FLAG_CHOICES).get(new_flag, new_flag)
if new_flag == 'red':
flag_icon = '🚩'
elif new_flag == 'orange':
flag_icon = '🟧'
elif new_flag == 'green':
flag_icon = '🟩'
else:
flag_icon = '🏷️'
content = _("{icon} {user} a défini le statut : {label}").format(
icon=flag_icon,
user=user_name,
label=flag_label,
)
else:
content = _("🏳️ {user} a retiré le drapeau de suivi.").format(user=user_name)
sys_message = Message.objects.create(
discussion=discussion,
author=user,
content=content,
is_system=True,
)
return sys_message
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# Règles de notification automatiques # Règles de notification automatiques
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────

View file

@ -12,13 +12,26 @@
<form method="post" action="{% url 'notifications:discussion_create' %}" enctype="multipart/form-data" id="new-discussion-form"> <form method="post" action="{% url 'notifications:discussion_create' %}" enctype="multipart/form-data" id="new-discussion-form">
{% csrf_token %} {% csrf_token %}
<div class="modal-body pt-3"> <div class="modal-body pt-3">
<div class="mb-3"> <div class="row g-2 mb-3">
<div class="col-sm-8">
<label for="new-discussion-title" class="form-label small fw-semibold text-secondary"> <label for="new-discussion-title" class="form-label small fw-semibold text-secondary">
{% translate "Sujet / Titre (optionnel)" %} {% translate "Sujet / Titre (optionnel)" %}
</label> </label>
<input type="text" class="form-control rounded-3" id="new-discussion-title" name="title" <input type="text" class="form-control rounded-3" id="new-discussion-title" name="title"
placeholder="{% translate 'Ex: Question sur le dossier...' %}"> placeholder="{% translate 'Ex: Question sur le dossier...' %}">
</div> </div>
<div class="col-sm-4">
<label for="new-discussion-flag" class="form-label small fw-semibold text-secondary">
{% translate "Statut initial" %}
</label>
<select class="form-select rounded-3" id="new-discussion-flag" name="flag">
<option value="">{% translate "Aucun" %}</option>
<option value="red">🚩 {% translate "Important" %}</option>
<option value="orange">🟧 {% translate "En cours" %}</option>
<option value="green">🟩 {% translate "Traité" %}</option>
</select>
</div>
</div>
<div class="mb-3 position-relative"> <div class="mb-3 position-relative">
<label for="new-discussion-content-display" class="form-label small fw-semibold text-secondary"> <label for="new-discussion-content-display" class="form-label small fw-semibold text-secondary">

View file

@ -81,11 +81,12 @@
<div class="container py-4" style="max-width:880px;"> <div class="container py-4" style="max-width:880px;">
<!-- Breadcrumb / Header --> <!-- Breadcrumb / Header -->
<div class="d-flex align-items-center gap-3 mb-3"> <div class="d-flex align-items-center justify-content-between gap-3 mb-3 flex-wrap">
<div class="d-flex align-items-center gap-3">
<a href="{% url 'notifications:inbox' %}?tab=discussions" class="btn btn-sm btn-outline-secondary rounded-circle p-2 d-flex align-items-center justify-content-center" style="width:36px; height:36px;" title="{% translate 'Retour aux discussions' %}"> <a href="{% url 'notifications:inbox' %}?tab=discussions" class="btn btn-sm btn-outline-secondary rounded-circle p-2 d-flex align-items-center justify-content-center" style="width:36px; height:36px;" title="{% translate 'Retour aux discussions' %}">
<i class="bi bi-arrow-left fs-6"></i> <i class="bi bi-arrow-left fs-6"></i>
</a> </a>
<div class="flex-grow-1 min-w-0"> <div class="min-w-0">
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
<h1 class="h4 mb-0 fw-bold text-dark">{{ discussion.title|default:discussion }}</h1> <h1 class="h4 mb-0 fw-bold text-dark">{{ discussion.title|default:discussion }}</h1>
{% if discussion.object_label %} {% if discussion.object_label %}
@ -100,6 +101,64 @@
</div> </div>
</div> </div>
<!-- Flag Dropdown Selector -->
<div class="dropdown" id="discussionFlagContainer">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle d-flex align-items-center gap-1.5 px-2.5 py-1.5 rounded-pill shadow-xs"
type="button" id="discussionFlagBtn" data-bs-toggle="dropdown" aria-expanded="false">
<span id="flagIconDisplay">
{% if discussion.flag == 'red' %}
<i class="bi bi-flag-fill text-danger"></i>
{% elif discussion.flag == 'orange' %}
<i class="bi bi-flag-fill text-warning"></i>
{% elif discussion.flag == 'green' %}
<i class="bi bi-flag-fill text-success"></i>
{% else %}
<i class="bi bi-flag text-secondary"></i>
{% endif %}
</span>
<span id="flagLabelDisplay" class="fw-medium small">
{% if discussion.flag %}
{{ discussion.flag_info.label }}
{% else %}
{% translate "Statut" %}
{% endif %}
</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="discussionFlagBtn">
<li><h6 class="dropdown-header small text-muted">{% translate "Statut de suivi" %}</h6></li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center gap-2 small flag-opt-btn" data-flag="red">
<i class="bi bi-flag-fill text-danger"></i>
<span>{% translate "Important / À traiter" %}</span>
{% if discussion.flag == 'red' %}<i class="bi bi-check ms-auto text-primary"></i>{% endif %}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center gap-2 small flag-opt-btn" data-flag="orange">
<i class="bi bi-flag-fill text-warning"></i>
<span>{% translate "En cours" %}</span>
{% if discussion.flag == 'orange' %}<i class="bi bi-check ms-auto text-primary"></i>{% endif %}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center gap-2 small flag-opt-btn" data-flag="green">
<i class="bi bi-flag-fill text-success"></i>
<span>{% translate "Traité / Fait" %}</span>
{% if discussion.flag == 'green' %}<i class="bi bi-check ms-auto text-primary"></i>{% endif %}
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center gap-2 small text-muted flag-opt-btn" data-flag="">
<i class="bi bi-flag"></i>
<span>{% translate "Aucun drapeau" %}</span>
{% if not discussion.flag %}<i class="bi bi-check ms-auto text-primary"></i>{% endif %}
</button>
</li>
</ul>
</div>
</div>
<!-- Participants --> <!-- Participants -->
<div class="mb-3 d-flex flex-wrap gap-1.5 align-items-center"> <div class="mb-3 d-flex flex-wrap gap-1.5 align-items-center">
<span class="small text-muted me-1"><i class="bi bi-people me-1"></i>{% translate "Participants" %} :</span> <span class="small text-muted me-1"><i class="bi bi-people me-1"></i>{% translate "Participants" %} :</span>
@ -120,8 +179,16 @@
<p class="mb-0">{% translate "Aucun message pour l'instant. Soyez le premier à écrire." %}</p> <p class="mb-0">{% translate "Aucun message pour l'instant. Soyez le premier à écrire." %}</p>
</div> </div>
{% else %} {% else %}
<div class="thread-container"> <div class="thread-container" id="thread-container">
{% for msg in thread_messages %} {% for msg in thread_messages %}
{% if msg.is_system %}
<div class="d-flex justify-content-center my-2">
<span class="badge bg-light text-secondary border px-3 py-1.5 rounded-pill shadow-xs" style="font-size: 0.8rem; font-weight: 500;">
{{ msg.content }}
<span class="ms-1 opacity-75 small">({{ msg.created_at|date:"d/m H:i" }})</span>
</span>
</div>
{% else %}
<div class="d-flex flex-column {% if msg.author == request.user %}msg-mine{% else %}msg-other{% endif %}"> <div class="d-flex flex-column {% if msg.author == request.user %}msg-mine{% else %}msg-other{% endif %}">
{% if msg.author != request.user %} {% if msg.author != request.user %}
<div class="d-flex align-items-start gap-2"> <div class="d-flex align-items-start gap-2">
@ -400,5 +467,72 @@ if (threadScroll) {
} }
}); });
})(); })();
// Flag selection AJAX
document.querySelectorAll('.flag-opt-btn').forEach(btn => {
btn.addEventListener('click', function() {
const flag = this.getAttribute('data-flag');
const url = "{% url 'notifications:discussion_set_flag' discussion.pk %}";
const formData = new FormData();
formData.append('flag', flag);
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
fetch(url, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
})
.then(res => res.json())
.then(data => {
if (data.ok) {
const iconDisplay = document.getElementById('flagIconDisplay');
const labelDisplay = document.getElementById('flagLabelDisplay');
if (data.flag === 'red') {
iconDisplay.innerHTML = '<i class="bi bi-flag-fill text-danger"></i>';
labelDisplay.textContent = data.flag_info.label;
} else if (data.flag === 'orange') {
iconDisplay.innerHTML = '<i class="bi bi-flag-fill text-warning"></i>';
labelDisplay.textContent = data.flag_info.label;
} else if (data.flag === 'green') {
iconDisplay.innerHTML = '<i class="bi bi-flag-fill text-success"></i>';
labelDisplay.textContent = data.flag_info.label;
} else {
iconDisplay.innerHTML = '<i class="bi bi-flag text-secondary"></i>';
labelDisplay.textContent = '{% translate "Statut" %}';
}
// Update checks in menu
document.querySelectorAll('.flag-opt-btn').forEach(b => {
const check = b.querySelector('.bi-check');
if (check) check.remove();
if (b.getAttribute('data-flag') === (data.flag || '')) {
const newCheck = document.createElement('i');
newCheck.className = 'bi bi-check ms-auto text-primary';
b.appendChild(newCheck);
}
});
// Append system message if returned
if (data.system_message) {
const threadContainer = document.getElementById('thread-container');
if (threadContainer) {
const div = document.createElement('div');
div.className = 'd-flex justify-content-center my-2';
div.innerHTML = `<span class="badge bg-light text-secondary border px-3 py-1.5 rounded-pill shadow-xs" style="font-size: 0.8rem; font-weight: 500;">
${data.system_message.content}
<span class="ms-1 opacity-75 small">(${data.system_message.created_at})</span>
</span>`;
threadContainer.appendChild(div);
const threadScroll = document.getElementById('thread-scroll');
if (threadScroll) threadScroll.scrollTop = threadScroll.scrollHeight;
}
}
}
})
.catch(err => console.error('Error changing flag:', err));
});
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -110,6 +110,12 @@
<span class="fw-semibold text-dark text-truncate" style="font-size: 0.975rem;"> <span class="fw-semibold text-dark text-truncate" style="font-size: 0.975rem;">
{{ item.discussion.title|default:item.discussion }} {{ item.discussion.title|default:item.discussion }}
</span> </span>
{% if item.discussion.flag %}
<span class="badge {{ item.discussion.flag_info.badge_class }} rounded-pill px-2 py-0.5 small"
title="{{ item.discussion.flag_info.label }}{% if item.discussion.flag_set_by %} - {{ item.discussion.flag_set_by.get_full_name|default:item.discussion.flag_set_by.username }}{% endif %}">
<i class="bi {{ item.discussion.flag_info.icon }} me-1"></i>{{ item.discussion.flag_info.label }}
</span>
{% endif %}
{% if item.discussion.object_label and item.discussion.object_label != item.discussion.title %} {% if item.discussion.object_label and item.discussion.object_label != item.discussion.title %}
<span class="badge bg-light text-secondary border small rounded-pill px-2 py-0.5"> <span class="badge bg-light text-secondary border small rounded-pill px-2 py-0.5">
<i class="bi bi-link-45deg me-0.5"></i>{{ item.discussion.object_label }} <i class="bi bi-link-45deg me-0.5"></i>{{ item.discussion.object_label }}

View file

@ -359,6 +359,34 @@
role="tabpanel" role="tabpanel"
aria-labelledby="tab-discussions-btn"> aria-labelledby="tab-discussions-btn">
<!-- Filter by Flag -->
<div class="card mb-3 border-0 shadow-sm">
<div class="card-body py-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="d-flex flex-wrap align-items-center gap-1.5">
<span class="small text-muted me-1 fw-medium"><i class="bi bi-funnel me-1"></i>{% translate "Statut" %} :</span>
<a href="?tab=discussions" class="btn btn-sm {% if not filter_flag %}btn-primary{% else %}btn-outline-secondary{% endif %} rounded-pill px-2.5 py-0.5" style="font-size: 0.8rem;">
{% translate "Tous" %} ({{ flag_counts.all|default:"0" }})
</a>
<a href="?tab=discussions&flag=red" class="btn btn-sm {% if filter_flag == 'red' %}btn-danger text-white{% else %}btn-outline-danger{% endif %} rounded-pill px-2.5 py-0.5" style="font-size: 0.8rem;">
<i class="bi bi-flag-fill me-1"></i>{% translate "Important" %} ({{ flag_counts.red|default:"0" }})
</a>
<a href="?tab=discussions&flag=orange" class="btn btn-sm {% if filter_flag == 'orange' %}btn-warning text-dark{% else %}btn-outline-warning text-dark{% endif %} rounded-pill px-2.5 py-0.5" style="font-size: 0.8rem;">
<i class="bi bi-flag-fill me-1"></i>{% translate "En cours" %} ({{ flag_counts.orange|default:"0" }})
</a>
<a href="?tab=discussions&flag=green" class="btn btn-sm {% if filter_flag == 'green' %}btn-success text-white{% else %}btn-outline-success{% endif %} rounded-pill px-2.5 py-0.5" style="font-size: 0.8rem;">
<i class="bi bi-flag-fill me-1"></i>{% translate "Traité" %} ({{ flag_counts.green|default:"0" }})
</a>
</div>
{% if filter_flag %}
<div>
<a href="?tab=discussions" class="btn btn-link btn-sm text-muted p-0 text-decoration-none" style="font-size: 0.8rem;">
<i class="bi bi-x-circle me-1"></i>{% translate "Réinitialiser" %}
</a>
</div>
{% endif %}
</div>
</div>
{% if not discussion_items %} {% if not discussion_items %}
<div class="card border-0 shadow-sm rounded-3"> <div class="card border-0 shadow-sm rounded-3">
<div class="card-body text-center py-5 text-muted"> <div class="card-body text-center py-5 text-muted">
@ -387,6 +415,12 @@
<span class="fw-semibold text-dark text-truncate" style="font-size: 0.975rem;"> <span class="fw-semibold text-dark text-truncate" style="font-size: 0.975rem;">
{{ item.discussion.title|default:item.discussion }} {{ item.discussion.title|default:item.discussion }}
</span> </span>
{% if item.discussion.flag %}
<span class="badge {{ item.discussion.flag_info.badge_class }} rounded-pill px-2 py-0.5 small"
title="{{ item.discussion.flag_info.label }}{% if item.discussion.flag_set_by %} - {{ item.discussion.flag_set_by.get_full_name|default:item.discussion.flag_set_by.username }}{% endif %}">
<i class="bi {{ item.discussion.flag_info.icon }} me-1"></i>{{ item.discussion.flag_info.label }}
</span>
{% endif %}
{% if item.discussion.object_label and item.discussion.object_label != item.discussion.title %} {% if item.discussion.object_label and item.discussion.object_label != item.discussion.title %}
<span class="badge bg-light text-secondary border small rounded-pill px-2 py-0.5"> <span class="badge bg-light text-secondary border small rounded-pill px-2 py-0.5">
<i class="bi bi-link-45deg me-0.5"></i>{{ item.discussion.object_label }} <i class="bi bi-link-45deg me-0.5"></i>{{ item.discussion.object_label }}

View file

@ -288,12 +288,28 @@
aria-labelledby="tab-discussions-btn"> aria-labelledby="tab-discussions-btn">
<!-- Action Button: Nouvelle Discussion --> <!-- Action Button: Nouvelle Discussion -->
<div class="mb-3"> <div class="mb-2">
<button type="button" class="btn btn-primary btn-sm w-100 shadow-sm" data-bs-toggle="modal" data-bs-target="#newDiscussionModal"> <button type="button" class="btn btn-primary btn-sm w-100 shadow-sm" data-bs-toggle="modal" data-bs-target="#newDiscussionModal">
<i class="bi bi-chat-plus-fill me-1.5"></i>{% translate "Nouvelle discussion" %} <i class="bi bi-chat-plus-fill me-1.5"></i>{% translate "Nouvelle discussion" %}
</button> </button>
</div> </div>
<!-- Filter by Flag -->
<div class="d-flex gap-1.5 overflow-x-auto pb-2 mb-2 no-scrollbar align-items-center">
<a href="?tab=discussions" class="btn btn-sm {% if not filter_flag %}btn-primary{% else %}btn-outline-secondary{% endif %} rounded-pill px-2.5 py-1 text-nowrap" style="font-size: 0.78rem;">
{% translate "Tous" %} ({{ flag_counts.all|default:"0" }})
</a>
<a href="?tab=discussions&flag=red" class="btn btn-sm {% if filter_flag == 'red' %}btn-danger text-white{% else %}btn-outline-danger{% endif %} rounded-pill px-2.5 py-1 text-nowrap" style="font-size: 0.78rem;">
<i class="bi bi-flag-fill me-1"></i>{% translate "Important" %} ({{ flag_counts.red|default:"0" }})
</a>
<a href="?tab=discussions&flag=orange" class="btn btn-sm {% if filter_flag == 'orange' %}btn-warning text-dark{% else %}btn-outline-warning text-dark{% endif %} rounded-pill px-2.5 py-1 text-nowrap" style="font-size: 0.78rem;">
<i class="bi bi-flag-fill me-1"></i>{% translate "En cours" %} ({{ flag_counts.orange|default:"0" }})
</a>
<a href="?tab=discussions&flag=green" class="btn btn-sm {% if filter_flag == 'green' %}btn-success text-white{% else %}btn-outline-success{% endif %} rounded-pill px-2.5 py-1 text-nowrap" style="font-size: 0.78rem;">
<i class="bi bi-flag-fill me-1"></i>{% translate "Traité" %} ({{ flag_counts.green|default:"0" }})
</a>
</div>
{% if not discussion_items %} {% if not discussion_items %}
<div class="card border-0 shadow-sm rounded-3"> <div class="card border-0 shadow-sm rounded-3">
<div class="card-body text-center py-5 text-muted"> <div class="card-body text-center py-5 text-muted">
@ -328,6 +344,11 @@
<!-- Badges --> <!-- Badges -->
<div class="d-flex flex-wrap gap-1 mb-1.5"> <div class="d-flex flex-wrap gap-1 mb-1.5">
{% if item.discussion.flag %}
<span class="badge {{ item.discussion.flag_info.badge_class }} rounded-pill py-0.5 px-1.5" style="font-size: 0.68rem;">
<i class="bi {{ item.discussion.flag_info.icon }} me-0.5"></i>{{ item.discussion.flag_info.label }}
</span>
{% endif %}
{% if item.discussion.object_label and item.discussion.object_label != item.discussion.title %} {% if item.discussion.object_label and item.discussion.object_label != item.discussion.title %}
<span class="badge bg-light text-secondary border rounded-pill py-0.5 px-1.5" style="font-size: 0.68rem;"> <span class="badge bg-light text-secondary border rounded-pill py-0.5 px-1.5" style="font-size: 0.68rem;">
<i class="bi bi-link-45deg me-0.5"></i>{{ item.discussion.object_label }} <i class="bi bi-link-45deg me-0.5"></i>{{ item.discussion.object_label }}

View file

@ -518,4 +518,106 @@ class TeamMentionTests(TestCase):
self.assertTrue(NotificationRecipient.objects.filter(user=self.team_member_user).exists()) self.assertTrue(NotificationRecipient.objects.filter(user=self.team_member_user).exists())
class DiscussionFlagTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='flag_user', password='pass', first_name='Alice', last_name='Smith')
self.other_user = User.objects.create_user(username='other_user', password='pass', first_name='Bob')
self.discussion = Discussion.objects.create(title="Discussion Flag Test", created_by=self.user)
self.discussion.participants.add(self.user)
def test_set_discussion_flag_service(self):
from notifications.services import set_discussion_flag
# 1. Set red flag
msg = set_discussion_flag(self.discussion, 'red', user=self.user)
self.discussion.refresh_from_db()
self.assertEqual(self.discussion.flag, 'red')
self.assertEqual(self.discussion.flag_set_by, self.user)
self.assertIsNotNone(self.discussion.flag_set_at)
self.assertIsNotNone(msg)
self.assertTrue(msg.is_system)
self.assertIn("Alice Smith", msg.content)
self.assertIn("🚩", msg.content)
# 2. Change to green flag
msg2 = set_discussion_flag(self.discussion, 'green', user=self.user)
self.discussion.refresh_from_db()
self.assertEqual(self.discussion.flag, 'green')
self.assertIn("🟩", msg2.content)
# 3. Setting same flag returns None and doesn't create new message
msg_noop = set_discussion_flag(self.discussion, 'green', user=self.user)
self.assertIsNone(msg_noop)
# 4. Remove flag
msg_clear = set_discussion_flag(self.discussion, '', user=self.user)
self.discussion.refresh_from_db()
self.assertEqual(self.discussion.flag, '')
self.assertIsNone(self.discussion.flag_set_at)
self.assertIn("retiré", msg_clear.content)
def test_discussion_set_flag_view_ajax(self):
self.client.force_login(self.user)
url = reverse('notifications:discussion_set_flag', args=[self.discussion.pk])
res = self.client.post(url, {'flag': 'orange'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(res.status_code, 200)
data = res.json()
self.assertTrue(data['ok'])
self.assertEqual(data['flag'], 'orange')
self.assertIn(data['flag_info']['label'], ['En cours', 'In progress'])
self.discussion.refresh_from_db()
self.assertEqual(self.discussion.flag, 'orange')
# Non-participant receives 403
self.client.force_login(self.other_user)
res_forbidden = self.client.post(url, {'flag': 'green'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(res_forbidden.status_code, 403)
def test_discussion_filter_by_flag(self):
from notifications.views import _get_user_discussions_data, _get_inbox_context
from django.test import RequestFactory
# Create discussions with different flags
d_red = Discussion.objects.create(title="Red Disc", created_by=self.user, flag='red')
d_red.participants.add(self.user)
d_green = Discussion.objects.create(title="Green Disc", created_by=self.user, flag='green')
d_green.participants.add(self.user)
# Filter red
items_red, _ = _get_user_discussions_data(self.user, flag='red')
pks_red = [i['discussion'].pk for i in items_red]
self.assertIn(d_red.pk, pks_red)
self.assertNotIn(d_green.pk, pks_red)
# Filter green
items_green, _ = _get_user_discussions_data(self.user, flag='green')
pks_green = [i['discussion'].pk for i in items_green]
self.assertIn(d_green.pk, pks_green)
self.assertNotIn(d_red.pk, pks_green)
# Inbox context with flag filter
factory = RequestFactory()
req = factory.get('/notifications/', {'tab': 'discussions', 'flag': 'red'})
req.user = self.user
ctx = _get_inbox_context(req)
self.assertEqual(ctx['filter_flag'], 'red')
self.assertEqual(ctx['flag_counts']['red'], 1)
self.assertEqual(ctx['flag_counts']['green'], 1)
def test_discussion_create_with_flag(self):
self.client.force_login(self.user)
url = reverse('notifications:discussion_create')
res = self.client.post(url, {
'title': 'New Flagged Discussion',
'content': 'First message',
'flag': 'red',
})
self.assertEqual(res.status_code, 302)
disc = Discussion.objects.filter(title='New Flagged Discussion').first()
self.assertIsNotNone(disc)
self.assertEqual(disc.flag, 'red')
self.assertEqual(disc.flag_set_by, self.user)

View file

@ -23,6 +23,7 @@ urlpatterns = [
path('discussions/', views.discussion_list, name='discussion_list'), path('discussions/', views.discussion_list, name='discussion_list'),
path('discussions/new/', views.discussion_create, name='discussion_create'), path('discussions/new/', views.discussion_create, name='discussion_create'),
path('discussions/<int:pk>/', views.discussion_detail, name='discussion_detail'), path('discussions/<int:pk>/', views.discussion_detail, name='discussion_detail'),
path('discussions/<int:pk>/flag/', views.discussion_set_flag, name='discussion_set_flag'),
# API # API
path('api/unread-count/', views.api_unread_count, name='api_unread_count'), path('api/unread-count/', views.api_unread_count, name='api_unread_count'),
path('api/recent/', views.api_recent, name='api_recent'), path('api/recent/', views.api_recent, name='api_recent'),

View file

@ -32,6 +32,7 @@ from .services import (
notify_team, notify_team,
post_message, post_message,
mark_discussion_seen, mark_discussion_seen,
set_discussion_flag,
) )
from .forms import NotificationCreateForm, TeamForm from .forms import NotificationCreateForm, TeamForm
@ -188,7 +189,7 @@ def _translate_notification_data(notification):
} }
def _get_user_discussions_data(user): def _get_user_discussions_data(user, flag=None, return_counts=False):
"""Récupère les discussions de l'utilisateur avec leur dernier message et compteur non-lus.""" """Récupère les discussions de l'utilisateur avec leur dernier message et compteur non-lus."""
from contracts.models import CompanyTeam from contracts.models import CompanyTeam
company_team_ids = list( company_team_ids = list(
@ -209,13 +210,29 @@ def _get_user_discussions_data(user):
| (Q(created_by=user) & Q(content_type__isnull=True)) | (Q(created_by=user) & Q(content_type__isnull=True))
) )
discussions = ( all_discussions_qs = (
Discussion.objects.filter(discussion_filter) Discussion.objects.filter(discussion_filter)
.distinct() .distinct()
.prefetch_related('participants', 'messages') .prefetch_related('participants', 'messages')
.select_related('created_by') .select_related('created_by', 'flag_set_by')
.order_by('-updated_at') .order_by('-updated_at')
) )
flag_counts = {
'all': all_discussions_qs.count(),
'red': all_discussions_qs.filter(flag='red').count(),
'orange': all_discussions_qs.filter(flag='orange').count(),
'green': all_discussions_qs.filter(flag='green').count(),
'none': all_discussions_qs.filter(flag='').count(),
}
discussions = all_discussions_qs
if flag:
if flag == 'none':
discussions = discussions.filter(flag='')
elif flag in ('red', 'orange', 'green'):
discussions = discussions.filter(flag=flag)
items = [] items = []
for d in discussions: for d in discussions:
last_msg = d.messages.filter(is_deleted=False).order_by('-created_at').first() last_msg = d.messages.filter(is_deleted=False).order_by('-created_at').first()
@ -225,6 +242,8 @@ def _get_user_discussions_data(user):
'unread_count': d.unread_count_for(user), 'unread_count': d.unread_count_for(user),
}) })
unread_discussions_count = sum(1 for item in items if item['unread_count'] > 0) unread_discussions_count = sum(1 for item in items if item['unread_count'] > 0)
if return_counts:
return items, unread_discussions_count, flag_counts
return items, unread_discussions_count return items, unread_discussions_count
@ -233,6 +252,7 @@ def _get_inbox_context(request):
show_archived = request.GET.get('archived') == '1' show_archived = request.GET.get('archived') == '1'
notification_type = request.GET.get('type', '') notification_type = request.GET.get('type', '')
active_tab = request.GET.get('tab', 'notifications') active_tab = request.GET.get('tab', 'notifications')
filter_flag = request.GET.get('flag', '').strip()
all_notifications = get_user_notifications(request.user, include_archived=show_archived) all_notifications = get_user_notifications(request.user, include_archived=show_archived)
@ -250,7 +270,9 @@ def _get_inbox_context(request):
unread_count = sum(1 for n in all_notifications if not n['is_read'] and not n['is_archived']) unread_count = sum(1 for n in all_notifications if not n['is_read'] and not n['is_archived'])
discussion_items, unread_discussions_count = _get_user_discussions_data(request.user) discussion_items, unread_discussions_count, flag_counts = _get_user_discussions_data(
request.user, flag=filter_flag, return_counts=True
)
return { return {
'notifications': all_notifications, 'notifications': all_notifications,
@ -260,6 +282,10 @@ def _get_inbox_context(request):
'notification_types': Notification.TYPES, 'notification_types': Notification.TYPES,
'discussion_items': discussion_items, 'discussion_items': discussion_items,
'unread_discussions_count': unread_discussions_count, 'unread_discussions_count': unread_discussions_count,
'filter_flag': filter_flag,
'flag_counts': flag_counts,
'flag_choices': Discussion.FLAG_CHOICES,
'flag_meta': Discussion.FLAG_META,
'active_tab': active_tab, 'active_tab': active_tab,
} }
@ -534,6 +560,38 @@ def discussion_list(request):
return redirect(f"{reverse('notifications:inbox')}?tab=discussions") return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
def _user_can_access_discussion(user, discussion):
"""Vérifie si l'utilisateur a le droit d'accéder à la discussion (lecture / écriture / flag)."""
user_config = getattr(user, 'config', None)
is_admin = user.is_superuser or (
user_config and (
user_config.has_role('admin')
or user_config.has_role('manager')
or user_config.has_role('operator')
or user_config.has_role('stock_manager')
)
)
if is_admin or discussion.participants.filter(pk=user.pk).exists():
return True
if discussion.content_type_id and discussion.object_id:
obj = discussion.content_object
if obj:
from stock.models import PreparationOrder
from interventions.models import Intervention
from observations.models import Observation
if isinstance(obj, Intervention):
from notifications.services import can_post_intervention_message
return can_post_intervention_message(user, obj)
elif isinstance(obj, PreparationOrder):
from notifications.services import can_post_preparation_order_message
return can_post_preparation_order_message(user, obj)
elif isinstance(obj, Observation):
from notifications.services import can_post_observation_message
return can_post_observation_message(user, obj)
return False
@login_required @login_required
def discussion_detail(request, pk): def discussion_detail(request, pk):
"""Affiche le fil de discussion et permet de répondre.""" """Affiche le fil de discussion et permet de répondre."""
@ -542,35 +600,14 @@ def discussion_detail(request, pk):
pk=pk, pk=pk,
) )
# Vérifier que l'utilisateur est participant (ou admin / accès à l'objet lié) if not _user_can_access_discussion(request.user, discussion):
user_config = getattr(request.user, 'config', None)
is_admin = request.user.is_superuser or (
user_config and (user_config.has_role('admin') or user_config.has_role('manager') or user_config.has_role('operator') or user_config.has_role('stock_manager'))
)
is_participant = discussion.participants.filter(pk=request.user.pk).exists()
has_access = is_admin or is_participant
if not has_access and discussion.content_type_id and discussion.object_id:
obj = discussion.content_object
if obj:
from stock.models import PreparationOrder
from interventions.models import Intervention
from observations.models import Observation
if isinstance(obj, Intervention):
from notifications.services import can_post_intervention_message
has_access = can_post_intervention_message(request.user, obj)
elif isinstance(obj, PreparationOrder):
from notifications.services import can_post_preparation_order_message
has_access = can_post_preparation_order_message(request.user, obj)
elif isinstance(obj, Observation):
from notifications.services import can_post_observation_message
has_access = can_post_observation_message(request.user, obj)
if not has_access:
messages.error(request, _("Vous n'êtes pas participant de cette discussion.")) messages.error(request, _("Vous n'êtes pas participant de cette discussion."))
return redirect(f"{reverse('notifications:inbox')}?tab=discussions") return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
if request.method == 'POST': if request.method == 'POST':
if 'flag' in request.POST:
set_discussion_flag(discussion, request.POST.get('flag', ''), user=request.user)
content = request.POST.get('content', '').strip() content = request.POST.get('content', '').strip()
if content: if content:
files = request.FILES.getlist('attachments') files = request.FILES.getlist('attachments')
@ -591,9 +628,80 @@ def discussion_detail(request, pk):
return render(request, 'notifications/discussion_detail.html', { return render(request, 'notifications/discussion_detail.html', {
'discussion': discussion, 'discussion': discussion,
'thread_messages': thread_messages, 'thread_messages': thread_messages,
'flag_choices': Discussion.FLAG_CHOICES,
'flag_meta': Discussion.FLAG_META,
}) })
@login_required
@require_POST
def discussion_set_flag(request, pk):
"""
Définit ou retire le drapeau de suivi sur une discussion.
Supporte les requêtes AJAX (JSON) et les soumissions classiques.
"""
discussion = get_object_or_404(Discussion, pk=pk)
if not _user_can_access_discussion(request.user, discussion):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.content_type == 'application/json':
return JsonResponse({'error': 'forbidden'}, status=403)
messages.error(request, _("Vous n'avez pas accès à cette discussion."))
return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
flag = request.POST.get('flag')
if flag is None and request.content_type == 'application/json':
try:
body = json.loads(request.body.decode('utf-8'))
flag = body.get('flag')
except Exception:
pass
flag = (flag or '').strip()
sys_msg = set_discussion_flag(discussion, flag, user=request.user)
is_ajax = (
request.headers.get('X-Requested-With') == 'XMLHttpRequest'
or request.content_type == 'application/json'
or request.GET.get('format') == 'json'
)
if is_ajax:
flag_info_data = None
if discussion.flag_info:
flag_info_data = {
'flag': discussion.flag,
'label': str(discussion.flag_info.get('label', '')),
'color': discussion.flag_info.get('color', ''),
'badge_class': discussion.flag_info.get('badge_class', ''),
'icon': discussion.flag_info.get('icon', 'bi-flag-fill'),
'set_by': (
discussion.flag_set_by.get_full_name().strip() or discussion.flag_set_by.username
if discussion.flag_set_by else None
),
'set_at': discussion.flag_set_at.strftime('%d/%m/%Y %H:%M') if discussion.flag_set_at else None,
}
return JsonResponse({
'ok': True,
'flag': discussion.flag,
'flag_info': flag_info_data,
'flag_set_by': (
discussion.flag_set_by.get_full_name().strip() or discussion.flag_set_by.username
if discussion.flag_set_by else None
),
'flag_set_at': discussion.flag_set_at.isoformat() if discussion.flag_set_at else None,
'system_message': {
'id': sys_msg.pk,
'content': sys_msg.content,
'created_at': sys_msg.created_at.strftime('%d/%m/%Y %H:%M'),
} if sys_msg else None,
})
messages.success(request, _("Statut de la discussion mis à jour."))
referer = request.META.get('HTTP_REFERER')
if referer:
return redirect(referer)
return redirect('notifications:discussion_detail', pk=pk)
@login_required @login_required
def discussion_create(request): def discussion_create(request):
""" """
@ -604,12 +712,17 @@ def discussion_create(request):
title = request.POST.get('title', '').strip() title = request.POST.get('title', '').strip()
content = request.POST.get('content', '').strip() content = request.POST.get('content', '').strip()
files = request.FILES.getlist('attachments') files = request.FILES.getlist('attachments')
flag = request.POST.get('flag', '').strip()
if content: if content:
user_name = request.user.get_full_name() or request.user.username user_name = request.user.get_full_name() or request.user.username
now = timezone.now()
discussion = Discussion.objects.create( discussion = Discussion.objects.create(
title=title or (_("Discussion de %(name)s") % {'name': user_name}), title=title or (_("Discussion de %(name)s") % {'name': user_name}),
created_by=request.user, created_by=request.user,
flag=flag if flag in dict(Discussion.FLAG_CHOICES) else '',
flag_set_by=request.user if flag in dict(Discussion.FLAG_CHOICES) and flag else None,
flag_set_at=now if flag in dict(Discussion.FLAG_CHOICES) and flag else None,
) )
discussion.participants.add(request.user) discussion.participants.add(request.user)

View file

@ -851,6 +851,27 @@
<i class="bi bi-chat-dots text-primary"></i> <i class="bi bi-chat-dots text-primary"></i>
{% translate "Messages" %} {% translate "Messages" %}
</h5> </h5>
{% if discussion %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary py-0 px-2 rounded-pill d-flex align-items-center gap-1 dropdown-toggle"
type="button" id="obsDiscussionFlagBtn" data-bs-toggle="dropdown" aria-expanded="false" style="font-size: 0.75rem;">
<span id="obs-flag-icon-display">
{% if discussion.flag == 'red' %}🚩{% elif discussion.flag == 'orange' %}🟧{% elif discussion.flag == 'green' %}🟩{% else %}🏳️{% endif %}
</span>
<span id="obs-flag-label-display">
{{ discussion.flag_info.label|default:_("Statut") }}
</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="obsDiscussionFlagBtn" style="font-size: 0.8rem;">
<li><h6 class="dropdown-header small text-muted py-1">{% translate "Statut de suivi" %}</h6></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 obs-flag-opt" data-flag="red"><i class="bi bi-flag-fill text-danger"></i> {% translate "Important / À traiter" %}</button></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 obs-flag-opt" data-flag="orange"><i class="bi bi-flag-fill text-warning"></i> {% translate "En cours" %}</button></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 obs-flag-opt" data-flag="green"><i class="bi bi-flag-fill text-success"></i> {% translate "Traité / Fait" %}</button></li>
<li><hr class="dropdown-divider my-1"></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 obs-flag-opt text-muted" data-flag=""><i class="bi bi-flag"></i> {% translate "Aucun drapeau" %}</button></li>
</ul>
</div>
{% endif %}
</div> </div>
<div class="card-body p-3"> <div class="card-body p-3">
@ -859,6 +880,13 @@
<div class="d-flex flex-column gap-3 mb-3" id="discussion-thread" <div class="d-flex flex-column gap-3 mb-3" id="discussion-thread"
style="max-height:380px; overflow-y:auto; padding-right:4px;"> style="max-height:380px; overflow-y:auto; padding-right:4px;">
{% for msg in discussion_messages %} {% for msg in discussion_messages %}
{% if msg.is_system %}
<div class="d-flex justify-content-center my-1">
<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
{{ msg.content }}
</span>
</div>
{% else %}
<div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}"> <div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}">
{% if msg.author != request.user %} {% if msg.author != request.user %}
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0" <div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0"
@ -899,6 +927,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
@ -1845,6 +1874,60 @@ document.addEventListener('DOMContentLoaded', function() {
})(); })();
// ---- fin @mention autocomplete ---- // ---- fin @mention autocomplete ----
// ---- Changement de drapeau de la discussion (observation) ----
{% if discussion %}
(function() {
document.querySelectorAll('.obs-flag-opt').forEach(btn => {
btn.addEventListener('click', function() {
const flag = this.getAttribute('data-flag');
const url = "{% url 'notifications:discussion_set_flag' discussion.pk %}";
const formData = new FormData();
formData.append('flag', flag);
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
fetch(url, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
})
.then(res => res.json())
.then(data => {
if (data.ok) {
const headerIcon = document.getElementById('obs-flag-icon-display');
const headerLabel = document.getElementById('obs-flag-label-display');
if (headerIcon) {
if (data.flag === 'red') headerIcon.textContent = '🚩';
else if (data.flag === 'orange') headerIcon.textContent = '🟧';
else if (data.flag === 'green') headerIcon.textContent = '🟩';
else headerIcon.textContent = '🏳️';
}
if (headerLabel) {
headerLabel.textContent = data.flag_info.label || '{% translate "Statut" %}';
}
if (data.system_message) {
const thread = document.getElementById('discussion-thread');
if (thread) {
const sysDiv = document.createElement('div');
sysDiv.className = 'd-flex justify-content-center my-1';
sysDiv.innerHTML = `<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
${data.system_message.content}
</span>`;
thread.appendChild(sysDiv);
thread.scrollTop = thread.scrollHeight;
}
}
}
})
.catch(err => console.error('Error changing observation discussion flag:', err));
});
});
})();
{% endif %}
// ---- Live-search modals : Lier à une intervention / un projet ---- // ---- Live-search modals : Lier à une intervention / un projet ----
(function () { (function () {
function makeLiveSearch({ inputId, resultsId, hiddenId, displayId, confirmBtnId, searchUrl }) { function makeLiveSearch({ inputId, resultsId, hiddenId, displayId, confirmBtnId, searchUrl }) {

View file

@ -1649,6 +1649,21 @@ document.addEventListener('DOMContentLoaded', function() {
line-height: 1; line-height: 1;
border: 2px solid #fff; border: 2px solid #fff;
} }
#discussion-fab .fab-flag-badge {
position: absolute;
top: -3px;
left: -3px;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: .75rem;
border: 2px solid #fff;
color: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,.2);
}
#discussion-panel { #discussion-panel {
position: fixed; position: fixed;
bottom: 90px; bottom: 90px;
@ -1702,21 +1717,56 @@ document.addEventListener('DOMContentLoaded', function() {
{% if discussion_messages %} {% if discussion_messages %}
<span class="fab-badge" id="discussion-fab-badge">{{ discussion_messages|length }}</span> <span class="fab-badge" id="discussion-fab-badge">{{ discussion_messages|length }}</span>
{% endif %} {% endif %}
<span class="fab-flag-badge {% if not discussion or not discussion.flag %}d-none{% endif %}" id="discussion-fab-flag-badge"
style="background-color: {{ discussion.flag_info.color|default:'#dc3545' }};"
title="{{ discussion.flag_info.label }}">
<i class="bi {{ discussion.flag_info.icon|default:'bi-flag-fill' }}"></i>
</span>
</button> </button>
<div id="discussion-panel" role="dialog" aria-label="{% translate 'Discussion' %}"> <div id="discussion-panel" role="dialog" aria-label="{% translate 'Discussion' %}">
<!-- En-tête --> <!-- En-tête -->
<div id="discussion-panel-header"> <div id="discussion-panel-header">
<span class="fw-semibold d-flex align-items-center gap-2 small"> <span class="fw-semibold d-flex align-items-center gap-2 small">
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion liée à ce bon" %} <i class="bi bi-chat-dots-fill"></i> {% translate "Discussion" %}
</span> </span>
<div class="d-flex align-items-center gap-2">
{% if discussion %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-light py-0 px-2 rounded-pill d-flex align-items-center gap-1 border-0 bg-white bg-opacity-20 dropdown-toggle"
type="button" id="poDiscussionFlagBtn" data-bs-toggle="dropdown" aria-expanded="false" style="font-size: 0.75rem;">
<span id="po-flag-icon-display">
{% if discussion.flag == 'red' %}🚩{% elif discussion.flag == 'orange' %}🟧{% elif discussion.flag == 'green' %}🟩{% else %}🏳️{% endif %}
</span>
<span id="po-flag-label-display">
{{ discussion.flag_info.label|default:_("Statut") }}
</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="poDiscussionFlagBtn" style="font-size: 0.8rem; z-index: 1070;">
<li><h6 class="dropdown-header small text-muted py-1">{% translate "Statut de suivi" %}</h6></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 po-flag-opt" data-flag="red"><i class="bi bi-flag-fill text-danger"></i> {% translate "Important / À traiter" %}</button></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 po-flag-opt" data-flag="orange"><i class="bi bi-flag-fill text-warning"></i> {% translate "En cours" %}</button></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 po-flag-opt" data-flag="green"><i class="bi bi-flag-fill text-success"></i> {% translate "Traité / Fait" %}</button></li>
<li><hr class="dropdown-divider my-1"></li>
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 po-flag-opt text-muted" data-flag=""><i class="bi bi-flag"></i> {% translate "Aucun drapeau" %}</button></li>
</ul>
</div>
{% endif %}
<button type="button" class="btn-close btn-close-white btn-sm" onclick="toggleDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button> <button type="button" class="btn-close btn-close-white btn-sm" onclick="toggleDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button>
</div> </div>
</div>
<!-- Fil de messages --> <!-- Fil de messages -->
<div id="discussion-thread"> <div id="discussion-thread">
{% if discussion_messages %} {% if discussion_messages %}
{% for msg in discussion_messages %} {% for msg in discussion_messages %}
{% if msg.is_system %}
<div class="d-flex justify-content-center my-1">
<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
{{ msg.content }}
</span>
</div>
{% else %}
<div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}"> <div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}">
{% if msg.author != request.user %} {% if msg.author != request.user %}
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0" <div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0"
@ -1754,6 +1804,7 @@ document.addEventListener('DOMContentLoaded', function() {
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
{% else %} {% else %}
<p class="text-muted small text-center py-3 mb-0"> <p class="text-muted small text-center py-3 mb-0">
@ -2073,5 +2124,73 @@ document.addEventListener('DOMContentLoaded', function () {
} }
}); });
})(); })();
// ---- Changement de drapeau de la discussion (bon de préparation) ----
{% if discussion %}
(function() {
document.querySelectorAll('.po-flag-opt').forEach(btn => {
btn.addEventListener('click', function() {
const flag = this.getAttribute('data-flag');
const url = "{% url 'notifications:discussion_set_flag' discussion.pk %}";
const formData = new FormData();
formData.append('flag', flag);
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
fetch(url, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
})
.then(res => res.json())
.then(data => {
if (data.ok) {
const fabFlagBadge = document.getElementById('discussion-fab-flag-badge');
const headerIcon = document.getElementById('po-flag-icon-display');
const headerLabel = document.getElementById('po-flag-label-display');
if (data.flag) {
if (fabFlagBadge) {
fabFlagBadge.classList.remove('d-none');
fabFlagBadge.style.backgroundColor = data.flag_info.color;
fabFlagBadge.title = data.flag_info.label;
fabFlagBadge.innerHTML = `<i class="bi ${data.flag_info.icon}"></i>`;
}
} else {
if (fabFlagBadge) {
fabFlagBadge.classList.add('d-none');
}
}
if (headerIcon) {
if (data.flag === 'red') headerIcon.textContent = '🚩';
else if (data.flag === 'orange') headerIcon.textContent = '🟧';
else if (data.flag === 'green') headerIcon.textContent = '🟩';
else headerIcon.textContent = '🏳️';
}
if (headerLabel) {
headerLabel.textContent = data.flag_info.label || '{% translate "Statut" %}';
}
if (data.system_message) {
const thread = document.getElementById('discussion-thread');
if (thread) {
const sysDiv = document.createElement('div');
sysDiv.className = 'd-flex justify-content-center my-1';
sysDiv.innerHTML = `<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
${data.system_message.content}
</span>`;
thread.appendChild(sysDiv);
thread.scrollTop = thread.scrollHeight;
}
}
}
})
.catch(err => console.error('Error changing preparation order discussion flag:', err));
});
});
})();
{% endif %}
</script> </script>
{% endblock content %} {% endblock content %}

View file

@ -3923,6 +3923,7 @@ def preparation_order_detail(request, pk):
return_batches = preparation_order.stock_batches.filter(movement_type='in').order_by('date') return_batches = preparation_order.stock_batches.filter(movement_type='in').order_by('date')
# Discussion liée à ce bon de préparation # Discussion liée à ce bon de préparation
discussion = None
discussion_messages = [] discussion_messages = []
can_post_discussion_message = False can_post_discussion_message = False
try: try:
@ -3959,6 +3960,7 @@ def preparation_order_detail(request, pk):
'has_executed_outputs': has_executed_outputs, 'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened, 'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return, 'deleted_items_to_return': deleted_items_to_return,
'discussion': discussion,
'discussion_messages': discussion_messages, 'discussion_messages': discussion_messages,
'can_post_discussion_message': can_post_discussion_message, 'can_post_discussion_message': can_post_discussion_message,
} }