From 30cbca4afa08a7bf3d46f405ef2b82d695c00eb1 Mon Sep 17 00:00:00 2001 From: Anto59290 Date: Fri, 21 Jun 2024 15:19:51 +0200 Subject: [PATCH] Ajoute une gestion basique des documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ce commit permet d'introduire une gestion basique des documents dans l'application. Les documents sont des objets qui portent les informations sur un fichier ajouté dans le processus de gestion des événements. Pour le moment la seule implémentation est faite dans les fiches détection, mais le but est que le concept puisse être ré-utilisé au travers de toute l'application. Pour la gestion des fichiers l'ajout de la bibliothéque django-storages a été nécessaire. A défaut d'avoir l'infra de prête j'ai utilisé minio en local pour simuler un bucket, j'ai ajouté les instructions pour que tout le monde puisse reproduire le processus si nécessaire. J'ai aussi ajouté pytest-env afin de pouvoir configurer simplement des variables d'env différentes pour les tests. Les fonctionnalités implémentées dans ce commit sont: - La gestion basique des documents (création du modèle, admin, etc.) - En tant qu'utilisateur je peut uploader un document avec les informations nécessaires. - En tant qu'utilisateur je peux supprimer (soft-delete) un document après confirmation. - En tant qu'utilisateur, je voit les documents liés a une fiche sur la fiche en question. Les points qui pourraient être améliorés sont: - Implémentation d'un tri pour les documents - Ajout de tous les types de documents - Lien entre un document et un utilisateur (qui a ajouté, qui a supprimé) --- .env.dist | 7 +++ README.md | 11 ++++ core/admin.py | 5 ++ core/forms.py | 31 ++++++++++ core/migrations/0006_document.py | 61 +++++++++++++++++++ core/mixins.py | 22 +++++++ core/models.py | 26 ++++++++ .../core/_carte_resume_document.html | 20 ++++++ core/templates/core/_documents.html | 16 +++++ core/templates/core/_fiche_bloc_commun.html | 24 ++++++++ .../core/_modale_ajout_document.html | 28 +++++++++ .../core/_modale_suppression_document.html | 30 +++++++++ core/urls.py | 15 +++++ core/views.py | 42 +++++++++++++ pytest.ini | 8 ++- requirements.in | 2 + requirements.txt | 23 +++++++ seves/settings.py | 23 +++++++ seves/urls.py | 1 + sv/models.py | 9 +++ sv/static/core/bloc_commun.css | 27 ++++++++ sv/templates/sv/base.html | 1 + sv/templates/sv/fichedetection_detail.html | 1 + sv/tests/test_fichedetection_documents.py | 55 +++++++++++++++++ sv/views.py | 7 ++- 25 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 core/admin.py create mode 100644 core/migrations/0006_document.py create mode 100644 core/mixins.py create mode 100644 core/templates/core/_carte_resume_document.html create mode 100644 core/templates/core/_documents.html create mode 100644 core/templates/core/_fiche_bloc_commun.html create mode 100644 core/templates/core/_modale_ajout_document.html create mode 100644 core/templates/core/_modale_suppression_document.html create mode 100644 core/urls.py create mode 100644 core/views.py create mode 100644 sv/static/core/bloc_commun.css create mode 100644 sv/tests/test_fichedetection_documents.py diff --git a/.env.dist b/.env.dist index 3a32c380..bb11ea38 100644 --- a/.env.dist +++ b/.env.dist @@ -6,3 +6,10 @@ SECRET_KEY=secret # DB DATABASE_URL=psql://user:password@127.0.0.1:8458/database_name + +# Storage +STORAGE_ENGINE="django.core.files.storage.FileSystemStorage" +STORAGE_BUCKET_NAME="dev" +STORAGE_ACCESS_KEY="XXX" +STORAGE_SECRET_KEY="XXX" +STORAGE_URL="XXX" diff --git a/README.md b/README.md index 2ee702c1..2422e00e 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,18 @@ Pour ajouter une nouvelle dépendance au projet : - executez la commande `pip-compile` (pour mettre à à jour le fichier `requirements.txt`) - executez la commande `pip-sync` (installation de la nouvelle dépendance) +# Travailler avec un service S3 local +Suivre [la documentation de minio](https://hub.docker.com/r/minio/minio) sur le hub docker, en résumé pour avoir le stockage persistent: + +```bash +sudo mkdir /mnt/data +sudo chown votre_user:votre_groupe /mnt/data/ +podman run -v /mnt/data:/data -p 9000:9000 -p 9001:9001 quay.io/minio/minio server /data --console-address ":9001" +``` + +Une fois dans la console Web de minio vous pouvez vous créer une clé d'accès ainsi qu'un bucket en local. +Configurez ensuite votre fichier .env avec `STORAGE_ENGINE="storages.backends.s3.S3Storage"` et les tokens d'authentification (cf exemple dans .env.dist). # Tests ## E2E diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 00000000..22f0844c --- /dev/null +++ b/core/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin +from .models import Document + + +admin.site.register(Document) diff --git a/core/forms.py b/core/forms.py index 73e38308..3d5557c8 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,3 +1,6 @@ +from django.contrib.contenttypes.models import ContentType + +from core.models import Document from django import forms from collections import defaultdict @@ -17,3 +20,31 @@ def __init__(self, *args, **kwargs): widget = self.fields[field].widget class_to_add = self.input_to_class[type(widget).__name__] widget.attrs["class"] = widget.attrs.get("class", "") + class_to_add + + +class DocumentUploadForm(DSFRForm, forms.ModelForm): + nom = forms.CharField( + help_text="Nommer le document de manière claire et compréhensible pour tous", label="Intitulé du document" + ) + document_type = forms.ChoiceField(choices=Document.DOCUMENT_TYPE_CHOICES, label="Type de document") + description = forms.CharField( + widget=forms.Textarea(attrs={"cols": 30, "rows": 4}), label="Description - facultatif", required=False + ) + file = forms.FileField(label="Ajouter un Document") + + class Meta: + model = Document + fields = ["nom", "document_type", "description", "file", "content_type", "object_id"] + + def __init__(self, *args, **kwargs): + obj = kwargs.pop("obj", None) + next = kwargs.pop("next", None) + super().__init__(*args, **kwargs) + if obj: + self.fields["content_type"].widget = forms.HiddenInput() + self.fields["object_id"].widget = forms.HiddenInput() + self.initial["content_type"] = ContentType.objects.get_for_model(obj) + self.initial["object_id"] = obj.pk + if next: + self.fields["next"] = forms.CharField(widget=forms.HiddenInput()) + self.initial["next"] = next diff --git a/core/migrations/0006_document.py b/core/migrations/0006_document.py new file mode 100644 index 00000000..70fdbc1b --- /dev/null +++ b/core/migrations/0006_document.py @@ -0,0 +1,61 @@ +# Generated by Django 5.0.3 on 2024-07-02 06:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("core", "0005_delete_structurehierarchique"), + ] + + operations = [ + migrations.CreateModel( + name="Document", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("nom", models.CharField(max_length=256)), + ("description", models.TextField()), + ( + "document_type", + models.CharField( + choices=[("autre", "Autre document")], max_length=100 + ), + ), + ("file", models.FileField(upload_to="")), + ( + "date_creation", + models.DateTimeField( + auto_now_add=True, verbose_name="Date de création" + ), + ), + ("is_deleted", models.BooleanField(default=False)), + ("object_id", models.PositiveIntegerField()), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="contenttypes.contenttype", + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["content_type", "object_id"], + name="core_docume_content_343c2e_idx", + ) + ], + }, + ), + ] diff --git a/core/mixins.py b/core/mixins.py new file mode 100644 index 00000000..c5d339fd --- /dev/null +++ b/core/mixins.py @@ -0,0 +1,22 @@ +from core.forms import DocumentUploadForm + + +class WithDocumentUploadFormMixin: + def get_object_linked_to_document(self): + raise NotImplementedError + + def get_redirect_url_after_upload(self): + raise NotImplementedError + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + obj = self.get_object_linked_to_document() + context["document_form"] = DocumentUploadForm(obj=obj, next=obj.get_absolute_url()) + return context + + +class WithDocumentListInContextMixin: + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["document_list"] = self.get_object().documents.all() + return context diff --git a/core/models.py b/core/models.py index 0b7c5461..9d083834 100644 --- a/core/models.py +++ b/core/models.py @@ -1,4 +1,6 @@ from django.db import models +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType class Contact(models.Model): @@ -18,3 +20,27 @@ class Meta: complement_fonction = models.TextField(blank=True) telephone = models.CharField(max_length=20, blank=True) mobile = models.CharField(max_length=20, blank=True) + + +class Document(models.Model): + DOCUMENT_AUTRE = "autre" + DOCUMENT_TYPE_CHOICES = ((DOCUMENT_AUTRE, "Autre document"),) + + nom = models.CharField(max_length=256) + description = models.TextField() + document_type = models.CharField(max_length=100, choices=DOCUMENT_TYPE_CHOICES) + file = models.FileField(upload_to="") + date_creation = models.DateTimeField(auto_now_add=True, verbose_name="Date de création") + is_deleted = models.BooleanField(default=False) + + content_type = models.ForeignKey(ContentType, on_delete=models.PROTECT) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + + class Meta: + indexes = [ + models.Index(fields=["content_type", "object_id"]), + ] + + def __str__(self): + return f"{self.nom} ({self.document_type})" diff --git a/core/templates/core/_carte_resume_document.html b/core/templates/core/_carte_resume_document.html new file mode 100644 index 00000000..2871e186 --- /dev/null +++ b/core/templates/core/_carte_resume_document.html @@ -0,0 +1,20 @@ +
+
+
+

