From 7780b3b807253b554e9b46dfeb6683db6259cf0e Mon Sep 17 00:00:00 2001 From: Julian B Date: Sun, 22 Sep 2024 00:05:57 +0200 Subject: [PATCH 1/4] add files app, add models, add view to serve files --- .dockerignore | 4 ++- deployment/compose/nginx.conf | 5 ++++ ephios/plugins/files/__init__.py | 0 ephios/plugins/files/admin.py | 5 ++++ ephios/plugins/files/apps.py | 15 +++++++++++ .../plugins/files/migrations/0001_initial.py | 26 +++++++++++++++++++ ephios/plugins/files/migrations/__init__.py | 0 ephios/plugins/files/models.py | 9 +++++++ ephios/plugins/files/signals.py | 0 ephios/plugins/files/urls.py | 8 ++++++ ephios/plugins/files/views.py | 20 ++++++++++++++ ephios/settings.py | 2 ++ 12 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 ephios/plugins/files/__init__.py create mode 100644 ephios/plugins/files/admin.py create mode 100644 ephios/plugins/files/apps.py create mode 100644 ephios/plugins/files/migrations/0001_initial.py create mode 100644 ephios/plugins/files/migrations/__init__.py create mode 100644 ephios/plugins/files/models.py create mode 100644 ephios/plugins/files/signals.py create mode 100644 ephios/plugins/files/urls.py create mode 100644 ephios/plugins/files/views.py diff --git a/.dockerignore b/.dockerignore index e980b6d7c..1e4723614 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,4 +9,6 @@ build .env .idea .env.example - +ENV +.venv +venv diff --git a/deployment/compose/nginx.conf b/deployment/compose/nginx.conf index 0be1bc301..0dd12a5c6 100644 --- a/deployment/compose/nginx.conf +++ b/deployment/compose/nginx.conf @@ -16,4 +16,9 @@ server { expires 1d; add_header Cache-Control "public"; } + + location /usercontent/ { + internal; + alias /var/ephios/data/private/media/; + } } diff --git a/ephios/plugins/files/__init__.py b/ephios/plugins/files/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/files/admin.py b/ephios/plugins/files/admin.py new file mode 100644 index 000000000..568ace12f --- /dev/null +++ b/ephios/plugins/files/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from ephios.plugins.files.models import Document + +admin.site.register(Document) diff --git a/ephios/plugins/files/apps.py b/ephios/plugins/files/apps.py new file mode 100644 index 000000000..acb17cf11 --- /dev/null +++ b/ephios/plugins/files/apps.py @@ -0,0 +1,15 @@ +from django.utils.translation import gettext_lazy as _ + +from ephios.core.plugins import PluginConfig + + +class PluginApp(PluginConfig): + name = "ephios.plugins.files" + + class EphiosPluginMeta: + name = _("Files") + author = "Ephios Team" + description = _("This plugins allows you to upload files and link to them in events.") + + def ready(self): + from . import signals # pylint: disable=unused-import diff --git a/ephios/plugins/files/migrations/0001_initial.py b/ephios/plugins/files/migrations/0001_initial.py new file mode 100644 index 000000000..cad424828 --- /dev/null +++ b/ephios/plugins/files/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.8 on 2024-09-21 12:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Document", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("title", models.CharField(max_length=255)), + ("file", models.FileField(upload_to="documents/")), + ], + ), + ] diff --git a/ephios/plugins/files/migrations/__init__.py b/ephios/plugins/files/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/files/models.py b/ephios/plugins/files/models.py new file mode 100644 index 000000000..e57f0aa0e --- /dev/null +++ b/ephios/plugins/files/models.py @@ -0,0 +1,9 @@ +from django.db import models + + +class Document(models.Model): + title = models.CharField(max_length=255) + file = models.FileField(upload_to="documents/") + + def __str__(self): + return self.title diff --git a/ephios/plugins/files/signals.py b/ephios/plugins/files/signals.py new file mode 100644 index 000000000..e69de29bb diff --git a/ephios/plugins/files/urls.py b/ephios/plugins/files/urls.py new file mode 100644 index 000000000..c1e4ba375 --- /dev/null +++ b/ephios/plugins/files/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from ephios.plugins.files.views import DocumentView + +app_name = "files" +urlpatterns = [ + path("documents//", DocumentView.as_view(), name="document"), +] diff --git a/ephios/plugins/files/views.py b/ephios/plugins/files/views.py new file mode 100644 index 000000000..e2033c704 --- /dev/null +++ b/ephios/plugins/files/views.py @@ -0,0 +1,20 @@ +import os + +from django.conf import settings +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from django.views import View +from guardian.mixins import LoginRequiredMixin + +from ephios.plugins.files.models import Document + + +class DocumentView(View, LoginRequiredMixin): + def get(self, request, *args, **kwargs): + document = get_object_or_404(Document, id=kwargs["pk"]) + response = HttpResponse() + response["Content-Disposition"] = ( + "attachment; filename=" + os.path.split(document.file.name)[1] + ) + response["X-Accel-Redirect"] = settings.MEDIA_URL + document.file.name + return response diff --git a/ephios/settings.py b/ephios/settings.py index 0a341d38b..51866eef2 100644 --- a/ephios/settings.py +++ b/ephios/settings.py @@ -107,6 +107,7 @@ "ephios.plugins.eventautoqualification.apps.PluginApp", "ephios.plugins.simpleresource.apps.PluginApp", "ephios.plugins.federation.apps.PluginApp", + "ephios.plugins.files.apps.PluginApp", ] PLUGINS = copy.copy(CORE_PLUGINS) for ep in metadata.entry_points(group="ephios.plugins"): @@ -224,6 +225,7 @@ # https://docs.djangoproject.com/en/3.0/howto/static-files/ STATIC_URL = env.str("STATIC_URL", default="/static/") +MEDIA_URL = env.str("MEDIA_URL", default="/usercontent/") STATICFILES_DIRS = (os.path.join(BASE_DIR, "ephios/static"),) STATICFILES_FINDERS = ( From 98fc2f110c5ba382623bb54db8692bbcb255f4ab Mon Sep 17 00:00:00 2001 From: Julian B Date: Sun, 22 Sep 2024 13:33:47 +0200 Subject: [PATCH 2/4] add middleware to separate usercontent and app domain --- ephios/extra/middleware.py | 17 +++++++++++++++++ ephios/plugins/files/views.py | 9 ++++++--- ephios/settings.py | 5 +++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/ephios/extra/middleware.py b/ephios/extra/middleware.py index e814686c1..284ec4577 100644 --- a/ephios/extra/middleware.py +++ b/ephios/extra/middleware.py @@ -1,4 +1,7 @@ +from urllib.parse import urljoin, urlsplit + from django.conf import settings +from django.shortcuts import redirect from ephios.core.services.notifications.types import NOTIFICATION_READ_PARAM_NAME @@ -36,3 +39,17 @@ def __call__(self, request): except Notification.DoesNotExist: pass return response + + +class EphiosMediaFileMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + if ( + request.get_host() == urlsplit(settings.GET_USERCONTENT_URL()).netloc + and request.resolver_match.url_name != "document" + ): + return redirect(urljoin(settings.GET_SITE_URL(), request.path)) + return response diff --git a/ephios/plugins/files/views.py b/ephios/plugins/files/views.py index e2033c704..052edd187 100644 --- a/ephios/plugins/files/views.py +++ b/ephios/plugins/files/views.py @@ -1,20 +1,23 @@ import os +from urllib.parse import urlsplit from django.conf import settings from django.http import HttpResponse -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, redirect from django.views import View from guardian.mixins import LoginRequiredMixin from ephios.plugins.files.models import Document -class DocumentView(View, LoginRequiredMixin): +class DocumentView(LoginRequiredMixin, View): def get(self, request, *args, **kwargs): + if (loc := urlsplit(settings.GET_USERCONTENT_URL()).netloc) and request.get_host() != loc: + return redirect(settings.GET_USERCONTENT_URL() + request.path) document = get_object_or_404(Document, id=kwargs["pk"]) response = HttpResponse() response["Content-Disposition"] = ( "attachment; filename=" + os.path.split(document.file.name)[1] ) - response["X-Accel-Redirect"] = settings.MEDIA_URL + document.file.name + response["X-Accel-Redirect"] = document.file.url return response diff --git a/ephios/settings.py b/ephios/settings.py index 51866eef2..5ee6cfee1 100644 --- a/ephios/settings.py +++ b/ephios/settings.py @@ -125,6 +125,7 @@ "django.middleware.locale.LocaleMiddleware", "ephios.extra.middleware.EphiosLocaleMiddleware", "ephios.extra.middleware.EphiosNotificationMiddleware", + "ephios.extra.middleware.EphiosMediaFileMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", @@ -318,6 +319,10 @@ def GET_SITE_URL(): return site_url +def GET_USERCONTENT_URL(): + return MEDIA_URL + + # Guardian configuration ANONYMOUS_USER_NAME = None GUARDIAN_MONKEY_PATCH = False From f1ee9bd1acdf547e6867f23e3057ab9fa576ae5a Mon Sep 17 00:00:00 2001 From: Julian B Date: Sun, 22 Sep 2024 13:58:50 +0200 Subject: [PATCH 3/4] add fallback media serving --- ephios/plugins/files/views.py | 9 ++++++--- ephios/settings.py | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ephios/plugins/files/views.py b/ephios/plugins/files/views.py index 052edd187..7aa3b41f1 100644 --- a/ephios/plugins/files/views.py +++ b/ephios/plugins/files/views.py @@ -2,7 +2,7 @@ from urllib.parse import urlsplit from django.conf import settings -from django.http import HttpResponse +from django.http import FileResponse, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.views import View from guardian.mixins import LoginRequiredMixin @@ -15,9 +15,12 @@ def get(self, request, *args, **kwargs): if (loc := urlsplit(settings.GET_USERCONTENT_URL()).netloc) and request.get_host() != loc: return redirect(settings.GET_USERCONTENT_URL() + request.path) document = get_object_or_404(Document, id=kwargs["pk"]) - response = HttpResponse() + if settings.FALLBACK_MEDIA_SERVING: + response = FileResponse(document.file) + else: + response = HttpResponse() + response["X-Accel-Redirect"] = document.file.url response["Content-Disposition"] = ( "attachment; filename=" + os.path.split(document.file.name)[1] ) - response["X-Accel-Redirect"] = document.file.url return response diff --git a/ephios/settings.py b/ephios/settings.py index 5ee6cfee1..4906b6901 100644 --- a/ephios/settings.py +++ b/ephios/settings.py @@ -227,6 +227,7 @@ STATIC_URL = env.str("STATIC_URL", default="/static/") MEDIA_URL = env.str("MEDIA_URL", default="/usercontent/") +FALLBACK_MEDIA_SERVING = env.bool("FALLBACK_MEDIA_SERVING", default=False) STATICFILES_DIRS = (os.path.join(BASE_DIR, "ephios/static"),) STATICFILES_FINDERS = ( From d75dd01c998df5999187f2dc09e7d37b958e89c3 Mon Sep 17 00:00:00 2001 From: Julian B Date: Mon, 23 Sep 2024 21:07:02 +0200 Subject: [PATCH 4/4] add CRUD views --- ephios/plugins/files/forms.py | 39 ++++++++++++++++++ .../plugins/files/migrations/0001_initial.py | 16 ++++++-- ephios/plugins/files/models.py | 8 +++- ephios/plugins/files/signals.py | 41 +++++++++++++++++++ .../templates/files/document_attachement.html | 5 +++ .../files/document_confirm_delete.html | 22 ++++++++++ .../files/templates/files/document_form.html | 19 +++++++++ .../files/templates/files/document_list.html | 33 +++++++++++++++ ephios/plugins/files/urls.py | 24 ++++++++++- ephios/plugins/files/views.py | 34 +++++++++++++++ 10 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 ephios/plugins/files/forms.py create mode 100644 ephios/plugins/files/templates/files/document_attachement.html create mode 100644 ephios/plugins/files/templates/files/document_confirm_delete.html create mode 100644 ephios/plugins/files/templates/files/document_form.html create mode 100644 ephios/plugins/files/templates/files/document_list.html diff --git a/ephios/plugins/files/forms.py b/ephios/plugins/files/forms.py new file mode 100644 index 000000000..57b7daf63 --- /dev/null +++ b/ephios/plugins/files/forms.py @@ -0,0 +1,39 @@ +from django.forms import FileInput, Form, ModelForm, ModelMultipleChoiceField +from django.utils.translation import gettext as _ +from django_select2.forms import Select2MultipleWidget + +from ephios.core.forms.events import BasePluginFormMixin +from ephios.plugins.files.models import Document + + +class DocumentForm(ModelForm): + class Meta: + model = Document + fields = ["title", "file"] + widgets = {"file": FileInput(attrs={"accept": ".pdf"})} + + +class EventAttachedDocumentForm(BasePluginFormMixin, Form): + documents = ModelMultipleChoiceField( + queryset=Document.objects.all(), required=False, widget=Select2MultipleWidget + ) + + def __init__(self, *args, **kwargs): + kwargs.setdefault("prefix", "files") + self.event = kwargs.pop("event") + self.request = kwargs.pop("request") + super().__init__(*args, **kwargs) + self.fields["documents"].initial = ( + self.event.documents.all() if self.event and self.event.pk else [] + ) + + def save(self): + if self.cleaned_data["documents"]: + self.event.documents.set(self.cleaned_data["documents"]) + + @property + def heading(self): + return _("Attach files") + + def is_function_active(self): + return self.event and self.event.documents.exists() diff --git a/ephios/plugins/files/migrations/0001_initial.py b/ephios/plugins/files/migrations/0001_initial.py index cad424828..7fd793eb2 100644 --- a/ephios/plugins/files/migrations/0001_initial.py +++ b/ephios/plugins/files/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 5.0.8 on 2024-09-21 12:01 +# Generated by Django 5.0.8 on 2024-09-23 19:05 +import django.core.validators from django.db import migrations, models @@ -7,7 +8,9 @@ class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + ("core", "0029_alter_userprofile_date_of_birth"), + ] operations = [ migrations.CreateModel( @@ -20,7 +23,14 @@ class Migration(migrations.Migration): ), ), ("title", models.CharField(max_length=255)), - ("file", models.FileField(upload_to="documents/")), + ( + "file", + models.FileField( + upload_to="documents/", + validators=[django.core.validators.FileExtensionValidator(["pdf"])], + ), + ), + ("attached_to", models.ManyToManyField(related_name="documents", to="core.event")), ], ), ] diff --git a/ephios/plugins/files/models.py b/ephios/plugins/files/models.py index e57f0aa0e..381ed58e1 100644 --- a/ephios/plugins/files/models.py +++ b/ephios/plugins/files/models.py @@ -1,9 +1,13 @@ +from django.core.validators import FileExtensionValidator from django.db import models +from ephios.core.models import Event + class Document(models.Model): title = models.CharField(max_length=255) - file = models.FileField(upload_to="documents/") + file = models.FileField(upload_to="documents/", validators=[FileExtensionValidator(["pdf"])]) + attached_to = models.ManyToManyField(Event, related_name="documents") def __str__(self): - return self.title + return str(self.title) diff --git a/ephios/plugins/files/signals.py b/ephios/plugins/files/signals.py index e69de29bb..bc6a73746 100644 --- a/ephios/plugins/files/signals.py +++ b/ephios/plugins/files/signals.py @@ -0,0 +1,41 @@ +from django.dispatch import receiver +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils.translation import gettext as _ + +from ephios.core.signals import event_forms, event_info, management_settings_sections +from ephios.plugins.files.forms import EventAttachedDocumentForm + + +@receiver( + management_settings_sections, + dispatch_uid="ephios.plugins.pages.signals.files_settings_section", +) +def files_settings_section(sender, request, **kwargs): + return ( + [ + { + "label": _("Files"), + "url": reverse("files:settings_document_list"), + "active": request.resolver_match.url_name.startswith("settings_document"), + }, + ] + if request.user.has_perm("files.add_document") + else [] + ) + + +@receiver( + event_forms, + dispatch_uid="ephios.plugins.files.signals.files_event_forms", +) +def guests_event_forms(sender, event, request, **kwargs): + return [EventAttachedDocumentForm(request.POST or None, event=event, request=request)] + + +@receiver(event_info, dispatch_uid="ephios.plugins.files.signals.event_info") +def display_event_files(event, request, **kwargs): + if event.documents.exists(): + return render_to_string( + "files/document_attachement.html", {"documents": event.documents.all()}, request + ) diff --git a/ephios/plugins/files/templates/files/document_attachement.html b/ephios/plugins/files/templates/files/document_attachement.html new file mode 100644 index 000000000..e72dcaa7f --- /dev/null +++ b/ephios/plugins/files/templates/files/document_attachement.html @@ -0,0 +1,5 @@ +
+ {% for document in documents %} + {{ document.title }} + {% endfor %} +
diff --git a/ephios/plugins/files/templates/files/document_confirm_delete.html b/ephios/plugins/files/templates/files/document_confirm_delete.html new file mode 100644 index 000000000..41f84a64d --- /dev/null +++ b/ephios/plugins/files/templates/files/document_confirm_delete.html @@ -0,0 +1,22 @@ +{% extends "core/settings/settings_base.html" %} +{% load i18n %} +{% load static %} + +{% block title %} + {% translate "Delete file" %} +{% endblock %} + +{% block settings_content %} + + +
{% csrf_token %} +

