diff --git a/CHANGELOG.md b/CHANGELOG.md index 4263f47a8..83fb1c9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,14 @@ --- ## Neste versjon + +## Versjon 2024.11.04 - 🎨**Overordnet**. Endret variabel og funksjonsnavn til å følge konvensjoner og andre små endringer. - ✨ **Filtrering**. Admin kan nå filtere deltakere av et arrangement på studie, studieår, om deltakere har allergier, (om deltakere godtar å bli tatt bilde av, om deltakere har ankommet), i tillegg til søk på fornavn og etternavn. +- ✨ **Filopplasting**. Det er nå mulig for admin brukere å laste opp- og slette filer. +- ✨ **Mail endepunkt**. Det er nå laget et endepunkt for å sende mailer. + ## Versjon 2024.10.11 - ✨ **Tilbakemelding-funksjon**. Man kan nå opprette tilbakemeldinger for bugs og idé. - 🦟 **Påmelding**. Det vil nå ikke være mulig med flere påmeldinger på et arrangement enn maksgrensen. diff --git a/app/common/serializers.py b/app/common/serializers.py index 8f8a386e7..f91f26e62 100644 --- a/app/common/serializers.py +++ b/app/common/serializers.py @@ -7,4 +7,6 @@ class BaseModelSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): if hasattr(instance, "image") and "image" in validated_data: replace_file(instance.image, validated_data.get("image", None)) + if hasattr(instance, "file") and "file" in validated_data: + replace_file(instance.file, validated_data.get("file", None)) return super().update(instance, validated_data) diff --git a/app/communication/models/mail.py b/app/communication/models/mail.py index 692fdd004..6a4e13eaa 100644 --- a/app/communication/models/mail.py +++ b/app/communication/models/mail.py @@ -40,5 +40,7 @@ def send(self, connection): return is_success def __str__(self): - return (f"\"{self.subject}\", to {self.users.all()[0] if self.users.count() == 1 else f'{self.users.count()} users'}, " - f"{'sent' if self.sent else 'eta'} {self.eta}") + return ( + f"\"{self.subject}\", to {self.users.all()[0] if self.users.count() == 1 else f'{self.users.count()} users'}, " + f"{'sent' if self.sent else 'eta'} {self.eta}" + ) diff --git a/app/constants.py b/app/constants.py index 638b4d67e..773570e14 100644 --- a/app/constants.py +++ b/app/constants.py @@ -20,4 +20,6 @@ SLACK_BEDPRES_OG_KURS_CHANNEL_ID = "C01DCSJ8X2Q" SLACK_ARRANGEMENTER_CHANNEL_ID = "C01LFEFJFV3" +MAX_GALLERY_SIZE = 50 + # TODO: Create api-urls as constants which then can be used in for example tests and urls.py files diff --git a/app/content/urls.py b/app/content/urls.py index 26911fd69..536f8fb71 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -18,10 +18,8 @@ UserCalendarEvents, UserViewSet, accept_form, - delete, register_with_feide, send_email, - upload, ) router = routers.DefaultRouter() @@ -53,9 +51,7 @@ urlpatterns = [ re_path(r"", include(router.urls)), path("accept-form/", accept_form), - path("upload/", upload), path("send-email/", send_email), - path("delete-file///", delete), path("feide/", register_with_feide), re_path(r"users/(?P[^/.]+)/events.ics", UserCalendarEvents()), ] diff --git a/app/content/util/event_utils.py b/app/content/util/event_utils.py index 304431a71..ad4239548 100644 --- a/app/content/util/event_utils.py +++ b/app/content/util/event_utils.py @@ -3,9 +3,9 @@ from app.content.exceptions import RefundFailedError from app.payment.tasks import check_if_has_paid from app.payment.util.payment_utils import ( + check_access_token, initiate_payment, refund_payment, - check_access_token ) diff --git a/app/content/views/__init__.py b/app/content/views/__init__.py index 5a9a5c3f4..8cdc2bdf6 100644 --- a/app/content/views/__init__.py +++ b/app/content/views/__init__.py @@ -8,7 +8,6 @@ from app.content.views.news import NewsViewSet from app.content.views.page import PageViewSet from app.content.views.short_link import ShortLinkViewSet -from app.content.views.upload import upload, delete from app.content.views.strike import StrikeViewSet from app.content.views.toddel import ToddelViewSet from app.content.views.qr_code import QRCodeViewSet diff --git a/app/content/views/accept_form.py b/app/content/views/accept_form.py index 44e4fe6f7..545e1d351 100644 --- a/app/content/views/accept_form.py +++ b/app/content/views/accept_form.py @@ -23,9 +23,11 @@ def accept_form(request): if len(types) == 1 and types[0].lower() == "annonse" else MAIL_NOK_LEADER ) - title = (f"{body['info']['bedrift']} vil ha {', '.join(types[:-1])}" - f"{' og ' if len(types) > 1 else ''}{', '.join(types[-1:])}, " - f"{', '.join(times[:-1])}{' og ' if len(times) > 1 else ''}{', '.join(times[-1:])}") + title = ( + f"{body['info']['bedrift']} vil ha {', '.join(types[:-1])}" + f"{' og ' if len(types) > 1 else ''}{', '.join(types[-1:])}, " + f"{', '.join(times[:-1])}{' og ' if len(times) > 1 else ''}{', '.join(times[-1:])}" + ) is_success = send_html_email( to_mails=[to_mail], diff --git a/app/content/views/user_bio.py b/app/content/views/user_bio.py index 5eeb1081d..b4c0902df 100644 --- a/app/content/views/user_bio.py +++ b/app/content/views/user_bio.py @@ -49,6 +49,4 @@ def update(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): super().destroy(request, *args, **kwargs) - return Response( - {"detail": "Brukerbio ble slettet"}, status=status.HTTP_200_OK - ) + return Response({"detail": "Brukerbio ble slettet"}, status=status.HTTP_200_OK) diff --git a/app/content/views/user_calendar_events.py b/app/content/views/user_calendar_events.py index 3c26c72ff..59e5d4999 100644 --- a/app/content/views/user_calendar_events.py +++ b/app/content/views/user_calendar_events.py @@ -23,7 +23,7 @@ def __call__(self, request, *args, **kwargs): return JsonResponse( { "detail": "Denne brukeren har skrudd av offentlig deling av påmeldinger til arrangementer. " - "Du kan derfor ikke hente ut brukerens arrangementer som .ics-fil" + "Du kan derfor ikke hente ut brukerens arrangementer som .ics-fil" }, status=status.HTTP_403_FORBIDDEN, ) diff --git a/app/files/__init__.py b/app/files/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/files/admin.py b/app/files/admin.py new file mode 100644 index 000000000..b4aaec23f --- /dev/null +++ b/app/files/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from app.files import models + +admin.site.register(models.UserGallery) +admin.site.register(models.File) diff --git a/app/files/app.py b/app/files/app.py new file mode 100644 index 000000000..2449379cf --- /dev/null +++ b/app/files/app.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FilesConfig(AppConfig): + name = "app.files" diff --git a/app/files/enums.py b/app/files/enums.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/files/exceptions.py b/app/files/exceptions.py new file mode 100644 index 000000000..83490cc2f --- /dev/null +++ b/app/files/exceptions.py @@ -0,0 +1,22 @@ +from rest_framework import status +from rest_framework.exceptions import APIException, ValidationError + +from app.constants import MAX_GALLERY_SIZE + + +class APINoGalleryFoundForUser(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Ingen galleri ble funnet for brukeren." + + +class NoGalleryFoundForUser(ValidationError): + default_detail = "Ingen galleri ble funnet for brukeren." + + +class APIGalleryIsFull(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = f"Galleriet er fullt med {MAX_GALLERY_SIZE} filer." + + +class GalleryIsFull(ValidationError): + default_detail = f"Galleriet er fullt med {MAX_GALLERY_SIZE} filer." diff --git a/app/files/factories/__init__.py b/app/files/factories/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/files/filters/__init__.py b/app/files/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/files/migrations/0001_initial.py b/app/files/migrations/0001_initial.py new file mode 100644 index 000000000..60e432dc8 --- /dev/null +++ b/app/files/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 4.2.16 on 2024-10-07 16:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserGallery", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "author", + models.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + related_name="user_galleries", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="File", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=80)), + ("url", models.URLField()), + ("description", models.TextField(blank=True)), + ( + "gallery", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="files", + to="files.usergallery", + ), + ), + ], + ), + ] diff --git a/app/files/migrations/0002_remove_file_url_file_file.py b/app/files/migrations/0002_remove_file_url_file_file.py new file mode 100644 index 000000000..5f5ac0f38 --- /dev/null +++ b/app/files/migrations/0002_remove_file_url_file_file.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.1 on 2024-10-31 15:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="file", + name="url", + ), + migrations.AddField( + model_name="file", + name="file", + field=models.URLField(blank=True, max_length=600, null=True), + ), + ] diff --git a/app/files/migrations/__init__.py b/app/files/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/files/mixins.py b/app/files/mixins.py new file mode 100644 index 000000000..1e36f0e47 --- /dev/null +++ b/app/files/mixins.py @@ -0,0 +1,17 @@ +from app.files.exceptions import ( + APIGalleryIsFull, + APINoGalleryFoundForUser, + GalleryIsFull, + NoGalleryFoundForUser, +) +from app.util.mixins import APIErrorsMixin + + +class FileErrorMixin(APIErrorsMixin): + @property + def expected_exceptions(self): + return { + **super().expected_exceptions, + NoGalleryFoundForUser: APINoGalleryFoundForUser, + GalleryIsFull: APIGalleryIsFull, + } diff --git a/app/files/models/__init__.py b/app/files/models/__init__.py new file mode 100644 index 000000000..8952713d6 --- /dev/null +++ b/app/files/models/__init__.py @@ -0,0 +1,2 @@ +from app.files.models.user_gallery import UserGallery +from app.files.models.file import File diff --git a/app/files/models/file.py b/app/files/models/file.py new file mode 100644 index 000000000..8f813bf2d --- /dev/null +++ b/app/files/models/file.py @@ -0,0 +1,68 @@ +from django.db import models +from django.db.models import PROTECT + +from app.common.enums import AdminGroup, Groups +from app.common.permissions import BasePermissionModel, check_has_access +from app.files.models.user_gallery import UserGallery +from app.util.models import BaseModel, OptionalFile + + +class File(BaseModel, BasePermissionModel, OptionalFile): + read_access = AdminGroup.admin() + write_access = AdminGroup.admin() + + title = models.CharField(max_length=80) + + description = models.TextField(blank=True) + gallery = models.ForeignKey( + UserGallery, on_delete=PROTECT, related_name="files", blank=False + ) + + class Meta: + pass + + def __str__(self): + return self.title + + @classmethod + def has_read_permission(cls, request): + return super().has_read_permission(request) + + @classmethod + def has_write_permission(cls, request): + return check_has_access(Groups.TIHLDE, request) + + @classmethod + def has_retrieve_permission(cls, request): + return cls.has_read_permission(request) + + @classmethod + def has_create_permission(cls, request): + return check_has_access(cls.write_access, request) + + @classmethod + def has_update_permission(cls, request): + return check_has_access(cls.write_access, request) + + @classmethod + def has_destroy_permission(cls, request): + return check_has_access(Groups.TIHLDE, request) + + @classmethod + def has_list_permission(cls, request): + return cls.has_read_permission(request) + + def has_object_read_permission(self, request): + return self.has_read_permission(request) + + def has_object_write_permission(self, request): + return self.has_write_permission(request) + + def has_object_retrieve_permission(self, request): + return self.has_object_read_permission(request) + + def has_object_update_permission(self, request): + return self.gallery.author == request.user + + def has_object_destroy_permission(self, request): + return self.gallery.author == request.user diff --git a/app/files/models/user_gallery.py b/app/files/models/user_gallery.py new file mode 100644 index 000000000..238cc4c70 --- /dev/null +++ b/app/files/models/user_gallery.py @@ -0,0 +1,77 @@ +from django.db import models +from django.db.models import PROTECT + +from app.common.enums import AdminGroup +from app.common.permissions import BasePermissionModel +from app.content.models.user import User +from app.util.models import BaseModel + + +class UserGallery(BaseModel, BasePermissionModel): + read_access = AdminGroup.admin() + write_access = AdminGroup.admin() + + author = models.OneToOneField( + User, on_delete=PROTECT, related_name="user_galleries" + ) + + class Meta: + pass + + def __str__(self): + return f"Gallery by {self.author.first_name} {self.author.last_name}" + + @classmethod + def has_read_permission(cls, request): + return super().has_read_permission(request) + + @classmethod + def has_write_permission(cls, request): + return super().has_write_permission(request) + + @classmethod + def has_retrieve_permission(cls, request): + return cls.has_read_permission(request) + + @classmethod + def has_create_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_update_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_destroy_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_list_permission(cls, request): + return cls.has_read_permission(request) + + def has_object_read_permission(self, request): + return self.has_read_permission(request) + + def has_object_write_permission(self, request): + return self.has_write_permission(request) + + def has_object_retrieve_permission(self, request): + return self.has_object_read_permission(request) + + def has_object_update_permission(self, request): + return self.author == request.user + + def has_object_destroy_permission(self, request): + return self.author == request.user + + @classmethod + def get_all_files(cls, user): + return cls.objects.get(author=user).files.all() + + @classmethod + def has_gallery(cls, user): + return cls.objects.filter(author=user).exists() + + @classmethod + def create_gallery(cls, user): + return cls.objects.create(author=user) diff --git a/app/files/serializers/__init__.py b/app/files/serializers/__init__.py new file mode 100644 index 000000000..e44eb26ca --- /dev/null +++ b/app/files/serializers/__init__.py @@ -0,0 +1,6 @@ +from app.files.serializers.user_gallery import UserGallerySerializer +from app.files.serializers.file import ( + FileSerializer, + CreateFileSerializer, + DeleteFileSerializer, +) diff --git a/app/files/serializers/file.py b/app/files/serializers/file.py new file mode 100644 index 000000000..d12dc675b --- /dev/null +++ b/app/files/serializers/file.py @@ -0,0 +1,68 @@ +from app.common.azure_file_handler import AzureFileHandler +from app.common.serializers import BaseModelSerializer +from app.constants import MAX_GALLERY_SIZE +from app.files.exceptions import GalleryIsFull, NoGalleryFoundForUser +from app.files.models import File +from app.files.models.user_gallery import UserGallery + + +class FileSerializer(BaseModelSerializer): + class Meta: + model = File + fields = ( + "id", + "title", + "description", + "file", + "created_at", + "updated_at", + ) + + +class CreateFileSerializer(BaseModelSerializer): + class Meta: + model = File + fields = ( + "title", + "description", + "file", + ) + + def create(self, validated_data): + user = self.context["request"].user + + gallery = UserGallery.objects.filter(author=user).first() + + if not gallery: + raise NoGalleryFoundForUser() + + if gallery.files.count() >= MAX_GALLERY_SIZE: + raise GalleryIsFull() + + validated_data["gallery"] = gallery + + file_instance = super().create(validated_data) + + return file_instance + + +class UpdateFileSerializer(BaseModelSerializer): + + class Meta: + model = File + fields = ( + "title", + "description", + "file", + ) + + +class DeleteFileSerializer(BaseModelSerializer): + class Meta: + model = File + + def delete(self, instance): + azure_handler = AzureFileHandler(url=instance.url) + azure_handler.deleteBlob() + + return super().delete(instance) diff --git a/app/files/serializers/user_gallery.py b/app/files/serializers/user_gallery.py new file mode 100644 index 000000000..8dd6f64aa --- /dev/null +++ b/app/files/serializers/user_gallery.py @@ -0,0 +1,22 @@ +from app.common.serializers import BaseModelSerializer +from app.content.serializers.user import DefaultUserSerializer +from app.files.models.user_gallery import UserGallery + + +class UserGallerySerializer(BaseModelSerializer): + author = DefaultUserSerializer(read_only=True) + + class Meta: + model = UserGallery + fields = ( + "id", + "created_at", + "updated_at", + "author", + ) + + def create(self, validated_data): + user = self.context["request"].user + validated_data["author"] = user + + return super().create(validated_data) diff --git a/app/files/tasks/__init__.py b/app/files/tasks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/files/tests/__init__.py b/app/files/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/files/urls.py b/app/files/urls.py new file mode 100644 index 000000000..02655adc3 --- /dev/null +++ b/app/files/urls.py @@ -0,0 +1,17 @@ +from django.urls import include, path +from rest_framework import routers + +from app.files.views.file import FileViewSet +from app.files.views.upload import delete, upload +from app.files.views.user_gallery import UserGalleryViewSet + +router = routers.DefaultRouter() + +router.register("file", FileViewSet, basename="file") +router.register("user_gallery", UserGalleryViewSet, basename="user_gallery") + +urlpatterns = [ + path("upload/", upload), + path("delete-file///", delete), + path("files/", include(router.urls)), +] diff --git a/app/files/util/__init__.py b/app/files/util/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/files/views/__init__.py b/app/files/views/__init__.py new file mode 100644 index 000000000..3a2ba8b4f --- /dev/null +++ b/app/files/views/__init__.py @@ -0,0 +1,2 @@ +from app.files.views.user_gallery import UserGalleryViewSet +from app.files.views.file import FileViewSet diff --git a/app/files/views/file.py b/app/files/views/file.py new file mode 100644 index 000000000..d99070bf4 --- /dev/null +++ b/app/files/views/file.py @@ -0,0 +1,56 @@ +from rest_framework import status +from rest_framework.response import Response + +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet +from app.files.mixins import FileErrorMixin +from app.files.models.file import File +from app.files.serializers.file import ( + CreateFileSerializer, + FileSerializer, + UpdateFileSerializer, +) + + +class FileViewSet(FileErrorMixin, BaseViewSet): + serializer_class = FileSerializer + permission_classes = [BasicViewPermission] + queryset = File.objects.select_related("gallery").all() + + def retrieve(self, request, *_args, **_kwargs): + """Retrieves a specific file by id""" + file = self.get_object() + serializer = FileSerializer(file, context={"request": request}, many=False) + return Response(data=serializer.data, status=status.HTTP_200_OK) + + def update(self, request, *_args, **_kwargs): + """Updates a specific file by id""" + file = self.get_object() + + serializer = UpdateFileSerializer( + file, data=request.data, partial=True, context={"request": request} + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def create(self, request, *args, **kwargs): + """Creates a file""" + serializer = CreateFileSerializer( + data=request.data, context={"request": request} + ) + if serializer.is_valid(): + file = super().perform_create(serializer) + return_serializer = CreateFileSerializer(file) + return Response(data=return_serializer.data, status=status.HTTP_201_CREATED) + return Response( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def delete(self, request, *_args, **_kwargs): + """Deletes a specific file by id""" + file = self.get_object() + file.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/app/content/views/upload.py b/app/files/views/upload.py similarity index 100% rename from app/content/views/upload.py rename to app/files/views/upload.py diff --git a/app/files/views/user_gallery.py b/app/files/views/user_gallery.py new file mode 100644 index 000000000..c362dcc8a --- /dev/null +++ b/app/files/views/user_gallery.py @@ -0,0 +1,41 @@ +from rest_framework import status +from rest_framework.response import Response + +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet +from app.files.models.user_gallery import UserGallery +from app.files.serializers.user_gallery import UserGallerySerializer + + +class UserGalleryViewSet(BaseViewSet): + serializer_class = UserGallerySerializer + permission_classes = [BasicViewPermission] + queryset = UserGallery.objects.all() + + def retrieve(self, request, *_args, **_kwargs): + """Retrieve all files in gallery""" + try: + files = UserGallery.get_all_files(request.user) + serializer = UserGallerySerializer(files, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except UserGallery.DoesNotExist: + return Response( + {"detail": "Galleriet finnes ikke"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def create(self, request, *args, **kwargs): + """Create a gallery for the current user""" + serializer = UserGallerySerializer( + data=request.data, context={"request": request} + ) + + if serializer.is_valid(): + user_gallery = super().perform_create(serializer) + return_serializer = UserGallerySerializer(user_gallery) + return Response(return_serializer.data, status=status.HTTP_201_CREATED) + + return Response( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/app/group/views/group.py b/app/group/views/group.py index a96f19cea..3533e33cc 100644 --- a/app/group/views/group.py +++ b/app/group/views/group.py @@ -2,7 +2,6 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.status import HTTP_201_CREATED from app.common.mixins import ActionMixin from app.common.permissions import BasicViewPermission @@ -73,7 +72,7 @@ def create(self, request, *args, **kwargs): if serializer.is_valid(): group = super().perform_create(serializer) return_serializer = SimpleGroupSerializer(group) - return Response(data=return_serializer.data, status=HTTP_201_CREATED) + return Response(data=return_serializer.data, status=status.HTTP_201_CREATED) return Response( {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST, diff --git a/app/group/views/membership.py b/app/group/views/membership.py index 1e4356a8e..f98db0ab7 100644 --- a/app/group/views/membership.py +++ b/app/group/views/membership.py @@ -58,13 +58,13 @@ def update(self, request, *args, **kwargs): UserNotificationSettingType.GROUP_MEMBERSHIP, ).add_paragraph(f"Hei, {membership.user.first_name}!").add_paragraph( f'Du har blitt gjort til leder i gruppen "{membership.group.name}". Som leder får du tilgang til ' - f'diverse funksjonalitet på nettsiden. Du kan finne administrasjonspanelene du har tilgang til ' + f"diverse funksjonalitet på nettsiden. Du kan finne administrasjonspanelene du har tilgang til " f'under "Admin" i din profil.' ).add_paragraph( f'Som leder har du også fått administratorrettigheter i "{membership.group.name}". Det innebærer ' - f'at du kan legge til og fjerne medlemmer, endre tidligere medlemskap og administrere gruppens ' - f'spørreskjemaer. I gruppens innstillinger kan du endre gruppens beskrivelse og logo, samt ' - f'aktivere botsystemet og velge en botsjef.' + f"at du kan legge til og fjerne medlemmer, endre tidligere medlemskap og administrere gruppens " + f"spørreskjemaer. I gruppens innstillinger kan du endre gruppens beskrivelse og logo, samt " + f"aktivere botsystemet og velge en botsjef." ).add_paragraph( "Gratulerer så mye og lykke til med ledervervet!" ).add_link( @@ -92,9 +92,11 @@ def create(self, request, *args, **kwargs): admin_text = " " if group.type in [GroupType.BOARD, GroupType.SUBGROUP]: - admin_text = (f'Som medlem av "{group.name}" har du også fått tilgang til diverse funksjonalitet på ' - f'nettsiden. Du kan finne administrasjonspanelene du har tilgang til under "Admin" ' - f'i din profil. ') + admin_text = ( + f'Som medlem av "{group.name}" har du også fått tilgang til diverse funksjonalitet på ' + f'nettsiden. Du kan finne administrasjonspanelene du har tilgang til under "Admin" ' + f"i din profil. " + ) Notify( [membership.user], diff --git a/app/settings.py b/app/settings.py index 26e8c1fe4..f9b3bcf6e 100644 --- a/app/settings.py +++ b/app/settings.py @@ -108,6 +108,7 @@ "app.emoji", "app.feedback", "app.codex", + "app.files", ] # Django rest framework diff --git a/app/tests/conftest.py b/app/tests/conftest.py index fbdccddf2..eb48220ec 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -33,6 +33,7 @@ NewsReactionFactory, ) from app.feedback.factories import BugFactory, IdeaFactory +from app.files.models.user_gallery import UserGallery from app.forms.tests.form_factories import FormFactory, SubmissionFactory from app.group.factories import GroupFactory, MembershipFactory from app.group.factories.fine_factory import FineFactory @@ -314,6 +315,18 @@ def codex_event_registration(): return CodexEventRegistrationFactory() +@pytest.fixture +def user_gallery(member): + """Creates a gallery for the member.""" + return UserGallery.objects.create(author=member) + + +@pytest.fixture +def admin_gallery(admin_user): + """Creates a gallery for the admin user.""" + return UserGallery.objects.create(author=admin_user) + + @pytest.fixture() def new_admin_user(): admin = UserFactory() diff --git a/app/tests/files/__init__.py b/app/tests/files/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/tests/files/test_file_integration.py b/app/tests/files/test_file_integration.py new file mode 100644 index 000000000..20660c7b3 --- /dev/null +++ b/app/tests/files/test_file_integration.py @@ -0,0 +1,272 @@ +from rest_framework import status + +import pytest + +from app.common.enums import AdminGroup, Groups +from app.constants import MAX_GALLERY_SIZE +from app.files.models.file import File +from app.files.models.user_gallery import UserGallery +from app.util.test_utils import ( + add_user_to_group_with_name, + get_api_client, + remove_user_from_group_with_name, +) + +FILE_URL = "/files/file/" + + +def _get_file_url(file=None): + return f"{FILE_URL}{file.id}/" if file else f"{FILE_URL}" + + +def _get_file_post_data(): + return { + "title": "Sample File", + "url": "https://example.com/file.pdf", + "description": "This is a sample file.", + } + + +def _get_file_put_data(file): + return {"title": file.title, "description": "Updated description."} + + +def _create_file(user, gallery=None): + """Helper function to create a file in the database.""" + if gallery is None: + gallery, _ = UserGallery.objects.get_or_create(author=user) + + return File.objects.create( + title="Sample File", + description="This is a sample file.", + gallery=gallery, + ) + + +@pytest.mark.django_db +def test_list_files_as_anonymous_user(default_client): + """Tests if an anonymous user cannot list files""" + url = _get_file_url() + response = default_client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_list_files_as_member(member): + """Tests if a member can list files""" + client = get_api_client(user=member) + url = _get_file_url() + response = client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_list_files_as_admin(admin_user): + """Tests if an admin user can list files""" + client = get_api_client(user=admin_user) + url = _get_file_url() + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_create_file_as_member(member): + """Tests if a member can create a file""" + client = get_api_client(user=member) + url = _get_file_url() + data = _get_file_post_data() + + response = client.post(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_create_file_as_admin(admin_user, admin_gallery): + """Tests if an admin can create a file""" + client = get_api_client(user=admin_user) + url = _get_file_url() + data = _get_file_post_data() + + response = client.post(url, data) + + assert response.status_code == status.HTTP_201_CREATED + assert File.objects.filter(title=data["title"]).exists() + file = File.objects.get(title=data["title"]) + assert file.gallery == admin_gallery + + +@pytest.mark.django_db +def test_create_file_as_admin_no_gallery(admin_user): + """Tests if an admin can create a file without a gallery""" + client = get_api_client(user=admin_user) + url = _get_file_url() + data = _get_file_post_data() + + response = client.post(url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_create_file_gallery_full(admin_user, admin_gallery): + """Tests if the admin cannot create a file when gallery is full (>=50 files)""" + for _ in range(MAX_GALLERY_SIZE): + _create_file(admin_user, admin_gallery) + + client = get_api_client(user=admin_user) + url = _get_file_url() + data = _get_file_post_data() + + response = client.post(url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +# noinspection PyTypeChecker +@pytest.mark.django_db +def test_retrieve_file_does_not_exist(admin_user): + """Tests retrieving a non-existent file""" + client = get_api_client(user=admin_user) + url = _get_file_url(file=None) + "999/" + + response = client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_delete_file_as_admin(admin_user, admin_gallery): + """Tests if an admin user can delete their file""" + file = _create_file(admin_user, admin_gallery) + + client = get_api_client(user=admin_user) + url = _get_file_url(file) + + response = client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not File.objects.filter(id=file.id).exists() + + +@pytest.mark.django_db +def test_delete_file_as_non_author(member, new_admin_user, admin_gallery): + """Tests if a non-author cannot delete another user's file""" + add_user_to_group_with_name(member, AdminGroup.HS) + + admin_gallery = UserGallery.objects.create(author=new_admin_user) + + file = _create_file(new_admin_user, admin_gallery) + + client = get_api_client(user=member) + url = _get_file_url(file) + + response = client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert File.objects.filter(id=file.id).exists() + + +@pytest.mark.django_db +def test_update_file_as_admin(admin_user, admin_gallery): + """Tests if an admin can update a file without providing a new file.""" + file = _create_file(admin_user, admin_gallery) + client = get_api_client(user=admin_user) + url = _get_file_url(file) + + data = { + "title": "Updated Sample File", + "description": "This is an updated sample file.", + } + + response = client.put(url, data) + + assert response.status_code == status.HTTP_200_OK + file.refresh_from_db() + assert file.title == "Updated Sample File" + assert file.description == "This is an updated sample file." + + +@pytest.mark.django_db +def test_update_file_as_member(member, user_gallery): + """Tests if a member cannot update a file.""" + file = _create_file(member, user_gallery) + client = get_api_client(user=member) + url = _get_file_url(file) + + data = { + "title": "Attempted Update by Member", + } + + response = client.put(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_update_file_invalid_data(admin_user, admin_gallery): + """Tests if an admin cannot update a file with invalid data.""" + file = _create_file(admin_user, admin_gallery) + client = get_api_client(user=admin_user) + url = _get_file_url(file) + + data = { + "title": "", + } + + response = client.put(url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "title" in response.data + + +@pytest.mark.django_db +def test_update_non_existent_file(admin_user): + """Tests if an admin cannot update a non-existent file.""" + client = get_api_client(user=admin_user) + url = _get_file_url() + "999/" + + data = { + "title": "Non-Existent File", + } + + response = client.put(url, data) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_partial_update_file(admin_user, admin_gallery): + """Tests if an admin can partially update a file.""" + file = _create_file(admin_user, admin_gallery) + client = get_api_client(user=admin_user) + url = _get_file_url(file) + + data = { + "description": "This is a partially updated description.", + } + + response = client.patch(url, data) + + assert response.status_code == status.HTTP_200_OK + file.refresh_from_db() + assert file.description == "This is a partially updated description." + + +@pytest.mark.django_db +def test_create_file_as_admin_and_delete_as_tihlde_user(admin_user, admin_gallery): + """Tests if an admin can create a file and delete it after being removed as an admin.""" + file = _create_file(admin_user, admin_gallery) + url = _get_file_url(file) + + remove_user_from_group_with_name(admin_user, AdminGroup.HS) + add_user_to_group_with_name(admin_user, Groups.TIHLDE) + + client = get_api_client(user=admin_user) + + delete_response = client.delete(url) + + assert delete_response.status_code == status.HTTP_204_NO_CONTENT diff --git a/app/tests/files/test_user_gallery_integration.py b/app/tests/files/test_user_gallery_integration.py new file mode 100644 index 000000000..5b9c1bdf5 --- /dev/null +++ b/app/tests/files/test_user_gallery_integration.py @@ -0,0 +1,52 @@ +from rest_framework import status + +import pytest + +from app.files.models.user_gallery import UserGallery +from app.util.test_utils import get_api_client + + +def _get_user_gallery_url(user_gallery=None): + return ( + f"/files/user_gallery/{user_gallery.id}/" + if user_gallery + else "/files/user_gallery/" + ) + + +@pytest.mark.django_db +def test_create_user_gallery(admin_user): + """Tests if an admin can create a gallery""" + client = get_api_client(user=admin_user) + url = _get_user_gallery_url() + + assert not UserGallery.has_gallery(admin_user) + + response = client.post(url) + + assert response.status_code == status.HTTP_201_CREATED + assert UserGallery.objects.filter(author=admin_user).exists() + + user_gallery = UserGallery.objects.get(author=admin_user) + assert user_gallery.author == admin_user + + +@pytest.mark.django_db +def test_delete_admin_gallery(admin_user): + """Tests if an admin can delete their gallery""" + client = get_api_client(user=admin_user) + + post_url = _get_user_gallery_url() + response = client.post(post_url) + + assert response.status_code == status.HTTP_201_CREATED + + user_gallery_id = response.data["id"] + user_gallery = UserGallery.objects.get(id=user_gallery_id) + + delete_url = _get_user_gallery_url(user_gallery) + + response = client.delete(delete_url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not UserGallery.objects.filter(id=user_gallery.id).exists() diff --git a/app/urls.py b/app/urls.py index 301f23427..74acacb4e 100644 --- a/app/urls.py +++ b/app/urls.py @@ -47,6 +47,7 @@ path("", include("app.content.urls")), path("", include("app.group.urls")), path("", include("app.payment.urls")), + path("", include("app.files.urls")), path("auth/", include("app.authentication.urls")), path("badges/", include("app.badge.urls")), path("forms/", include("app.forms.urls")), diff --git a/app/util/models.py b/app/util/models.py index 65d4589a0..4d3034773 100644 --- a/app/util/models.py +++ b/app/util/models.py @@ -17,3 +17,12 @@ class OptionalImage(models.Model): class Meta: abstract = True + + +class OptionalFile(models.Model): + """Abstract model for models containing a file""" + + file = models.URLField(max_length=600, null=True, blank=True) + + class Meta: + abstract = True diff --git a/app/util/test_utils.py b/app/util/test_utils.py index 78682ec2c..2ab0818f2 100644 --- a/app/util/test_utils.py +++ b/app/util/test_utils.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ObjectDoesNotExist from django.db.transaction import atomic from rest_framework.authtoken.models import Token from rest_framework.test import APIClient @@ -24,6 +25,9 @@ def get_api_client(user=None, group_name=None): def add_user_to_group_with_name( user, group_name, group_type=None, membership_type=MembershipType.MEMBER ): + """ + Adds a user to a group with the given name. + """ if not group_type: group_type = get_group_type_from_group_name(group_name) group = Group.objects.get_or_create(name=group_name, type=group_type)[0] @@ -33,6 +37,35 @@ def add_user_to_group_with_name( return group +@atomic +def remove_user_from_group_with_name(user, group_name, group_type=None): + """ + Removes a user from a group with the given name. + """ + if not group_type: + group_type = get_group_type_from_group_name(group_name) + + try: + group = Group.objects.get(name=group_name, type=group_type) + except Group.DoesNotExist: + raise ObjectDoesNotExist( + f"Group with name '{group_name}' and type '{group_type}' does not exist." + ) + + try: + membership = Membership.objects.get(group=group, user=user) + membership.delete() + except Membership.DoesNotExist: + raise ObjectDoesNotExist( + f"User '{user}' is not a member of the group '{group_name}'." + ) + + if not Membership.objects.filter(group=group).exists(): + group.delete() + + return group + + def get_group_type_from_group_name(group_name): if group_name == AdminGroup.HS: return GroupType.BOARD