Ajouté le {{ document.date_creation }}

+
{{ document.nom }}
+

{{ document.description | truncatechars:150 | default:"Pas de description" }}

+

{{document.document_type|title}}

+ + {% if document.is_deleted %} + Document supprimé + {% else %} +
+ + +
+ {% include "core/_modale_suppression_document.html" %} + {% endif %} +
+
+
\ No newline at end of file diff --git a/core/templates/core/_documents.html b/core/templates/core/_documents.html new file mode 100644 index 00000000..e434881f --- /dev/null +++ b/core/templates/core/_documents.html @@ -0,0 +1,16 @@ +
+ +
+ +{% include "core/_modale_ajout_document.html" %} +
+
+ {% for document in document_list %} +
+ {% include "core/_carte_resume_document.html" %} +
+ {% endfor %} +
+
diff --git a/core/templates/core/_fiche_bloc_commun.html b/core/templates/core/_fiche_bloc_commun.html new file mode 100644 index 00000000..42e28736 --- /dev/null +++ b/core/templates/core/_fiche_bloc_commun.html @@ -0,0 +1,24 @@ + +
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+ {% include "core/_documents.html" %} +
+
+
+ diff --git a/core/templates/core/_modale_ajout_document.html b/core/templates/core/_modale_ajout_document.html new file mode 100644 index 00000000..b3dc5ee3 --- /dev/null +++ b/core/templates/core/_modale_ajout_document.html @@ -0,0 +1,28 @@ + +
+
+
+
+
+ {% csrf_token %} + +
+ +
+

