diff --git a/.env.dist b/.env.dist index 3a32c38..bb11ea3 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 2ee702c..2422e00 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 0000000..22f0844 --- /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 73e3830..3d5557c 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/mixins.py b/core/mixins.py new file mode 100644 index 0000000..c5d339f --- /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 0b7c546..9d08383 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 0000000..2871e18 --- /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 0000000..e434881 --- /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 0000000..42e2873 --- /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 0000000..b3dc5ee --- /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 0000000..2496f81 --- /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 0000000..081a3e1 --- /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 0000000..732de1c --- /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 e243ab3..e9163a3 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 f55ae65..c40d5b0 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 51de5ed..455e002 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 3f741fc..955ca3c 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 edc6d5c..38f6709 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 ede4055..092e43f 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 0000000..18d5fec --- /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 80c3412..f9b7986 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 5aa8d24..463274e 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 0000000..fd49098 --- /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 eb796f2..6842415 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)