{% blocktranslate trimmed with title=document.title %} + Are you sure you want to delete the file "{{ title }}"? + {% endblocktranslate %}

+ {% translate "Back" %} + +
+ +{% endblock %} diff --git a/ephios/plugins/files/templates/files/document_form.html b/ephios/plugins/files/templates/files/document_form.html new file mode 100644 index 000000000..8a2faaf00 --- /dev/null +++ b/ephios/plugins/files/templates/files/document_form.html @@ -0,0 +1,19 @@ +{% extends "core/settings/settings_base.html" %} +{% load crispy_forms_filters %} +{% load i18n %} + +{% block settings_content %} + + +
+ {% csrf_token %} + {{ form|crispy }} + +
+{% endblock %} diff --git a/ephios/plugins/files/templates/files/document_list.html b/ephios/plugins/files/templates/files/document_list.html new file mode 100644 index 000000000..5a11ecd5c --- /dev/null +++ b/ephios/plugins/files/templates/files/document_list.html @@ -0,0 +1,33 @@ +{% extends "core/settings/settings_base.html" %} +{% load i18n %} + +{% block settings_content %} + {% translate "Add file" %} + + + + + + + + + {% for document in document_list %} + + + + + {% endfor %} + +
{% translate "Title" %}{% translate "Action" %}
{{ document.title }} + {% translate "Download" %} + {% translate "Edit" %} + {% translate "Delete" %} +
+{% endblock %} diff --git a/ephios/plugins/files/urls.py b/ephios/plugins/files/urls.py index c1e4ba375..3e961bdff 100644 --- a/ephios/plugins/files/urls.py +++ b/ephios/plugins/files/urls.py @@ -1,8 +1,28 @@ from django.urls import path -from ephios.plugins.files.views import DocumentView +from ephios.plugins.files.views import ( + DocumentCreateView, + DocumentDeleteView, + DocumentListView, + DocumentUpdateView, + DocumentView, +) app_name = "files" urlpatterns = [ - path("documents//", DocumentView.as_view(), name="document"), + path("document//", DocumentView.as_view(), name="document"), + path("settings/documents/", DocumentListView.as_view(), name="settings_document_list"), + path( + "settings/documents/create/", DocumentCreateView.as_view(), name="settings_document_create" + ), + path( + "settings/documents//edit/", + DocumentUpdateView.as_view(), + name="settings_document_edit", + ), + path( + "settings/documents//delete/", + DocumentDeleteView.as_view(), + name="settings_document_delete", + ), ] diff --git a/ephios/plugins/files/views.py b/ephios/plugins/files/views.py index 7aa3b41f1..b710dce9d 100644 --- a/ephios/plugins/files/views.py +++ b/ephios/plugins/files/views.py @@ -2,11 +2,17 @@ from urllib.parse import urlsplit from django.conf import settings +from django.contrib.messages.views import SuccessMessageMixin from django.http import FileResponse, HttpResponse from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ from django.views import View +from django.views.generic import CreateView, DeleteView, ListView, UpdateView from guardian.mixins import LoginRequiredMixin +from ephios.extra.mixins import CustomPermissionRequiredMixin +from ephios.plugins.files.forms import DocumentForm from ephios.plugins.files.models import Document @@ -24,3 +30,31 @@ def get(self, request, *args, **kwargs): "attachment; filename=" + os.path.split(document.file.name)[1] ) return response + + +class DocumentListView(CustomPermissionRequiredMixin, ListView): + model = Document + permission_required = "files.add_document" + + +class DocumentCreateView(CustomPermissionRequiredMixin, SuccessMessageMixin, CreateView): + model = Document + permission_required = "files.add_document" + form_class = DocumentForm + success_url = reverse_lazy("files:settings_document_list") + success_message = _("File saved successfully.") + + +class DocumentUpdateView(CustomPermissionRequiredMixin, SuccessMessageMixin, UpdateView): + model = Document + permission_required = "files.change_document" + form_class = DocumentForm + success_url = reverse_lazy("files:settings_document_list") + success_message = _("File saved successfully.") + + +class DocumentDeleteView(CustomPermissionRequiredMixin, SuccessMessageMixin, DeleteView): + model = Document + permission_required = "files.delete_document" + success_url = reverse_lazy("files:settings_document_list") + success_message = _("File deleted successfully.")