Ajouter un document

+ + {{ document_form.as_dsfr_div }} + +
+ +
+
+
+
+
+
\ No newline at end of file diff --git a/core/templates/core/_modale_suppression_document.html b/core/templates/core/_modale_suppression_document.html new file mode 100644 index 00000000..2496f811 --- /dev/null +++ b/core/templates/core/_modale_suppression_document.html @@ -0,0 +1,30 @@ + +
+
+
+
+
+ +
+

Supprimer un document

+ Voulez-vous supprimer le document {{ document.nom }} ? +
+ + + +
+
+
+
+
\ No newline at end of file diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 00000000..081a3e1a --- /dev/null +++ b/core/urls.py @@ -0,0 +1,15 @@ +from django.urls import path +from .views import DocumentUploadView, DocumentDeleteView + +urlpatterns = [ + path( + "document-upload/", + DocumentUploadView.as_view(), + name="document-upload", + ), + path( + "document-delete//", + DocumentDeleteView.as_view(), + name="document-delete", + ), +] diff --git a/core/views.py b/core/views.py new file mode 100644 index 00000000..732de1ca --- /dev/null +++ b/core/views.py @@ -0,0 +1,42 @@ +from django.conf import settings +from django.contrib import messages +from django.shortcuts import get_object_or_404 +from django.views import View +from django.views.generic.edit import FormView +from .forms import DocumentUploadForm +from django.http import HttpResponseRedirect +from django.utils.http import url_has_allowed_host_and_scheme + +from .models import Document + + +class DocumentUploadView(FormView): + form_class = DocumentUploadForm + + def _get_redirect(self): + if url_has_allowed_host_and_scheme( + url=self.request.POST.get("next"), + allowed_hosts={self.request.get_host()}, + require_https=self.request.is_secure(), + ): + return HttpResponseRedirect(self.request.POST.get("next")) + return HttpResponseRedirect(settings.LOGIN_REDIRECT_URL) + + def post(self, request, *args, **kwargs): + form = DocumentUploadForm(request.POST, request.FILES) + if form.is_valid(): + form.save() + messages.success(request, "Le document a été ajouté avec succés.") + return self._get_redirect() + + messages.error(request, "Une erreur s'est produite lors de l'ajout du document") + return self._get_redirect() + + +class DocumentDeleteView(View): + def post(self, request, *args, **kwargs): + document = get_object_or_404(Document, pk=kwargs.get("pk")) + document.is_deleted = True + document.save() + messages.success(request, "Le document a été marqué comme supprimé.") + return HttpResponseRedirect(request.POST.get("next")) diff --git a/pytest.ini b/pytest.ini index e243ab3e..e9163a39 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,10 @@ [pytest] DJANGO_SETTINGS_MODULE = seves.settings python_files = test_*.py -addopts = -nauto \ No newline at end of file +addopts = -nauto +env = + STORAGE_ENGINE=django.core.files.storage.FileSystemStorage + STORAGE_BUCKET_NAME= + STORAGE_ACCESS_KEY= + STORAGE_SECRET_KEY= + STORAGE_URL= \ No newline at end of file diff --git a/requirements.in b/requirements.in index f55ae65f..c40d5b0c 100644 --- a/requirements.in +++ b/requirements.in @@ -7,8 +7,10 @@ playwright ruff pre-commit pytest-django +pytest-env pytest-xdist pytest-playwright djhtml model-bakery sentry-sdk[django] +django-storages[s3] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 51de5edd..455e0022 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,12 @@ # asgiref==3.7.2 # via django +boto3==1.34.134 + # via django-storages +botocore==1.34.134 + # via + # boto3 + # s3transfer certifi==2024.2.2 # via # requests @@ -21,10 +27,13 @@ dj-static==0.0.6 django==5.0.3 # via # -r requirements.in + # django-storages # model-bakery # sentry-sdk django-environ==0.11.2 # via -r requirements.in +django-storages[s3]==1.14.3 + # via -r requirements.in djhtml==3.0.6 # via -r requirements.in execnet==2.1.1 @@ -41,6 +50,10 @@ idna==3.7 # via requests iniconfig==2.0.0 # via pytest +jmespath==1.0.1 + # via + # boto3 + # botocore model-bakery==1.18.0 # via -r requirements.in nodeenv==1.8.0 @@ -67,16 +80,21 @@ pytest==8.1.1 # via # pytest-base-url # pytest-django + # pytest-env # pytest-playwright # pytest-xdist pytest-base-url==2.1.0 # via pytest-playwright pytest-django==4.8.0 # via -r requirements.in +pytest-env==1.1.3 + # via -r requirements.in pytest-playwright==0.4.4 # via -r requirements.in pytest-xdist==3.5.0 # via -r requirements.in +python-dateutil==2.9.0.post0 + # via botocore python-slugify==8.0.4 # via pytest-playwright pyyaml==6.0.1 @@ -85,8 +103,12 @@ requests==2.31.0 # via pytest-base-url ruff==0.4.1 # via -r requirements.in +s3transfer==0.10.2 + # via boto3 sentry-sdk[django]==2.5.1 # via -r requirements.in +six==1.16.0 + # via python-dateutil sqlparse==0.4.4 # via django static3==0.7.0 @@ -97,6 +119,7 @@ typing-extensions==4.11.0 # via pyee urllib3==2.2.1 # via + # botocore # requests # sentry-sdk virtualenv==20.25.3 diff --git a/seves/settings.py b/seves/settings.py index 3f741fcd..955ca3c0 100644 --- a/seves/settings.py +++ b/seves/settings.py @@ -148,3 +148,26 @@ integrations=[DjangoIntegration()], traces_sample_rate=1.0, ) + + +STORAGES = { + "default": {"BACKEND": env("STORAGE_ENGINE")}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} + +if all( + [ + env("STORAGE_BUCKET_NAME", default=None), + env("STORAGE_ACCESS_KEY", default=None), + env("STORAGE_SECRET_KEY", default=None), + env("STORAGE_URL", default=None), + ] +): + STORAGES["default"]["OPTIONS"] = { + "bucket_name": env("STORAGE_BUCKET_NAME", default=None), + "access_key": env("STORAGE_ACCESS_KEY", default=None), + "secret_key": env("STORAGE_SECRET_KEY", default=None), + "endpoint_url": env("STORAGE_URL", default=None), + } diff --git a/seves/urls.py b/seves/urls.py index edc6d5cc..38f67098 100644 --- a/seves/urls.py +++ b/seves/urls.py @@ -28,4 +28,5 @@ path("", RedirectView.as_view(pattern_name="fiche-detection-list"), name="index"), path("admin/", admin.site.urls), path("sv/", include("sv.urls"), name="sv-index"), + path("core/", include("core.urls"), name="core"), ] diff --git a/sv/models.py b/sv/models.py index ede40556..092e43f7 100644 --- a/sv/models.py +++ b/sv/models.py @@ -2,6 +2,11 @@ from django.core.validators import RegexValidator import datetime +from django.contrib.contenttypes.fields import GenericRelation +from django.urls import reverse + +from core.models import Document + class NumeroFiche(models.Model): class Meta: @@ -454,3 +459,7 @@ class Meta: Etat, on_delete=models.PROTECT, verbose_name="État de la fiche", default=Etat.get_etat_initial ) date_creation = models.DateTimeField(auto_now_add=True, verbose_name="Date de création") + documents = GenericRelation(Document) + + def get_absolute_url(self): + return reverse("fiche-detection-vue-detaillee", kwargs={"pk": self.pk}) diff --git a/sv/static/core/bloc_commun.css b/sv/static/core/bloc_commun.css new file mode 100644 index 00000000..18d5fec9 --- /dev/null +++ b/sv/static/core/bloc_commun.css @@ -0,0 +1,27 @@ +.bloc-commun-gestion .fr-tabs__tab:not([aria-selected="true"]){ + background-color: var(--background-contrast-grey); + box-shadow: 0 2px 0 0 var(--background-open-blue-france); + color: var(--text-action-high-grey); +} + +.bloc-commun-gestion .fr-tabs__tab[aria-selected="true"]{ + background-color: var(--background-open-blue-france); + box-shadow: 0 2px 0 0 var(--background-open-blue-france); + color: var(--background-active-blue-france); +} +.bloc-commun-gestion .fr-tabs__panel{ + background-color: var(--background-open-blue-france); +} + +.document__details--deleted{ + background-color: var(--background-disabled-grey); +} +.document__details .fr-card__desc{ + order: initial; +} +.document__actions a[target="_blank"]::after { + content: none; +} +.document__actions a { + background-image: none; +} diff --git a/sv/templates/sv/base.html b/sv/templates/sv/base.html index 80c34126..f9b79864 100644 --- a/sv/templates/sv/base.html +++ b/sv/templates/sv/base.html @@ -16,6 +16,7 @@ Sèves + {% block extrahead %}{% endblock %} diff --git a/sv/templates/sv/fichedetection_detail.html b/sv/templates/sv/fichedetection_detail.html index 5aa8d247..463274e7 100644 --- a/sv/templates/sv/fichedetection_detail.html +++ b/sv/templates/sv/fichedetection_detail.html @@ -209,6 +209,7 @@

