diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb3d57be..032fbfd30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ ## Neste versjon +## Versjon 2024.12.26 +- ✨ **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.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. 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..ef01a5330 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -18,11 +18,10 @@ UserCalendarEvents, UserViewSet, accept_form, - delete, register_with_feide, send_email, - upload, ) +from app.files.views.upload import delete, upload router = routers.DefaultRouter() @@ -54,8 +53,8 @@ re_path(r"", include(router.urls)), path("accept-form/", accept_form), path("upload/", upload), - path("send-email/", send_email), path("delete-file///", delete), + path("send-email/", send_email), 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/registration.py b/app/content/views/registration.py index 4aa3bf68c..c41a86823 100644 --- a/app/content/views/registration.py +++ b/app/content/views/registration.py @@ -13,6 +13,7 @@ from app.common.pagination import BasePagination from app.common.permissions import ( BasicViewPermission, + check_has_access, is_admin_group_user, is_admin_user, ) @@ -157,7 +158,22 @@ def _admin_unregister(self, registration): def add_registration(self, request, *_args, **_kwargs): """Add registration to event for admins""" - if not is_admin_group_user(request): + event_id = self.kwargs.get("event_id", None) + user_id = request.data["user"] + + event = get_object_or_404(Event, id=event_id) + user = get_object_or_404(User, user_id=user_id) + + organizing_group = event.organizer + + is_member_or_leader_of_organizing_group = check_has_access( + [organizing_group], request + ) + + if ( + not is_admin_group_user(request) + and not is_member_or_leader_of_organizing_group + ): return Response( { "detail": "Du har ikke tillatelse til å opprette en påmelding på dette arrangementet" @@ -165,12 +181,6 @@ def add_registration(self, request, *_args, **_kwargs): status=status.HTTP_403_FORBIDDEN, ) - event_id = self.kwargs.get("event_id", None) - user_id = request.data["user"] - - event = get_object_or_404(Event, id=event_id) - user = get_object_or_404(User, user_id=user_id) - if not user.accepts_event_rules: return Response( { 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/feedback/factories/bug_factory.py b/app/feedback/factories/bug_factory.py index 4ff95a210..3b8c61af7 100644 --- a/app/feedback/factories/bug_factory.py +++ b/app/feedback/factories/bug_factory.py @@ -11,3 +11,7 @@ class Meta: title = factory.Sequence(lambda n: f"Bug{n}") author = factory.SubFactory(UserFactory) + description = factory.Faker("text") + url = factory.Faker("url") + platform = factory.Faker("word") + browser = factory.Faker("word") diff --git a/app/feedback/factories/idea_factory.py b/app/feedback/factories/idea_factory.py index c923c4710..463f92c33 100644 --- a/app/feedback/factories/idea_factory.py +++ b/app/feedback/factories/idea_factory.py @@ -11,3 +11,4 @@ class Meta: title = factory.Sequence(lambda n: f"Idea{n}") author = factory.SubFactory(UserFactory) + description = factory.Faker("text") diff --git a/app/feedback/filters/feedback.py b/app/feedback/filters/feedback.py new file mode 100644 index 000000000..9df76817e --- /dev/null +++ b/app/feedback/filters/feedback.py @@ -0,0 +1,46 @@ +from django.db.models import Q +from django_filters import rest_framework as filters +from django_filters.rest_framework import OrderingFilter + +from app.feedback.models import Feedback +from app.feedback.models.bug import Bug +from app.feedback.models.idea import Idea + + +class FeedbackFilter(filters.FilterSet): + feedback_type = filters.CharFilter( + method="filter_feedback_type", label="List of feedback type" + ) + + status = filters.CharFilter( + method="filter_status", + label="List of feedback status", + ) + + ordering = OrderingFilter( + fields=( + "created_at", + "updated_at", + ) + ) + + def filter_feedback_type(self, queryset, _name, feedback_type): + if feedback_type == "Idea": + return queryset.filter(Q(instance_of=Idea)) + elif feedback_type == "Bug": + return queryset.filter(Q(instance_of=Bug)) + else: + return queryset + + def filter_status(self, queryset, _name, value): + return queryset.filter(status=value) + + class Meta: + model = Feedback + fields = [ + "title", + "author", + "status", + "created_at", + "updated_at", + ] diff --git a/app/feedback/migrations/0002_alter_feedback_options_bug_browser_bug_platform_and_more.py b/app/feedback/migrations/0002_alter_feedback_options_bug_browser_bug_platform_and_more.py new file mode 100644 index 000000000..1b4150808 --- /dev/null +++ b/app/feedback/migrations/0002_alter_feedback_options_bug_browser_bug_platform_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.5 on 2024-10-21 18:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("feedback", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="feedback", + options={"ordering": ("-created_at",)}, + ), + migrations.AddField( + model_name="bug", + name="Browser", + field=models.CharField(default="", max_length=200), + ), + migrations.AddField( + model_name="bug", + name="Platform", + field=models.CharField(default="", max_length=200), + ), + migrations.AddField( + model_name="bug", + name="Url", + field=models.URLField(blank=True, null=True), + ), + ] diff --git a/app/feedback/migrations/0003_rename_browser_bug_browser_and_more.py b/app/feedback/migrations/0003_rename_browser_bug_browser_and_more.py new file mode 100644 index 000000000..9ffbc39e7 --- /dev/null +++ b/app/feedback/migrations/0003_rename_browser_bug_browser_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.5 on 2024-10-21 22:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("feedback", "0002_alter_feedback_options_bug_browser_bug_platform_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="bug", + old_name="Browser", + new_name="browser", + ), + migrations.RenameField( + model_name="bug", + old_name="Platform", + new_name="platform", + ), + migrations.RenameField( + model_name="bug", + old_name="Url", + new_name="url", + ), + ] diff --git a/app/feedback/models/bug.py b/app/feedback/models/bug.py index 39962659d..f979d5f29 100644 --- a/app/feedback/models/bug.py +++ b/app/feedback/models/bug.py @@ -1,5 +1,9 @@ +from django.db import models + from app.feedback.models.feedback import Feedback class Bug(Feedback): - pass + url = models.URLField(max_length=200, blank=True, null=True) + browser = models.CharField(max_length=200, default="") + platform = models.CharField(max_length=200, default="") diff --git a/app/feedback/models/feedback.py b/app/feedback/models/feedback.py index 13d698db2..528eb28f9 100644 --- a/app/feedback/models/feedback.py +++ b/app/feedback/models/feedback.py @@ -26,7 +26,7 @@ def __str__(self): return f"{self.title} - {self.status}" class Meta: - ordering = ("created_at",) + ordering = ("-created_at",) @classmethod def has_read_permission(cls, request): diff --git a/app/feedback/serializers/__init__.py b/app/feedback/serializers/__init__.py index 25f74a27d..d788cd869 100644 --- a/app/feedback/serializers/__init__.py +++ b/app/feedback/serializers/__init__.py @@ -1,3 +1,11 @@ -from app.feedback.serializers.bug import BugListSerializer -from app.feedback.serializers.idea import IdeaListSerializer +from app.feedback.serializers.bug import ( + BugDetailSerializer, + BugCreateSerializer, + BugUpdateSerializer, +) +from app.feedback.serializers.idea import ( + IdeaDetailSerializer, + IdeaCreateSerializer, + IdeaUpdateSerializer, +) from app.feedback.serializers.feedback import FeedbackListPolymorphicSerializer diff --git a/app/feedback/serializers/bug.py b/app/feedback/serializers/bug.py index 850462566..60d85c2dc 100644 --- a/app/feedback/serializers/bug.py +++ b/app/feedback/serializers/bug.py @@ -3,7 +3,7 @@ from app.feedback.models.bug import Bug -class BugListSerializer(BaseModelSerializer): +class BugSerializer(BaseModelSerializer): author = SimpleUserSerializer(read_only=True) class Meta: @@ -14,4 +14,51 @@ class Meta: "status", "created_at", "author", + "description", + ) + + +class BugCreateSerializer(BaseModelSerializer): + class Meta: + model = Bug + fields = ( + "title", + "description", + ) + + def create(self, validated_data): + user = self.context["request"].user + validated_data["author"] = user + + return super().create(validated_data) + + +class BugUpdateSerializer(BaseModelSerializer): + class Meta: + model = Bug + fields = ( + "title", + "description", + "status", + ) + + def update(self, instance, validated_data): + return super().update(instance, validated_data) + + +class BugDetailSerializer(BaseModelSerializer): + author = SimpleUserSerializer(read_only=True) + + class Meta: + model = Bug + fields = ( + "id", + "title", + "description", + "status", + "created_at", + "author", + "url", + "platform", + "browser", ) diff --git a/app/feedback/serializers/feedback.py b/app/feedback/serializers/feedback.py index 588fc6800..c0228e210 100644 --- a/app/feedback/serializers/feedback.py +++ b/app/feedback/serializers/feedback.py @@ -2,15 +2,15 @@ from app.common.serializers import BaseModelSerializer from app.feedback.models import Bug, Feedback, Idea -from app.feedback.serializers import BugListSerializer, IdeaListSerializer +from app.feedback.serializers import BugDetailSerializer, IdeaDetailSerializer class FeedbackListPolymorphicSerializer(PolymorphicSerializer, BaseModelSerializer): resource_type_field_name = "feedback_type" model_serializer_mapping = { - Bug: BugListSerializer, - Idea: IdeaListSerializer, + Bug: BugDetailSerializer, + Idea: IdeaDetailSerializer, } class Meta: @@ -21,60 +21,5 @@ class Meta: "status", "created_at", "author", - ) - - -class IdeaCreateSerializer(BaseModelSerializer): - class Meta: - model = Idea - fields = ( - "title", "description", ) - - def create(self, validated_data): - user = self.context["request"].user - validated_data["author"] = user - - return super().create(validated_data) - - -class BugCreateSerializer(BaseModelSerializer): - class Meta: - model = Bug - fields = ( - "title", - "description", - ) - - def create(self, validated_data): - user = self.context["request"].user - validated_data["author"] = user - - return super().create(validated_data) - - -class IdeaUpdateSerializer(BaseModelSerializer): - class Meta: - model = Feedback - fields = ( - "title", - "description", - "status", - ) - - def update(self, instance, validated_data): - return super().update(instance, validated_data) - - -class BugUpdateSerializer(BaseModelSerializer): - class Meta: - model = Feedback - fields = ( - "title", - "description", - "status", - ) - - def update(self, instance, validated_data): - return super().update(instance, validated_data) diff --git a/app/feedback/serializers/idea.py b/app/feedback/serializers/idea.py index 6b415eba1..6fe8b9aef 100644 --- a/app/feedback/serializers/idea.py +++ b/app/feedback/serializers/idea.py @@ -3,7 +3,7 @@ from app.feedback.models.idea import Idea -class IdeaListSerializer(BaseModelSerializer): +class IdeaSerializer(BaseModelSerializer): author = SimpleUserSerializer(read_only=True) class Meta: @@ -14,4 +14,48 @@ class Meta: "status", "created_at", "author", + "description", + ) + + +class IdeaCreateSerializer(BaseModelSerializer): + class Meta: + model = Idea + fields = ( + "title", + "description", + ) + + def create(self, validated_data): + user = self.context["request"].user + validated_data["author"] = user + + return super().create(validated_data) + + +class IdeaUpdateSerializer(BaseModelSerializer): + class Meta: + model = Idea + fields = ( + "title", + "description", + "status", + ) + + def update(self, instance, validated_data): + return super().update(instance, validated_data) + + +class IdeaDetailSerializer(BaseModelSerializer): + author = SimpleUserSerializer(read_only=True) + + class Meta: + model = Idea + fields = ( + "id", + "title", + "description", + "status", + "created_at", + "author", ) diff --git a/app/feedback/views/feedback.py b/app/feedback/views/feedback.py index 0ccbc653c..6501059ee 100644 --- a/app/feedback/views/feedback.py +++ b/app/feedback/views/feedback.py @@ -1,11 +1,13 @@ -from rest_framework import status +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, status from rest_framework.response import Response from app.common.pagination import BasePagination from app.common.permissions import BasicViewPermission from app.common.viewsets import BaseViewSet +from app.feedback.filters.feedback import FeedbackFilter from app.feedback.models.feedback import Feedback -from app.feedback.serializers.feedback import ( +from app.feedback.serializers import ( BugCreateSerializer, BugUpdateSerializer, FeedbackListPolymorphicSerializer, @@ -20,6 +22,14 @@ class FeedbackViewSet(BaseViewSet): pagination_class = BasePagination permission_classes = [BasicViewPermission] + filter_backends = [DjangoFilterBackend, filters.SearchFilter] + filterset_class = FeedbackFilter + search_fields = [ + "title", + "author__first_name", + "author__last_name", + ] + def create(self, request, *_args, **_kwargs): data = request.data diff --git a/app/feedback/tests/__init__.py b/app/files/__init__.py similarity index 100% rename from app/feedback/tests/__init__.py rename to app/files/__init__.py 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/tests/index/__init__.py b/app/files/enums.py similarity index 100% rename from app/tests/index/__init__.py rename to app/files/enums.py 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..27da1ac43 --- /dev/null +++ b/app/files/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework import routers + +from app.files.views.file import FileViewSet +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("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/gallery/factories/_init_.py b/app/gallery/factories/_init_.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/gallery/factories/album_factory.py b/app/gallery/factories/album_factory.py new file mode 100644 index 000000000..e2abd6592 --- /dev/null +++ b/app/gallery/factories/album_factory.py @@ -0,0 +1,15 @@ +import factory +from factory.django import DjangoModelFactory + +from app.gallery.models.album import Album + + +class AlbumFactory(DjangoModelFactory): + class Meta: + model = Album + + id = factory.Sequence(lambda n: f"picture_{n}") + image = factory.Faker("image") + title = factory.Faker("title") + image_alt = factory.Faker("image_alt") + description = factory.Faker("description") diff --git a/app/gallery/migrations/0003_alter_album_options.py b/app/gallery/migrations/0003_alter_album_options.py new file mode 100644 index 000000000..5d8f724bd --- /dev/null +++ b/app/gallery/migrations/0003_alter_album_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.5 on 2024-10-21 17:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("gallery", "0002_remove_picture_album_album_id_alter_album_slug"), + ] + + operations = [ + migrations.AlterModelOptions( + name="album", + options={"ordering": ["-created_at"]}, + ), + ] diff --git a/app/gallery/models/album.py b/app/gallery/models/album.py index 43dfe6722..b3caf6821 100644 --- a/app/gallery/models/album.py +++ b/app/gallery/models/album.py @@ -18,6 +18,9 @@ class Album(BaseModel, BasePermissionModel, OptionalImage): slug = models.SlugField(max_length=50, primary_key=False) write_access = AdminGroup.all() + class Meta: + ordering = ["-created_at"] + def __str__(self): return self.title diff --git a/app/group/migrations/0021_alter_group_description.py b/app/group/migrations/0021_alter_group_description.py new file mode 100644 index 000000000..cf6af11e1 --- /dev/null +++ b/app/group/migrations/0021_alter_group_description.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2024-12-26 09:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("group", "0020_alter_membership_membership_type_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="group", + name="description", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/app/group/models/group.py b/app/group/models/group.py index 6d4bbded3..7b9111285 100644 --- a/app/group/models/group.py +++ b/app/group/models/group.py @@ -19,7 +19,7 @@ class Group(OptionalImage, BaseModel, BasePermissionModel): name = models.CharField(max_length=50) slug = models.SlugField(max_length=50, primary_key=True) - description = models.TextField(max_length=1000, null=True, blank=True) + description = models.TextField(null=True, blank=True) contact_email = models.EmailField(max_length=200, null=True, blank=True) fine_info = models.TextField(default="", blank=True) type = models.CharField( 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/content/test_registration_integration.py b/app/tests/content/test_registration_integration.py index 86d111158..eb7a39909 100644 --- a/app/tests/content/test_registration_integration.py +++ b/app/tests/content/test_registration_integration.py @@ -4,7 +4,7 @@ import pytest -from app.common.enums import AdminGroup +from app.common.enums import AdminGroup, Groups from app.common.enums import NativeGroupType as GroupType from app.common.enums import NativeMembershipType as MembershipType from app.common.enums import NativeUserStudy as StudyType @@ -1038,6 +1038,110 @@ def test_add_registration_to_event_as_member(member, event): assert response.status_code == status.HTTP_403_FORBIDDEN +@pytest.mark.django_db +@pytest.mark.parametrize( + "group_name", + [ + Groups.JUBKOM, + Groups.REDAKSJONEN, + Groups.FONDET, + Groups.PLASK, + Groups.DRIFT, + ], +) +def test_add_registration_to_event_as_group_member(event, member, group_name): + """ + A member of a specific group (not part of AdminGroup) should be able to add a + registration to an event if their group organized it. + """ + + member_group = add_user_to_group_with_name( + member, group_name, GroupType.SUBGROUP, MembershipType.MEMBER + ) + + event.organizer = member_group + event.save() + + data = {"user": member.user_id, "event": event.id} + url = f"{_get_registration_url(event=event)}add/" + + client = get_api_client(user=member) + response = client.post(url, data) + + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "group_name", + [ + Groups.JUBKOM, + Groups.REDAKSJONEN, + Groups.FONDET, + Groups.PLASK, + Groups.DRIFT, + ], +) +def test_add_registration_to_event_as_group_member_of_non_organizing_group( + event, member, group_name +): + """ + A member of a specific group (not part of AdminGroup) should NOT be able to add a + registration to an event if their group did not organize it. + """ + add_user_to_group_with_name( + member, group_name, GroupType.SUBGROUP, MembershipType.MEMBER + ) + + event.organizer = GroupFactory(name="Different Organizer") + event.save() + + data = {"user": member.user_id, "event": event.id} + url = f"{_get_registration_url(event=event)}add/" + + client = get_api_client(user=member) + response = client.post(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "group_name", + [ + Groups.JUBKOM, + Groups.REDAKSJONEN, + Groups.FONDET, + Groups.PLASK, + Groups.DRIFT, + ], +) +def test_add_registration_when_event_is_full(event, member, group_name): + """ + A member of the organizing group should be able to add a registration to an event + for another member even when the event is full, and the registration should be added to the waitlist. + """ + + member_group = add_user_to_group_with_name( + member, group_name, GroupType.SUBGROUP, MembershipType.MEMBER + ) + + event.organizer = member_group + event.limit = 1 + event.save() + + RegistrationFactory(event=event) + + data = {"user": member.user_id, "event": event.id} + url = f"{_get_registration_url(event=event)}add/" + + client = get_api_client(user=member) + response = client.post(url, data) + + assert response.status_code == status.HTTP_201_CREATED + assert event.registrations.get(user=member).is_on_wait + + @pytest.mark.django_db @pytest.mark.parametrize( ("order_status", "status_code"), diff --git a/app/tests/feedback/__init__.py b/app/tests/feedback/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/tests/index/test_feedback_integration.py b/app/tests/feedback/test_feedback_integration.py similarity index 50% rename from app/tests/index/test_feedback_integration.py rename to app/tests/feedback/test_feedback_integration.py index 890c8ad45..fd8686dcb 100644 --- a/app/tests/index/test_feedback_integration.py +++ b/app/tests/feedback/test_feedback_integration.py @@ -2,6 +2,9 @@ import pytest +from app.feedback.enums import Status +from app.feedback.factories.bug_factory import BugFactory +from app.feedback.factories.idea_factory import IdeaFactory from app.util.test_utils import get_api_client FEEDBACK_BASE_URL = "/feedbacks/" @@ -16,11 +19,12 @@ def get_data(type): @pytest.mark.django_db -def test_list_feedback_with_both_bug_and_idea_as_member( - member, feedback_bug, feedback_idea -): +def test_list_feedback_with_both_bug_and_idea_as_member(member): """All members should be able to list all types of feedbacks.""" + BugFactory(author=member) + IdeaFactory(author=member) + url = FEEDBACK_BASE_URL client = get_api_client(member) response = client.get(url) @@ -32,8 +36,8 @@ def test_list_feedback_with_both_bug_and_idea_as_member( assert response.status_code == status.HTTP_200_OK assert data["count"] == 2 - assert bug_type - assert idea_type + assert len(bug_type) == 1 + assert len(idea_type) == 1 @pytest.mark.django_db @@ -147,9 +151,6 @@ def test_destroy_your_own_feedback_as_member(member, type): initial_response = client.post(url, data=data) - print(initial_response.data) - print(f"Author: {initial_response.data['author']}") - feedback_id = initial_response.data["id"] url = f"{FEEDBACK_BASE_URL}{feedback_id}/" @@ -172,3 +173,123 @@ def test_destroy_feedback_as_anonymous_user(default_client, type): response = default_client.delete(url, data=data) assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_retrieve_details_bug_as_member(member): + """A member should be able to retrieve a bug feedback""" + feedback_bug = BugFactory(author=member) + + url = f"{FEEDBACK_BASE_URL}{feedback_bug.id}/" + client = get_api_client(member) + response = client.get(url) + + assert response.data["feedback_type"] == "Bug" + assert response.data["url"] == feedback_bug.url + assert response.data["browser"] == feedback_bug.browser + assert response.data["platform"] == feedback_bug.platform + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_retrieve_details_idea_as_member(member): + """A member should be able to retrieve an idea feedback""" + feedback_idea = IdeaFactory(author=member) + + url = f"{FEEDBACK_BASE_URL}{feedback_idea.id}/" + client = get_api_client(member) + response = client.get(url) + + assert response.data["feedback_type"] == "Idea" + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_retrieve_details_bug_as_anonymous_user(default_client, member): + """Non TIHLDE users should not be able to retrieve bug feedbacks""" + feedback_bug = BugFactory(author=member) + + url = f"{FEEDBACK_BASE_URL}{feedback_bug.id}/" + response = default_client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_retrieve_details_idea_as_anonymous_user(default_client, member): + """Non TIHLDE users should not be able to retrieve idea feedbacks""" + feedback_idea = IdeaFactory(author=member) + + url = f"{FEEDBACK_BASE_URL}{feedback_idea.id}/" + response = default_client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_retrieve_details_bug_as_index_user(index_member, member): + """An Index user should be able to retrieve bug feedbacks from members""" + feedback_bug = BugFactory(author=member) + + url = f"{FEEDBACK_BASE_URL}{feedback_bug.id}/" + client = get_api_client(user=index_member) + response = client.get(url) + + assert response.data["feedback_type"] == "Bug" + assert response.data["url"] == feedback_bug.url + assert response.data["browser"] == feedback_bug.browser + assert response.data["platform"] == feedback_bug.platform + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_retrieve_details_idea_as_index_user(index_member, member): + """An Index user should be able to retrieve idea feedbacks from members""" + feedback_idea = IdeaFactory(author=member) + + url = f"{FEEDBACK_BASE_URL}{feedback_idea.id}/" + client = get_api_client(user=index_member) + response = client.get(url) + + assert response.data["feedback_type"] == "Idea" + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +@pytest.mark.parametrize("feedback_type", ["Bug", "Idea"]) +def test_filter_feedback_type_as_member(member, feedback_type): + """A member should be able to filter feedbacks by type""" + + BugFactory() + IdeaFactory() + + url = f"{FEEDBACK_BASE_URL}?feedback_type={feedback_type}" + client = get_api_client(member) + response = client.get(url) + + data = response.data + results = data["results"] + + assert response.status_code == status.HTTP_200_OK + assert data["count"] == 1 + assert results[0]["feedback_type"] == feedback_type + + +@pytest.mark.django_db +def test_status_filter_as_member(member): + """A member should be able to filter feedbacks by status""" + + BugFactory(author=member, status=Status.OPEN) + IdeaFactory(author=member, status=Status.CLOSED) + + url = f"{FEEDBACK_BASE_URL}?status={Status.OPEN}" + client = get_api_client(member) + response = client.get(url) + + data = response.data + + results = data["results"] + + assert response.status_code == status.HTTP_200_OK + assert data["count"] == 1 + assert results[0]["status"] == Status.OPEN 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/tests/gallery/_init_.py b/app/tests/gallery/_init_.py new file mode 100644 index 000000000..e69de29bb 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