Sith/core/views/files.py

413 lines
14 KiB
Python
Raw Permalink Normal View History

#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
2024-09-23 08:25:27 +00:00
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
2024-09-23 08:25:27 +00:00
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
2024-09-01 17:05:54 +00:00
import mimetypes
2024-08-05 08:46:15 +00:00
from urllib.parse import quote, urljoin
2016-08-10 03:48:06 +00:00
# This file contains all the views that concern the page model
2024-06-24 11:07:36 +00:00
from wsgiref.util import FileWrapper
2016-08-10 03:48:06 +00:00
2024-06-24 11:07:36 +00:00
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db.models import Exists, OuterRef
2024-06-24 11:07:36 +00:00
from django.forms.models import modelform_factory
from django.http import Http404, HttpRequest, HttpResponse
2024-06-24 11:07:36 +00:00
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.http import http_date
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
2024-06-24 11:07:36 +00:00
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
2016-12-18 16:59:08 +00:00
from core.models import Notification, SithFile, User
from core.views import (
AllowFragment,
CanEditMixin,
CanEditPropMixin,
2024-06-24 11:07:36 +00:00
CanViewMixin,
can_view,
)
2024-10-20 21:25:56 +00:00
from core.views.widgets.select import (
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectSithFile,
AutoCompleteSelectUser,
)
2024-08-05 08:46:15 +00:00
from counter.utils import is_logged_in_counter
2016-08-10 03:48:06 +00:00
2017-06-12 07:42:03 +00:00
def send_file(
request: HttpRequest,
file_id: int,
file_class: type[SithFile] = SithFile,
file_attr: str = "file",
) -> HttpResponse:
"""Send a protected file, if the user can see it.
In prod, the server won't handle the download itself,
but set the appropriate headers in the response to make the reverse-proxy
deal with it.
In debug mode, the server will directly send the file.
2016-08-10 03:48:06 +00:00
"""
f = get_object_or_404(file_class, id=file_id)
2024-08-05 08:46:15 +00:00
if not can_view(f, request.user) and not is_logged_in_counter(request):
2016-08-10 03:48:06 +00:00
raise PermissionDenied
name = getattr(f, file_attr).name
response = HttpResponse(
headers={"Content-Disposition": f'inline; filename="{quote(name)}"'}
)
if not settings.DEBUG:
# When receiving a response with the Accel-Redirect header,
# the reverse proxy will automatically handle the file sending.
# This is really hard to test (thus isn't tested)
# so please do not mess with this.
response["Content-Type"] = "" # automatically set by nginx
2024-08-05 08:46:15 +00:00
response["X-Accel-Redirect"] = quote(urljoin(settings.MEDIA_URL, name))
return response
filepath = settings.MEDIA_ROOT / name
# check if file exists on disk
if not filepath.exists():
raise Http404
2024-07-26 13:14:37 +00:00
with open(filepath, "rb") as filename:
response.content = FileWrapper(filename)
2024-09-01 17:05:54 +00:00
response["Content-Type"] = mimetypes.guess_type(filepath)[0]
response["Last-Modified"] = http_date(f.date.timestamp())
2024-07-26 13:14:37 +00:00
response["Content-Length"] = filepath.stat().st_size
2016-08-10 03:48:06 +00:00
return response
2017-06-12 07:42:03 +00:00
2024-06-22 19:16:42 +00:00
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class _MultipleFieldMixin:
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", MultipleFileInput())
super().__init__(*args, **kwargs)
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
result = [single_file_clean(d, initial) for d in data]
else:
result = [single_file_clean(data, initial)]
return result
class MultipleFileField(_MultipleFieldMixin, forms.FileField): ...
2024-06-22 19:16:42 +00:00
class MultipleImageField(_MultipleFieldMixin, forms.ImageField): ...
2024-06-22 19:16:42 +00:00
class AddFilesForm(forms.Form):
2018-10-04 19:29:19 +00:00
folder_name = forms.CharField(
label=_("Add a new folder"), max_length=30, required=False
)
2024-06-22 19:16:42 +00:00
file_field = MultipleFileField(
2018-10-04 19:29:19 +00:00
label=_("Files"),
required=False,
)
2016-08-10 03:48:06 +00:00
def process(self, parent, owner, files):
2016-12-08 18:47:28 +00:00
notif = False
2016-08-10 03:48:06 +00:00
try:
2018-10-04 19:29:19 +00:00
if self.cleaned_data["folder_name"] != "":
folder = SithFile(
parent=parent, name=self.cleaned_data["folder_name"], owner=owner
)
2016-08-10 03:48:06 +00:00
folder.clean()
folder.save()
2016-12-08 18:47:28 +00:00
notif = True
2016-08-10 03:48:06 +00:00
except Exception as e:
2018-10-04 19:29:19 +00:00
self.add_error(
None,
_("Error creating folder %(folder_name)s: %(msg)s")
% {"folder_name": self.cleaned_data["folder_name"], "msg": repr(e)},
)
2016-08-10 03:48:06 +00:00
for f in files:
2018-10-04 19:29:19 +00:00
new_file = SithFile(
parent=parent,
name=f.name,
file=f,
owner=owner,
is_folder=False,
mime_type=f.content_type,
size=f.size,
2018-10-04 19:29:19 +00:00
)
2016-08-10 03:48:06 +00:00
try:
new_file.clean()
new_file.save()
2016-12-08 18:47:28 +00:00
notif = True
2016-08-10 03:48:06 +00:00
except Exception as e:
2018-10-04 19:29:19 +00:00
self.add_error(
None,
_("Error uploading file %(file_name)s: %(msg)s")
% {"file_name": f, "msg": repr(e)},
)
2016-12-08 18:47:28 +00:00
if notif:
unread_notif_subquery = Notification.objects.filter(
user=OuterRef("pk"), type="FILE_MODERATION", viewed=False
)
for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
2018-10-04 19:29:19 +00:00
):
Notification.objects.create(
user=user,
url=reverse("core:file_moderation"),
type="FILE_MODERATION",
)
2016-12-08 18:47:28 +00:00
2017-06-12 07:42:03 +00:00
2016-08-10 14:23:12 +00:00
class FileListView(ListView):
2018-10-04 19:29:19 +00:00
template_name = "core/file_list.jinja"
2016-08-10 03:48:06 +00:00
context_object_name = "file_list"
def get_queryset(self):
return SithFile.objects.filter(parent=None)
def get_context_data(self, **kwargs):
2024-06-27 12:46:43 +00:00
kwargs = super().get_context_data(**kwargs)
2018-10-04 19:29:19 +00:00
kwargs["popup"] = ""
if self.kwargs.get("popup") is not None:
2018-10-04 19:29:19 +00:00
kwargs["popup"] = "popup"
2016-08-10 03:48:06 +00:00
return kwargs
2017-06-12 07:42:03 +00:00
2016-08-10 03:48:06 +00:00
class FileEditView(CanEditMixin, UpdateView):
model = SithFile
pk_url_kwarg = "file_id"
2018-10-04 19:29:19 +00:00
template_name = "core/file_edit.jinja"
2016-08-10 03:48:06 +00:00
context_object_name = "file"
def get_form_class(self):
2018-10-04 19:29:19 +00:00
fields = ["name", "is_moderated"]
2016-08-10 03:48:06 +00:00
if self.object.is_file:
fields = ["file", *fields]
2016-08-10 03:48:06 +00:00
return modelform_factory(SithFile, fields=fields)
def get_success_url(self):
if self.kwargs.get("popup") is not None:
2018-10-04 19:29:19 +00:00
return reverse(
"core:file_detail", kwargs={"file_id": self.object.id, "popup": "popup"}
)
return reverse(
"core:file_detail", kwargs={"file_id": self.object.id, "popup": ""}
)
2016-08-10 03:48:06 +00:00
def get_context_data(self, **kwargs):
2024-06-27 12:46:43 +00:00
kwargs = super().get_context_data(**kwargs)
2018-10-04 19:29:19 +00:00
kwargs["popup"] = ""
if self.kwargs.get("popup") is not None:
2018-10-04 19:29:19 +00:00
kwargs["popup"] = "popup"
2016-08-10 03:48:06 +00:00
return kwargs
2017-06-12 07:42:03 +00:00
2016-12-18 16:59:08 +00:00
class FileEditPropForm(forms.ModelForm):
class Meta:
model = SithFile
2018-10-04 19:29:19 +00:00
fields = ["parent", "owner", "edit_groups", "view_groups"]
2024-10-20 21:25:56 +00:00
widgets = {
"parent": AutoCompleteSelectSithFile,
"owner": AutoCompleteSelectUser,
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
}
2018-10-04 19:29:19 +00:00
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
2016-12-18 16:59:08 +00:00
2017-06-12 07:42:03 +00:00
2016-08-10 03:48:06 +00:00
class FileEditPropView(CanEditPropMixin, UpdateView):
model = SithFile
pk_url_kwarg = "file_id"
2018-10-04 19:29:19 +00:00
template_name = "core/file_edit.jinja"
2016-08-10 03:48:06 +00:00
context_object_name = "file"
2016-12-18 16:59:08 +00:00
form_class = FileEditPropForm
2016-08-10 03:48:06 +00:00
def get_form(self, form_class=None):
2024-06-27 12:46:43 +00:00
form = super().get_form(form_class)
2018-10-04 19:29:19 +00:00
form.fields["parent"].queryset = SithFile.objects.filter(is_folder=True)
2016-08-10 03:48:06 +00:00
return form
def form_valid(self, form):
2024-06-27 12:46:43 +00:00
ret = super().form_valid(form)
2018-10-04 19:29:19 +00:00
if form.cleaned_data["recursive"]:
self.object.apply_rights_recursively()
return ret
2016-08-10 03:48:06 +00:00
def get_success_url(self):
2018-10-04 19:29:19 +00:00
return reverse(
"core:file_detail",
kwargs={"file_id": self.object.id, "popup": self.kwargs.get("popup", "")},
2018-10-04 19:29:19 +00:00
)
2016-08-10 03:48:06 +00:00
def get_context_data(self, **kwargs):
2024-06-27 12:46:43 +00:00
kwargs = super().get_context_data(**kwargs)
2018-10-04 19:29:19 +00:00
kwargs["popup"] = ""
if self.kwargs.get("popup") is not None:
2018-10-04 19:29:19 +00:00
kwargs["popup"] = "popup"
2016-08-10 03:48:06 +00:00
return kwargs
2017-06-12 07:42:03 +00:00
2016-08-10 12:48:18 +00:00
class FileView(CanViewMixin, DetailView, FormMixin):
2024-07-12 07:34:16 +00:00
"""Handle the upload of new files into a folder."""
2018-10-04 19:29:19 +00:00
2016-08-10 03:48:06 +00:00
model = SithFile
pk_url_kwarg = "file_id"
2018-10-04 19:29:19 +00:00
template_name = "core/file_detail.jinja"
2016-08-10 03:48:06 +00:00
context_object_name = "file"
form_class = AddFilesForm
2016-08-10 03:48:06 +00:00
2024-06-27 12:30:58 +00:00
@staticmethod
def handle_clipboard(request, obj):
2024-07-12 07:34:16 +00:00
"""Handle the clipboard in the view.
This method can fail, since it does not catch the exceptions coming from
below, allowing proper handling in the calling view.
Use this method like this:
FileView.handle_clipboard(request, self.object)
2024-06-27 12:30:58 +00:00
`request` is usually the self.request obj in your view
`obj` is the SithFile object you want to put in the clipboard, or
where you want to paste the clipboard
"""
if "delete" in request.POST:
2018-10-04 19:29:19 +00:00
for f_id in request.POST.getlist("file_list"):
file = SithFile.objects.filter(id=f_id).first()
if file:
file.delete()
if "clear" in request.POST:
2018-10-04 19:29:19 +00:00
request.session["clipboard"] = []
if "cut" in request.POST:
for f_id_str in request.POST.getlist("file_list"):
f_id = int(f_id_str)
2018-10-04 19:29:19 +00:00
if (
2024-06-27 12:30:58 +00:00
f_id in [c.id for c in obj.children.all()]
2018-10-04 19:29:19 +00:00
and f_id not in request.session["clipboard"]
):
request.session["clipboard"].append(f_id)
if "paste" in request.POST:
2018-10-04 19:29:19 +00:00
for f_id in request.session["clipboard"]:
file = SithFile.objects.filter(id=f_id).first()
if file:
file.move_to(obj)
2018-10-04 19:29:19 +00:00
request.session["clipboard"] = []
2016-12-13 16:17:58 +00:00
request.session.modified = True
2016-08-10 03:48:06 +00:00
def get(self, request, *args, **kwargs):
self.form = self.get_form()
if "clipboard" not in request.session:
2018-10-04 19:29:19 +00:00
request.session["clipboard"] = []
2024-06-27 12:46:43 +00:00
return super().get(request, *args, **kwargs)
2016-08-10 03:48:06 +00:00
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if "clipboard" not in request.session:
2018-10-04 19:29:19 +00:00
request.session["clipboard"] = []
2016-12-12 23:45:20 +00:00
if request.user.can_edit(self.object):
# XXX this call can fail!
2024-06-27 12:30:58 +00:00
self.handle_clipboard(request, self.object)
2017-06-12 07:42:03 +00:00
self.form = self.get_form() # The form handle only the file upload
2018-10-04 19:29:19 +00:00
files = request.FILES.getlist("file_field")
if (
request.user.is_authenticated
2018-10-04 19:29:19 +00:00
and request.user.can_edit(self.object)
and self.form.is_valid()
):
2016-08-10 03:48:06 +00:00
self.form.process(parent=self.object, owner=request.user, files=files)
if self.form.is_valid():
2024-06-27 12:46:43 +00:00
return super().form_valid(self.form)
2016-08-10 03:48:06 +00:00
return self.form_invalid(self.form)
def get_success_url(self):
2018-10-04 19:29:19 +00:00
return reverse(
"core:file_detail",
kwargs={"file_id": self.object.id, "popup": self.kwargs.get("popup", "")},
2018-10-04 19:29:19 +00:00
)
2016-08-10 03:48:06 +00:00
def get_context_data(self, **kwargs):
2024-06-27 12:46:43 +00:00
kwargs = super().get_context_data(**kwargs)
2018-10-04 19:29:19 +00:00
kwargs["popup"] = ""
kwargs["form"] = self.form
if self.kwargs.get("popup") is not None:
2018-10-04 19:29:19 +00:00
kwargs["popup"] = "popup"
kwargs["clipboard"] = SithFile.objects.filter(
id__in=self.request.session["clipboard"]
)
2016-08-10 03:48:06 +00:00
return kwargs
2017-06-12 07:42:03 +00:00
class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
2016-08-10 03:48:06 +00:00
model = SithFile
pk_url_kwarg = "file_id"
2018-10-04 19:29:19 +00:00
template_name = "core/file_delete_confirm.jinja"
2016-08-10 03:48:06 +00:00
context_object_name = "file"
def get_success_url(self):
2017-06-12 07:42:03 +00:00
self.object.file.delete() # Doing it here or overloading delete() is the same, so let's do it here
if "next" in self.request.GET:
2018-10-04 19:29:19 +00:00
return self.request.GET["next"]
2016-08-10 03:48:06 +00:00
if self.object.parent is None:
2018-10-04 19:29:19 +00:00
return reverse(
"core:file_list", kwargs={"popup": self.kwargs.get("popup", "")}
2018-10-04 19:29:19 +00:00
)
return reverse(
"core:file_detail",
kwargs={
"file_id": self.object.parent.id,
"popup": self.kwargs.get("popup", ""),
2018-10-04 19:29:19 +00:00
},
)
2016-08-10 03:48:06 +00:00
def get_context_data(self, **kwargs):
2024-06-27 12:46:43 +00:00
kwargs = super().get_context_data(**kwargs)
kwargs["popup"] = "" if self.kwargs.get("popup") is None else "popup"
kwargs["next"] = self.request.GET.get("next", None)
kwargs["previous"] = self.request.GET.get("previous", None)
kwargs["current"] = self.request.path
2016-08-10 03:48:06 +00:00
return kwargs
2017-06-12 07:42:03 +00:00
class FileModerationView(AllowFragment, ListView):
model = SithFile
2016-11-09 08:13:57 +00:00
template_name = "core/file_moderation.jinja"
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
paginate_by = 100
def dispatch(self, request: HttpRequest, *args, **kwargs):
user: User = request.user
if user.is_root:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied()
2016-11-09 08:13:57 +00:00
2017-06-12 07:42:03 +00:00
2016-11-09 08:13:57 +00:00
class FileModerateView(CanEditPropMixin, SingleObjectMixin):
model = SithFile
pk_url_kwarg = "file_id"
2024-09-09 19:37:28 +00:00
# FIXME : wrong http method. This should be a POST or a DELETE request
2016-11-09 08:13:57 +00:00
def get(self, request, *args, **kwargs):
self.object = self.get_object()
self.object.is_moderated = True
2016-12-12 16:23:06 +00:00
self.object.moderator = request.user
2016-11-09 08:13:57 +00:00
self.object.save()
if "next" in self.request.GET:
2018-10-04 19:29:19 +00:00
return redirect(self.request.GET["next"])
return redirect("core:file_moderation")