Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions envergo/pages/templatetags/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,21 @@ def querystring(context, *args, **kwargs):
params[key] = value
query_string = params.urlencode() if params else ""
return f"?{query_string}"


@register.inclusion_tag("_truncated_comment.html")
def truncated_comment(text, uid, limit=50):
"""
Display a truncated comment with DSFR-compatible expand/collapse.
"""
if not text:
return {"text": None}

return {
"text": text,
"limit": limit,
"uid": uid,
"is_truncated": len(text) > limit,
"head": text[:limit],
"tail": text[limit:],
}
27 changes: 27 additions & 0 deletions envergo/petitions/migrations/0029_statuslog_resumed_by_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.2.23 on 2025-12-18 05:27

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("petitions", "0028_alter_statuslog_options_and_more"),
]

operations = [
migrations.AddField(
model_name="statuslog",
name="resumed_by",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="resumed_logs",
to=settings.AUTH_USER_MODEL,
verbose_name="Auteur de la reprise de la procédure suite à la réception d'informations complémentaires",
),
),
]
13 changes: 13 additions & 0 deletions envergo/petitions/migrations/0034_merge_20260107_0633.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Generated by Django 4.2.23 on 2026-01-07 05:33

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("petitions", "0029_statuslog_resumed_by_and_more"),
("petitions", "0033_merge_20251211_1513"),
]

operations = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Generated by Django 4.2.23 on 2026-01-07 05:34

from django.db import migrations, models


def fix_missing_resumed_by(apps, schema_editor):
"""Set resumed_by for logs that have info_receipt_date but no resumed_by."""
StatusLog = apps.get_model("petitions", "StatusLog")
User = apps.get_model("users", "User")
qs = StatusLog.objects.filter(
info_receipt_date__isnull=False,
resumed_by__isnull=True,
)
if qs.count() == 0:
return

user = User.objects.get(id=1)
qs.update(resumed_by=user)


class Migration(migrations.Migration):
atomic = False

dependencies = [
("petitions", "0034_merge_20260107_0633"),
]

operations = [
migrations.RemoveConstraint(
model_name="statuslog",
name="receipt_date_data_is_consistent",
),
migrations.RunPython(fix_missing_resumed_by, migrations.RunPython.noop),
migrations.AddConstraint(
model_name="statuslog",
constraint=models.CheckConstraint(
check=models.Q(
models.Q(
("info_receipt_date__isnull", True),
("resumed_by__isnull", True),
),
models.Q(
("info_receipt_date__isnull", False),
("resumed_by__isnull", False),
("suspension_date__isnull", False),
("response_due_date__isnull", False),
),
_connector="OR",
),
name="receipt_date_data_is_consistent",
),
),
]
13 changes: 10 additions & 3 deletions envergo/petitions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,9 +588,9 @@ def is_valid(self):
& Q(original_due_date__isnull=True)
)