Mesures de gestion

{{ fichedetection.mesures_conservatoires_immediates|default:"nc." }}

+ {% include "core/_fiche_bloc_commun.html" with redirect_url=fichedetection.get_absolute_url %} {% endblock %} diff --git a/sv/tests/test_fichedetection_documents.py b/sv/tests/test_fichedetection_documents.py new file mode 100644 index 00000000..fd490981 --- /dev/null +++ b/sv/tests/test_fichedetection_documents.py @@ -0,0 +1,55 @@ +from model_bakery import baker +from playwright.sync_api import Page, expect + +from core.models import Document +from ..models import FicheDetection + + +def test_can_add_document_to_fiche_detection(live_server, page: Page, fiche_detection: FicheDetection): + page.goto(f"{live_server.url}{fiche_detection.get_absolute_url()}") + page.get_by_test_id("documents").click() + expect(page.get_by_test_id("documents-add")).to_be_visible() + page.get_by_test_id("documents-add").click() + + expect(page.locator("#fr-modal-add-doc")).to_be_visible() + + page.locator("#id_nom").fill("Name of the document") + page.locator("#id_document_type").select_option("autre") + page.locator("#id_description").fill("Description") + page.locator("#id_file").set_input_files("README.md") + page.get_by_test_id("documents-send").click() + + page.wait_for_timeout(200) + assert fiche_detection.documents.count() == 1 + document = fiche_detection.documents.get() + + assert document.document_type == "autre" + assert document.nom == "Name of the document" + assert document.description == "Description" + + # Check the document is now listed on the page + page.get_by_test_id("documents").click() + expect(page.get_by_text("Name of the document", exact=True)).to_be_visible() + + +def test_can_see_and_delete_document_on_fiche_detection(live_server, page: Page, fiche_detection: FicheDetection): + document = baker.make(Document, nom="Test document", _create_files=True) + fiche_detection.documents.set([document]) + assert fiche_detection.documents.count() == 1 + + page.goto(f"{live_server.url}{fiche_detection.get_absolute_url()}") + page.get_by_test_id("documents").click() + expect(page.get_by_role("heading", name="Test document")).to_be_visible() + + page.locator(f'a[aria-controls="fr-modal-{document.id}"]').click() + expect(page.locator(f"#fr-modal-{document.id}")).to_be_visible() + page.get_by_test_id(f"documents-delete-{document.id}").click() + + page.wait_for_timeout(200) + document = fiche_detection.documents.get() + assert document.is_deleted is True + + # Document is still displayed + page.get_by_test_id("documents").click() + expect(page.get_by_text("Test document")).to_be_visible() + expect(page.get_by_text("Document supprimé")).to_be_visible() diff --git a/sv/views.py b/sv/views.py index eb796f2e..68424159 100644 --- a/sv/views.py +++ b/sv/views.py @@ -17,6 +17,8 @@ from django.core.exceptions import ValidationError from django.contrib import messages from django import forms + +from core.mixins import WithDocumentUploadFormMixin, WithDocumentListInContextMixin from .models import ( FicheDetection, Lieu, @@ -118,9 +120,12 @@ def get_context_data(self, **kwargs): return context -class FicheDetectionDetailView(DetailView): +class FicheDetectionDetailView(WithDocumentListInContextMixin, WithDocumentUploadFormMixin, DetailView): model = FicheDetection + def get_object_linked_to_document(self): + return self.get_object() + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs)