From f2c650f2261a520c6c6c058fb8f48120f776d052 Mon Sep 17 00:00:00 2001 From: kovacspe Date: Sat, 18 May 2024 18:10:29 +0200 Subject: [PATCH 1/8] More info in EventRegistration --- competition/serializers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/competition/serializers.py b/competition/serializers.py index d224acb..97ac54b 100644 --- a/competition/serializers.py +++ b/competition/serializers.py @@ -126,11 +126,15 @@ def get_history_events(self, obj): class EventRegistrationSerializer(serializers.ModelSerializer): class Meta: model = models.EventRegistration - fields = ['school', 'grade', 'profile'] + fields = ['school', 'grade', 'profile', 'id', 'event'] school = SchoolShortSerializer(many=False) grade = serializers.SlugRelatedField( slug_field='tag', many=False, read_only=True) profile = ProfileShortSerializer(many=False) + verbose_name = serializers.SerializerMethodField('get_verbose_name') + + def get_verbose_name(self, obj): + return str(obj) @ts_interface(context='competition') From 4a7a07762d0f1676a0d223e46c9edf5ccd76dbee Mon Sep 17 00:00:00 2001 From: kovacspe Date: Sat, 18 May 2024 18:13:22 +0200 Subject: [PATCH 2/8] Add verbose name --- competition/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/competition/serializers.py b/competition/serializers.py index 97ac54b..665e15b 100644 --- a/competition/serializers.py +++ b/competition/serializers.py @@ -126,7 +126,7 @@ def get_history_events(self, obj): class EventRegistrationSerializer(serializers.ModelSerializer): class Meta: model = models.EventRegistration - fields = ['school', 'grade', 'profile', 'id', 'event'] + fields = ['school', 'grade', 'profile', 'verbose_name', 'id', 'event'] school = SchoolShortSerializer(many=False) grade = serializers.SlugRelatedField( slug_field='tag', many=False, read_only=True) From 6bbf15f09ba70981571ab6a459f7b86faafbd839 Mon Sep 17 00:00:00 2001 From: kovacspe Date: Sun, 19 May 2024 13:30:33 +0200 Subject: [PATCH 3/8] Add city to school search fileds --- personal/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/personal/views.py b/personal/views.py index 61dc107..9c5e193 100644 --- a/personal/views.py +++ b/personal/views.py @@ -32,7 +32,7 @@ class SchoolViewSet(viewsets.ModelViewSet): serializer_class = SchoolSerializer filterset_fields = ['district', 'district__county'] filter_backends = [DjangoFilterBackend, SearchFilter] - search_fields = ['name', 'street'] + search_fields = ['name', 'street', 'city'] def destroy(self, request, *args, **kwargs): """Zmazanie školy""" From 112527b6e3df6b75c8d8b341d0811f00b44587e3 Mon Sep 17 00:00:00 2001 From: kovacspe Date: Sun, 19 May 2024 13:43:45 +0200 Subject: [PATCH 4/8] Add school verbose name --- personal/serializers.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/personal/serializers.py b/personal/serializers.py index e566f45..871d71d 100644 --- a/personal/serializers.py +++ b/personal/serializers.py @@ -21,9 +21,15 @@ class Meta: @ts_interface(context='personal') class SchoolSerializer(serializers.ModelSerializer): + verbose_name = serializers.SerializerMethodField('get_verbose_name') + class Meta: model = School - fields = '__all__' + fields = ['code', 'name', 'abbreviation', 'street', + 'city', 'zip_code', 'email', 'district', 'verbose_name'] + + def get_verbose_name(self, obj): + return str(obj) @ts_interface(context='personal') From 3edb79391c1ca1cdea3af7cedfda537423e7890d Mon Sep 17 00:00:00 2001 From: kovacspe Date: Sun, 19 May 2024 13:55:57 +0200 Subject: [PATCH 5/8] Add user verbose_name --- personal/models.py | 2 +- personal/serializers.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/personal/models.py b/personal/models.py index 15488fd..c48a45f 100644 --- a/personal/models.py +++ b/personal/models.py @@ -120,7 +120,7 @@ def grade(self, value): pk=value).get_year_of_graduation_by_date() def __str__(self): - return str(self.user) + return f'{self.full_name()} ({self.user})' def full_name(self): return f'{self.first_name} {self.last_name}' diff --git a/personal/serializers.py b/personal/serializers.py index 871d71d..8a1718f 100644 --- a/personal/serializers.py +++ b/personal/serializers.py @@ -61,13 +61,14 @@ class ProfileSerializer(serializers.ModelSerializer): has_school = serializers.SerializerMethodField('get_has_school') school_id = serializers.IntegerField() email = serializers.EmailField(source='user.email') + verbose_name = serializers.SerializerMethodField('get_verbose_name') class Meta: model = Profile fields = ['grade_name', 'id', 'email', 'first_name', 'last_name', 'school', - 'phone', 'parent_phone', 'grade', 'is_student', 'has_school', 'school_id'] + 'phone', 'parent_phone', 'grade', 'is_student', 'has_school', 'school_id', 'verbose_name'] read_only_fields = ['grade_name', 'id', 'first_name', 'last_name', - 'email', 'is_student', 'has_school', 'school'] # 'year_of_graduation', + 'email', 'is_student', 'has_school', 'school', 'verbose_name'] extra_kwargs = { 'grade': { @@ -78,6 +79,9 @@ class Meta: } } + def get_verbose_name(self, obj): + return str(obj) + def get_is_student(self, obj): return obj.school != School.objects.get(pk=1) From da72fad3335c0104810ff9895901b8fc03dae9bc Mon Sep 17 00:00:00 2001 From: kovacspe Date: Sun, 19 May 2024 19:18:45 +0200 Subject: [PATCH 6/8] Add id field copying code --- personal/serializers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/personal/serializers.py b/personal/serializers.py index 8a1718f..7e893f9 100644 --- a/personal/serializers.py +++ b/personal/serializers.py @@ -22,15 +22,20 @@ class Meta: @ts_interface(context='personal') class SchoolSerializer(serializers.ModelSerializer): verbose_name = serializers.SerializerMethodField('get_verbose_name') + id = serializers.SerializerMethodField('get_id') class Meta: model = School - fields = ['code', 'name', 'abbreviation', 'street', + fields = ['id', 'code', 'name', 'abbreviation', 'street', 'city', 'zip_code', 'email', 'district', 'verbose_name'] + read_only_fields = ['id', 'verbose_name'] def get_verbose_name(self, obj): return str(obj) + def get_id(self, obj): + return obj.code + @ts_interface(context='personal') class SchoolShortSerializer(serializers.ModelSerializer): From 5dd519996526085397188eca84744cfa91439718 Mon Sep 17 00:00:00 2001 From: kovacspe Date: Tue, 21 May 2024 21:09:24 +0200 Subject: [PATCH 7/8] lint --- personal/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/personal/serializers.py b/personal/serializers.py index 7e893f9..ec72cb0 100644 --- a/personal/serializers.py +++ b/personal/serializers.py @@ -71,7 +71,8 @@ class ProfileSerializer(serializers.ModelSerializer): class Meta: model = Profile fields = ['grade_name', 'id', 'email', 'first_name', 'last_name', 'school', - 'phone', 'parent_phone', 'grade', 'is_student', 'has_school', 'school_id', 'verbose_name'] + 'phone', 'parent_phone', 'grade', 'is_student', 'has_school', + 'school_id', 'verbose_name'] read_only_fields = ['grade_name', 'id', 'first_name', 'last_name', 'email', 'is_student', 'has_school', 'school', 'verbose_name'] From d1c21229c223b69e90ab98d271a711223ca8ad19 Mon Sep 17 00:00:00 2001 From: kovacspe Date: Tue, 21 May 2024 21:13:26 +0200 Subject: [PATCH 8/8] File names (#388) --- Pipfile | 1 + competition/models.py | 37 ++++++++++++++++++++++++++++---- competition/views.py | 13 +++++------ downloads/__init__.py | 0 downloads/apps.py | 6 ++++++ downloads/migrations/__init__.py | 0 downloads/models.py | 3 +++ downloads/tests.py | 3 +++ downloads/urls.py | 18 ++++++++++++++++ downloads/views.py | 29 +++++++++++++++++++++++++ requirements.txt | 1 + webstrom/settings.py | 5 +++-- webstrom/urls.py | 1 + 13 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 downloads/__init__.py create mode 100644 downloads/apps.py create mode 100644 downloads/migrations/__init__.py create mode 100644 downloads/models.py create mode 100644 downloads/tests.py create mode 100644 downloads/urls.py create mode 100644 downloads/views.py diff --git a/Pipfile b/Pipfile index 334e3e2..ad8d4a4 100644 --- a/Pipfile +++ b/Pipfile @@ -9,6 +9,7 @@ dj-rest-auth = "~=5.0.1" django = "~=3.2.23" django-allauth = "~=0.58.2" django-filter = "~=23.5" +django-sendfile2 = "~=0.7.1" djangorestframework = "~=3.14.0" drf-writable-nested = "~=0.7.0" pillow = "~=10.3.0" diff --git a/competition/models.py b/competition/models.py index 0a99732..d0a49fe 100644 --- a/competition/models.py +++ b/competition/models.py @@ -24,7 +24,9 @@ from personal.models import Profile, School from user.models import User -private_storage = FileSystemStorage(location=settings.PRIVATE_STORAGE_ROOT) +private_storage = FileSystemStorage(location=settings.PRIVATE_STORAGE_ROOT, + base_url='/protected/' + ) class CompetitionType(models.Model): @@ -619,6 +621,14 @@ class Vote(models.IntegerChoices): POSITIVE = 1, 'pozitívny' +def get_solution_path(instance, filename): #pylint: disable=unused-argument + return instance.get_solution_file_path() + + +def get_corrected_solution_path(instance, filename): #pylint: disable=unused-argument + return instance.get_corrected_solution_file_path() + + class Solution(models.Model): """ Popisuje riešenie úlohy od užívateľa. Obsahuje nahraté aj opravné riešenie, body @@ -634,11 +644,11 @@ class Meta: solution = RestrictedFileField( content_types=['application/pdf'], storage=private_storage, - verbose_name='účastnícke riešenie', blank=True, upload_to='solutions/user_solutions') + verbose_name='účastnícke riešenie', blank=True, upload_to=get_solution_path) corrected_solution = RestrictedFileField( content_types=['application/pdf'], storage=private_storage, - verbose_name='opravené riešenie', blank=True, upload_to='solutions/corrected/') + verbose_name='opravené riešenie', blank=True, upload_to=get_corrected_solution_path) score = models.PositiveSmallIntegerField( verbose_name='body', null=True, blank=True) @@ -664,13 +674,32 @@ def get_solution_file_name(self): return f'{self.semester_registration.profile.user.get_full_name_camel_case()}'\ f'-{self.problem.id}-{self.semester_registration.id}.pdf' + def get_solution_file_path(self): + return f'solutions/user_solutions/{self.get_solution_file_name()}' + def get_corrected_solution_file_name(self): - return f'corrected/{self.semester_registration.profile.user.get_full_name_camel_case()}'\ + return f'{self.semester_registration.profile.user.get_full_name_camel_case()}'\ f'-{self.problem.id}-{self.semester_registration.id}_corrected.pdf' + def get_corrected_solution_file_path(self): + return f'solutions/corrected/{self.get_corrected_solution_file_name()}' + def can_user_modify(self, user): return self.problem.can_user_modify(user) + def can_access(self, user): + return self.semester_registration.profile.user == user or self.can_user_modify(user) + + @classmethod + def get_by_filepath(cls, path): + try: + return cls.objects.get(solution=path) + except cls.DoesNotExist: + try: + return cls.objects.get(corrected_solution=path) + except cls.DoesNotExist: + return None + @classmethod def can_user_create(cls, user: User, data: dict) -> bool: problem = Problem.objects.get(pk=data['problem']) diff --git a/competition/views.py b/competition/views.py index 09bebb9..73736e2 100644 --- a/competition/views.py +++ b/competition/views.py @@ -280,10 +280,11 @@ def upload_solution(self, request, pk=None): problem=problem, semester_registration=event_registration, late_tag=late_tag, - is_online=True + is_online=True, + solution=file ) - solution.solution.save( - solution.get_solution_file_name(), file, save=True) + # solution.solution.save( + # solution.get_solution_file_name(), file, save=True) return Response(status=status.HTTP_201_CREATED) @@ -394,7 +395,7 @@ def upload_solutions_with_points(self, request, pk=None): except (IndexError, ValueError, AssertionError): errors.append({ 'filename': filename, - 'status': 'Nedá sa prečítať názov súboru. Skontroluj, že názov súboru' + 'status': 'Nedá sa prečítať názov súboru. Skontroluj, že názov súboru' 'je v tvare BODY-MENO-ID_ULOHY-ID_REGISTRACIE_USERA.pdf' }) continue @@ -583,7 +584,7 @@ def upload_solution_file(self, request, pk=None): raise exceptions.ParseError( detail='Riešenie nie je vo formáte pdf') solution.solution.save( - solution.get_solution_file_name(), file, save=True + solution.get_solution_file_path(), file, save=True ) return Response(status=status.HTTP_201_CREATED) @@ -600,7 +601,7 @@ def upload_corrected_solution_file(self, request, pk=None): raise exceptions.ParseError( detail='Riešenie nie je vo formáte pdf') solution.corrected_solution.save( - solution.get_corrected_solution_file_name(), file, save=True + solution.get_corrected_solution_file_path(), file, save=True ) return Response(status=status.HTTP_201_CREATED) diff --git a/downloads/__init__.py b/downloads/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/downloads/apps.py b/downloads/apps.py new file mode 100644 index 0000000..62b306e --- /dev/null +++ b/downloads/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DownloadsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'downloads' diff --git a/downloads/migrations/__init__.py b/downloads/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/downloads/models.py b/downloads/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/downloads/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/downloads/tests.py b/downloads/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/downloads/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/downloads/urls.py b/downloads/urls.py new file mode 100644 index 0000000..d871f9c --- /dev/null +++ b/downloads/urls.py @@ -0,0 +1,18 @@ +from django.conf.urls import url +from django.utils.translation import ugettext_lazy as _ + +from competition.models import Solution + +from .views import download_protected_file + +urlpatterns = [ + # Include non-translated versions only since Admin ignores lang prefix + url(r'solutions/(?P.*)$', download_protected_file, + {'path_prefix': 'solutions/', 'model_class': Solution}, + name='download_solution'), + url(r'corrected_solutions/(?P.*)$', download_protected_file, + {'path_prefix': 'corrected_solutions/', + 'model_class': Solution}, + name='download_corrected_solution'), + +] diff --git a/downloads/views.py b/downloads/views.py new file mode 100644 index 0000000..1986e1b --- /dev/null +++ b/downloads/views.py @@ -0,0 +1,29 @@ +import os + +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django_sendfile import sendfile +from rest_framework.request import Request + + +@login_required +def download_protected_file(request: Request, model_class, path_prefix, path): + """ + This view allows download of the file at the specified path, if the user + is allowed to. This is checked by calling the model's can_access_files + method. + """ + filepath = os.path.join(settings.PRIVATE_STORAGE_ROOT, path_prefix, path) + filepath_mediapath = path_prefix + path + + if request.user.is_authenticated: + # Superusers can access all files + if request.user.is_superuser: + return sendfile(request, filepath) + obj = model_class.get_by_filepath(filepath_mediapath) + + if obj is not None and obj.can_access(request.user): + return sendfile(request, filepath) + + raise PermissionDenied diff --git a/requirements.txt b/requirements.txt index 91c5904..c4036f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ Django==3.2.25 django-allauth==0.58.2 django-filter==23.5 django-rest-swagger==2.2.0 +django-sendfile2==0.7.1 django-typomatic==2.5.0 djangorestframework==3.14.0 drf-writable-nested==0.7.0 diff --git a/webstrom/settings.py b/webstrom/settings.py index 6268432..894772e 100644 --- a/webstrom/settings.py +++ b/webstrom/settings.py @@ -178,8 +178,9 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_URL = '/media/' -PRIVATE_STORAGE_ROOT = os.path.join(BASE_DIR, 'protected_media') - +PRIVATE_STORAGE_ROOT = os.path.join(BASE_DIR, 'protected_media/') +SENDFILE_ROOT = PRIVATE_STORAGE_ROOT +SENDFILE_BACKEND = "django_sendfile.backends.simple" # Email backend EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend' diff --git a/webstrom/urls.py b/webstrom/urls.py index ed42558..177c6a5 100644 --- a/webstrom/urls.py +++ b/webstrom/urls.py @@ -10,6 +10,7 @@ path('cms/', include('cms.urls')), path('personal/', include('personal.urls')), path('base/', include('base.urls')), + path('protected/', include('downloads.urls')), # Dočasná cesta pre allauth bez rest frameworku path('accounts/', include('allauth.urls')), ]