# Check that the receipt date is only set if the project was suspended
q_receipt_date = Q(info_receipt_date__isnull=True) | (
Q(info_receipt_date__isnull=False) & q_suspended
# Check that the receipt date is only set if the project was suspended and the resumption author is set
q_receipt_date = Q(info_receipt_date__isnull=True) & Q(resumed_by__isnull=True) | (
Q(info_receipt_date__isnull=False) & Q(resumed_by__isnull=False) & q_suspended
)


Expand Down Expand Up @@ -662,6 +662,13 @@ class StatusLog(models.Model):
info_receipt_date = models.DateField(
"Date de réception des pièces complémentaires", null=True, blank=True
)
resumed_by = models.ForeignKey(
"users.User",
related_name="resumed_logs",
on_delete=models.SET_NULL,
verbose_name="Auteur de la reprise de la procédure suite à la réception d'informations complémentaires",
null=True,
)

# Meta fields
created_at = models.DateTimeField(
Expand Down
142 changes: 110 additions & 32 deletions envergo/petitions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import shutil
import tempfile
from urllib.parse import parse_qs, urlparse
from zoneinfo import ZoneInfo

import fiona
import requests
Expand All @@ -13,7 +14,6 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.postgres.expressions import ArraySubquery
from django.contrib.sites.models import Site
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import transaction
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
from django.db.models.functions import Coalesce
Expand All @@ -35,6 +35,7 @@
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, FormView, ListView, UpdateView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.list import MultipleObjectMixin
from fiona import Feature, Geometry, Properties
from pyproj import Transformer
from shapely.ops import transform
Expand All @@ -61,6 +62,7 @@
SimulationForm,
)
from envergo.petitions.models import (
DECISIONS,
DOSSIER_STATES,
STAGES,
InvitationToken,
Expand Down Expand Up @@ -1251,14 +1253,28 @@ def get_success_url(self):


class PetitionProjectInstructorProcedureView(
BasePetitionProjectInstructorView, FormView
BasePetitionProjectInstructorView, MultipleObjectMixin, FormView
):
"""View for display and edit the petition project procedure by the instructor"""

form_class = ProcedureForm
template_name = "haie/petitions/instructor_view_procedure.html"
paginate_by = 10

def get(self, request, *args, **kwargs):
self.object = self.get_object()
self.object_list = self.object.status_history.select_related(
"created_by"
).order_by("-created_at")
return super().get(request, *args, **kwargs)

def post(self, request, *args, **kwargs):
self.object = self.get_object()
self.object_list = self.object.status_history.select_related(
"created_by"
).order_by("-created_at")
return super().post(request, *args, **kwargs)

def get_initial(self):
initial = super().get_initial()
initial["stage"] = self.object.current_stage
Expand All @@ -1267,49 +1283,109 @@ def get_initial(self):
return initial

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
obj = self.object
history_qs = obj.status_history.select_related("created_by").order_by(
"-created_at"
)
paginator = Paginator(history_qs, self.paginate_by)

page_number = self.request.GET.get("page")
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)

# Prefetch previous log in the current page
start = max(
page_obj.start_index() - 2, 0
) # -1 for 0 index, -1 to get previous log
end = page_obj.end_index()
logs = list(history_qs[start:end])
for i, log in enumerate(logs):
log.previous_log = logs[i + 1] if i + 1 < len(logs) else None

# Drop the extra item (first one) if it's not part of this page
if page_obj.number > 1:
logs = logs[1:]
def extract_entries(log):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Je suis un peu mitigé quant à ce choix technique. À ce stade, on a une belle correspondance ou chaque entrée StatusLog correspond à un changement d'état. C'est clair, explicite et propre.

Est-ce que ça n'aurait pas été plus simple de garder cette correspondance en créant tout simplement une nouvelle entrée au moment de la reprise de l'instruction ? Ça aurait évité de bidouiller pour créer ce tableau et épargné la nécessité de créer une nouvelle structure de données.

Par ailleurs, la présente solution créé un petit bug de cohérence sur le nombre de changements indiqué.

Image

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pour ce besoin précis d'affichage des changements, on prefererait effectivement avoir un objet distinct pour chaque changement: changement d'état, demande de suppression, reception des compléments.
Mais il y a peut etre eu d'autre raison technique ou metier qui ont mené à la decision de faire porter les demandes de supplement et les reception directement sur les changements d'état.
Je ne suis pas fermé à lancer la discussion, mais peut etre en dehors de ce ticket que je preferais merger tel quel.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Je reste super mitigé vis à vis de ça, parce qu'on intègre au dépôt du code relativement complexe pour quelque chose qui devrait être trivial : une liste d'états = un tableau d'historique. Pour moi ça frise pas mal la dette technique parce que dès qu'on devra toucher à ce changement d'état, il faudra retoucher cette boucle. Je trouve dommage d'intégrer ça au dépôt si ça doit être retouché plus tard.

D'ailleurs, il me semble qu'il y a déjà un ticket dans les tuyaux qui va obliger à se poser la question.

https://trello.com/c/ecg5IsPW/2057-conserver-l%C3%A9tat-suspendu-lors-dun-changement-d%C3%A9tape

Maintenant, si tu juges que c'est mieux de fusionner en état pour des raisons de mep et que tu veux y revenir plus tard, je ne bloque pas la fusion :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

J'ai suivi la voie de la sagesse (aka @thibault) voici la séparation des logs en différents objets ! Une nouvelle revue n'est pas de refus :)

