Skip to content

Commit

Permalink
Ajoute une gestion basique des documents
Browse files Browse the repository at this point in the history
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é)
  • Loading branch information
Anto59290 committed Jul 2, 2024
1 parent b712ea2 commit 8f2fa2b
Show file tree
Hide file tree
Showing 24 changed files with 432 additions and 2 deletions.
7 changes: 7 additions & 0 deletions .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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"
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions core/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.contrib import admin
from .models import Document


admin.site.register(Document)
31 changes: 31 additions & 0 deletions core/forms.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from django.contrib.contenttypes.models import ContentType

from core.models import Document
from django import forms
from collections import defaultdict

Expand All @@ -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
22 changes: 22 additions & 0 deletions core/mixins.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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})"
20 changes: 20 additions & 0 deletions core/templates/core/_carte_resume_document.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<div class="fr-card">
<div class="fr-card__body">
<div class="fr-card__content document__details {% if document.is_deleted %}document__details--deleted{% endif %}">
<p class="fr-hint-text">Ajouté le {{ document.date_creation }}</p>
<h6 class="fr-h6">{{ document.nom }}</h6>
<p class="fr-card__dess fr-mb-1w">{{ document.description | truncatechars:150 | default:"Pas de description" }}</p>
<p class="fr-tag fr-my-1w">{{document.document_type|title}}</p>

{% if document.is_deleted %}
Document supprimé
{% else %}
<div class="fr-btns-group--right document__actions">
<a href="{{ document.file.url }}" target="_blank" class="fr-icon-download-line fr-btn fr-btn--tertiary fr-mr-1w"></a>
<a href="#" class="fr-icon-delete-line fr-btn fr-btn--tertiary" data-fr-opened="false" aria-controls="fr-modal-{{ document.pk }}"></a>
</div>
{% include "core/_modale_suppression_document.html" %}
{% endif %}
</div>
</div>
</div>
16 changes: 16 additions & 0 deletions core/templates/core/_documents.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<div class="fr-btns-group--right fr-mb-2w">
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-add-circle-line" data-testid="documents-add" data-fr-opened="false" aria-controls="fr-modal-add-doc">
Ajouter un document
</button>
</div>

{% include "core/_modale_ajout_document.html" %}
<div class="fr-container--fluid">
<div class="fr-grid-row fr-grid-row--gutters">
{% for document in document_list %}
<div class="fr-col-3 fr-col-xl-2">
{% include "core/_carte_resume_document.html" %}
</div>
{% endfor %}
</div>
</div>
24 changes: 24 additions & 0 deletions core/templates/core/_fiche_bloc_commun.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

<div class="bloc-commun-gestion">
<div class="fr-tabs fr-mt-2w">
<ul class="fr-tabs__list" role="tablist">
<li role="presentation">
<button id="tabpanel-404" class="fr-tabs__tab fr-icon-checkbox-line fr-tabs__tab--icon-left" tabindex="0" role="tab" aria-selected="true" aria-controls="tabpanel-404-panel">Fil de suivi</button>
</li>
<li role="presentation">
<button id="tabpanel-405" class="fr-tabs__tab fr-icon-checkbox-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-selected="false" aria-controls="tabpanel-405-panel">Contacts</button>
</li>
<li role="presentation">
<button id="tabpanel-406" class="fr-tabs__tab fr-icon-checkbox-line fr-tabs__tab--icon-left" tabindex="-1" role="tab" aria-selected="false" aria-controls="tabpanel-406-panel" data-testid="documents">Documents</button>
</li>
</ul>
<div id="tabpanel-404-panel" class="fr-tabs__panel fr-tabs__panel--selected" role="tabpanel" aria-labelledby="tabpanel-404" tabindex="0">
</div>
<div id="tabpanel-405-panel" class="fr-tabs__panel" role="tabpanel" aria-labelledby="tabpanel-405" tabindex="0">
</div>
<div id="tabpanel-406-panel" class="fr-tabs__panel" role="tabpanel" aria-labelledby="tabpanel-406" tabindex="0">
{% include "core/_documents.html" %}
</div>
</div>
</div>

28 changes: 28 additions & 0 deletions core/templates/core/_modale_ajout_document.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<dialog aria-labelledby="fr-modal-add-doc" id="fr-modal-add-doc" class="fr-modal" role="dialog">
<div class="fr-container fr-container--fluid fr-container-md">
<div class="fr-grid-row fr-grid-row--center">
<div class="fr-col-12 fr-col-md-8 fr-col-lg-6">
<div class="fr-modal__body">
<form method="post" action="{% url 'document-upload' %}" enctype="multipart/form-data">
{% csrf_token %}

<div class="fr-modal__header">
<button class="fr-btn--close fr-btn" aria-controls="fr-modal-add-doc">Fermer</button>
</div>
<div class="fr-modal__content"><h1 class="fr-modal__title"><span
class="fr-icon-arrow-right-line fr-icon--lg"></span>Ajouter un document</h1>

{{ document_form.as_dsfr_div }}

</div>
<div class="fr-modal__footer">
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline-lg">
<button type="submit" class="fr-btn" data-testid="documents-send">Envoyer</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</dialog>
30 changes: 30 additions & 0 deletions core/templates/core/_modale_suppression_document.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<dialog aria-labelledby="fr-modal-{{ document.pk }}-title" id="fr-modal-{{ document.pk }}" class="fr-modal" role="dialog">
<div class="fr-container fr-container--fluid fr-container-md">
<div class="fr-grid-row fr-grid-row--center">
<div class="fr-col-12 fr-col-md-8 fr-col-lg-6">
<div class="fr-modal__body">
<div class="fr-modal__header">
<button class="fr-btn--close fr-btn" aria-controls="fr-modal-{{ document.pk }}">Fermer</button>
</div>
<div class="fr-modal__content"><h1 class="fr-modal__title"><span
class="fr-icon-arrow-right-line fr-icon--lg"></span>Supprimer un document</h1>
Voulez-vous supprimer le document {{ document.nom }} ?
</div>

<div class="fr-modal__footer">
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline-lg">
<button class="fr-btn fr-btn--secondary" aria-controls="fr-modal-{{ document.pk }}">Annuler</button>
<form action="{% url 'document-delete' document.pk %}" method="POST">
{% csrf_token %}
<input type="hidden" value="{{ document.pk }}" name="pk">
<input type="hidden" value="{{ redirect_url }}" name="next">
<button type="submit" class="fr-btn" data-testid="documents-delete-{{ document.pk }}">Supprimer le document</button>
</form>
</div>
</div>

</div>
</div>
</div>
</div>
</dialog>
15 changes: 15 additions & 0 deletions core/urls.py
Original file line number Diff line number Diff line change
@@ -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/<int:pk>/",
DocumentDeleteView.as_view(),
name="document-delete",
),
]
42 changes: 42 additions & 0 deletions core/views.py
Original file line number Diff line number Diff line change
@@ -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"))
8 changes: 7 additions & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
[pytest]
DJANGO_SETTINGS_MODULE = seves.settings
python_files = test_*.py
addopts = -nauto
addopts = -nauto
env =
STORAGE_ENGINE=django.core.files.storage.FileSystemStorage
STORAGE_BUCKET_NAME=
STORAGE_ACCESS_KEY=
STORAGE_SECRET_KEY=
STORAGE_URL=
2 changes: 2 additions & 0 deletions requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading

0 comments on commit 8f2fa2b

Please sign in to comment.