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.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 %} +{{ 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)