"""The history table display an entry for each status change, suspension and resumption for requesting info

As the suspension and resumption are not a single StatusLog model but attributes of it, this method will
map a StatusLog into one or more entries for the history table.
"""
TYPE_CHOICES = [
("status_change", "Changement d'état"),
("suspension", "Demande de compléments"),
("resumption", "Complément reçus"),
]
TYPE_LABELS = dict(TYPE_CHOICES)

entries = [
{
"type": "status_change",
"type_display": TYPE_LABELS["status_change"],
"created_at": log.created_at,
"status_date": log.status_date,
"created_by": (
""
if not log.created_by
else (
log.created_by.email
if not log.created_by.is_staff
else "Administrateur"
)
),
"update_comment": log.update_comment,
"stage": log.stage,
"decision": log.decision,
"due_date": log.due_date,
}
]
if log.suspension_date:
# this log object is storing a suspension, we add an entry
entries.insert(
0,
{
"type": "suspension",
"type_display": TYPE_LABELS["suspension"],
"created_by": (
""
if not log.suspended_by
else (
log.suspended_by.email
if not log.suspended_by.is_staff
else "Administrateur"
)
),
"created_at": datetime.datetime.combine(
log.suspension_date, datetime.time.min
).replace(tzinfo=ZoneInfo("UTC")),
"response_due_date": log.response_due_date,
"update_comment": "Suspension de l’instruction, message envoyé au demandeur.",
},
)
if log.info_receipt_date:
# this log object is storing a resumption, we add an entry
entries.insert(
0,
{
"type": "resumption",
"type_display": TYPE_LABELS["resumption"],
"created_by": (
""
if not log.resumed_by
else (
log.resumed_by.email
if not log.resumed_by.is_staff
else "Administrateur"
)
),
"created_at": datetime.datetime.combine(
log.info_receipt_date, datetime.time.min
).replace(tzinfo=ZoneInfo("UTC")),
"due_date": log.due_date,
"update_comment": "Reprise de l’instruction, date d'échéance ajustée.",
},
)
entries[0]["due_date"] = log.original_due_date

return entries

context = super().get_context_data(**kwargs)
paginator, page_obj, page_qs, has_other_pages = self.paginate_queryset(
self.object_list, self.paginate_by
)
logs = [entry for log in page_qs for entry in extract_entries(log)]
context.update(
{
"object_list": logs,
"paginator": paginator,
"page_obj": page_obj,
"is_paginated": paginator.num_pages > 1,
"STAGES": STAGES,
"DECISIONS": DECISIONS,
}
)

# Request for additional information is only relevant when the project is
# in the "instruction" phase
if self.has_change_permission(
self.request, self.object
) and obj.current_stage.startswith("instruction"):
) and self.object.current_stage.startswith("instruction"):
request_info_form = RequestAdditionalInfoForm()
resume_processing_form = ResumeProcessingForm()
context.update(
Expand Down Expand Up @@ -1378,6 +1454,7 @@ def notify_admin():
self.request,
reference=self.object.reference,
etape_i=previous_stage,
department=self.object.get_department_code(),
etape_f=log.stage,
decision_i=previous_decision,
decision_f=log.decision,
Expand Down Expand Up @@ -1511,6 +1588,7 @@ def resume_form_valid(self, form):
status.due_date = status.original_due_date + interruption_days
else:
status.due_date = None
status.resumed_by = self.request.user
status.save()

# Send Mattermost notification
Expand Down
15 changes: 8 additions & 7 deletions envergo/static/sass/project_haie.scss
Original file line number Diff line number Diff line change
Expand Up @@ -917,15 +917,16 @@ div.title-with-link-container {
}

td {
height: 72px;
padding-left: 0.5rem;
padding-right: 0.5rem;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

a {
@supports (selector(:has(*))) {
&:has(.more.fr-collapse--expanded) .ellipsis {
display: none;
}
}

a,
button {
font-size: 0.875rem;
}
}
Expand Down
Loading
Loading