diff --git a/CHANGELOG.md b/CHANGELOG.md index ae1eaec33..83fb1c9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ ## 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/authentication/serializers/change_password.py b/app/authentication/serializers/change_password.py index bc1dc685c..5d214bdce 100644 --- a/app/authentication/serializers/change_password.py +++ b/app/authentication/serializers/change_password.py @@ -11,6 +11,7 @@ class ChangePasswordSerializer(serializers.Serializer): set_password_form_class = SetPasswordForm def __init__(self, *args, **kwargs): + self.set_password_form = None self.old_password_field_enabled = getattr( settings, "OLD_PASSWORD_FIELD_ENABLED", True ) diff --git a/app/authentication/serializers/reset_password.py b/app/authentication/serializers/reset_password.py index 73baeb6ff..67d612d2d 100644 --- a/app/authentication/serializers/reset_password.py +++ b/app/authentication/serializers/reset_password.py @@ -2,6 +2,7 @@ from django.contrib.auth.forms import PasswordResetForm from rest_framework import serializers +from rest_framework.fields import empty from sentry_sdk import capture_exception @@ -16,6 +17,10 @@ class PasswordResetSerializer(serializers.Serializer): email = serializers.EmailField() password_reset_form_class = PasswordResetForm + def __init__(self, instance=None, data=empty, **kwargs): + super().__init__(instance, data, kwargs) + self.reset_form = None + def validate_email(self, value): # Create PasswordResetForm with the serializer try: diff --git a/app/badge/filters/badge.py b/app/badge/filters/badge.py index 91e91a038..a7c887de1 100644 --- a/app/badge/filters/badge.py +++ b/app/badge/filters/badge.py @@ -23,15 +23,15 @@ class UserWithBadgesFilter(FilterSet): queryset=BadgeCategory.objects.all(), ) - def filter_category(self, queryset, name, value): + def filter_category(self, queryset, _name, value): return queryset.filter(user_badges__badge__badge_category=value) - def filter_is_in_study(self, queryset, name, value): + def filter_is_in_study(self, queryset, _name, value): return queryset.filter( memberships__group__slug=value, memberships__group__type=GroupType.STUDY ) - def filter_is_in_studyyear(self, queryset, name, value): + def filter_is_in_studyyear(self, queryset, _name, value): return queryset.filter( memberships__group__slug=value, memberships__group__type=GroupType.STUDYYEAR ) @@ -49,7 +49,7 @@ class UserWithSpecificBadgeFilter(FilterSet): study = filters.NumberFilter(method="filter_study") studyyear = filters.NumberFilter(method="filter_studyyear") - def filter_study(self, queryset, name, value): + def filter_study(self, queryset, _name, value): return queryset.filter( Exists( Membership.objects.filter( @@ -60,7 +60,7 @@ def filter_study(self, queryset, name, value): ) ) - def filter_studyyear(self, queryset, name, value): + def filter_studyyear(self, queryset, _name, value): return queryset.filter( Exists( Membership.objects.filter( diff --git a/app/career/filters/job_post.py b/app/career/filters/job_post.py index 179b26bfc..bcafc9d76 100644 --- a/app/career/filters/job_post.py +++ b/app/career/filters/job_post.py @@ -20,12 +20,12 @@ class Meta: model: JobPost fields = ["expired", "job_type"] - def filter_expired(self, queryset, name, value): + def filter_expired(self, queryset, _name, value): if value: return queryset.filter(deadline__lt=yesterday()).order_by("-deadline") return queryset.filter(deadline__gte=yesterday()).order_by("deadline") - def filter_classes(self, queryset, name, value): + def filter_classes(self, queryset, _name, value): query = Q() for year in value: query |= Q(class_start__lte=year, class_end__gte=year) diff --git a/app/career/views/weekly_business.py b/app/career/views/weekly_business.py index 3e336630b..80baeffc9 100644 --- a/app/career/views/weekly_business.py +++ b/app/career/views/weekly_business.py @@ -12,7 +12,6 @@ class WeeklyBusinessViewSet(BaseViewSet): - queryset = WeeklyBusiness.objects.none() serializer_class = WeeklyBusinessSerializer permission_classes = [BasicViewPermission] @@ -30,7 +29,7 @@ def get_queryset(self): in_future_this_year_filter = Q(year=now().year) & Q(week__gte=week_nr(now())) next_year_filter = Q(year__gt=now().year) return WeeklyBusiness.objects.filter( - (in_future_this_year_filter) | next_year_filter + in_future_this_year_filter | next_year_filter ).order_by("year", "week") def list(self, request, *args, **kwargs): @@ -57,9 +56,10 @@ def create(self, request, *args, **kwargs): return Response( {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST ) - except ValueError as value_error: + except ValueError: return Response( - {"detail": str(value_error)}, status=status.HTTP_400_BAD_REQUEST + {"detail": "En feil oppstod under behandlingen av forespørselen."}, + status=status.HTTP_400_BAD_REQUEST, ) def update(self, request, pk): @@ -78,9 +78,10 @@ def update(self, request, pk): return Response( {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST ) - except ValueError as value_error: + except ValueError: return Response( - {"detail": str(value_error)}, status=status.HTTP_400_BAD_REQUEST + {"detail": "En feil oppstod under behandlingen av forespørselen."}, + status=status.HTTP_400_BAD_REQUEST, ) def destroy(self, request, *args, **kwargs): diff --git a/app/codex/exceptions.py b/app/codex/exceptions.py index fd68fcce9..bcb72ba31 100644 --- a/app/codex/exceptions.py +++ b/app/codex/exceptions.py @@ -17,8 +17,12 @@ class APICodexEventEndRegistrationDateBeforeStartRegistrationDate(APIException): class CodexEventEndRegistrationDateAfterStartDate(ValueError): - pass + default_detail = ( + "Sluttdatoen for påmelding kan ikke være etter startdatoen for kurset" + ) class CodexEventEndRegistrationDateBeforeStartRegistrationDate(ValueError): - pass + default_detail = ( + "Sluttdatoen for påmelding kan ikke være før startdatoen for påmelding" + ) diff --git a/app/codex/util/event.py b/app/codex/util/event.py index 00b3979f1..2248a1eeb 100644 --- a/app/codex/util/event.py +++ b/app/codex/util/event.py @@ -6,11 +6,7 @@ def validate_event_dates(data: dict): if data["end_registration_at"] > data["start_date"]: - raise CodexEventEndRegistrationDateAfterStartDate( - "Påmeldingsslutt kan ikke være etter kursstart" - ) + raise CodexEventEndRegistrationDateAfterStartDate() if data["end_registration_at"] < data["start_registration_at"]: - raise CodexEventEndRegistrationDateBeforeStartRegistrationDate( - "Påmeldingsslutt kan ikke være før påmeldingsstart" - ) + raise CodexEventEndRegistrationDateBeforeStartRegistrationDate() diff --git a/app/common/azure_file_handler.py b/app/common/azure_file_handler.py index 3476f422e..b963ba4b0 100644 --- a/app/common/azure_file_handler.py +++ b/app/common/azure_file_handler.py @@ -13,7 +13,7 @@ def __init__(self, blob=None, url=None): self.blob = blob self.url = url if url: - data = self.getContainerAndNameFromUrl() + data = self.get_container_and_name_from_url() self.containerName = data[0] self.blobName = data[1] @@ -29,24 +29,24 @@ def get_or_create_container(self, name="default"): container = blob_service_client.create_container(name, public_access="blob") return container - def getContainerAndNameFromUrl(self): + def get_container_and_name_from_url(self): import urllib.parse url = urllib.parse.unquote(self.url) # fmt: off - return re.sub("\w+:\/{2}[\d\w-]+(\.[\d\w-]+)*/", "", url).split("/") # noqa: W605 + return re.sub("\w+:/{2}[\d\w-]+(\.[\d\w-]+)*/", "", url).split("/") # noqa: W605 # fmt: on - def uploadBlob(self): - "Uploads the given blob to Azure and returns a url to the blob" + def upload_blob(self): + """Uploads the given blob to Azure and returns a url to the blob""" if not self.blob: raise ValueError("Du må sende med en blob for som skal lastes opp") - self.checkBlobSize() - containerName = self.getContainerNameFromBlob() - container = self.get_or_create_container(containerName) + self.check_blob_size() + container_name = self.get_container_name_from_blob() + container = self.get_or_create_container(container_name) - blob_name = f"{uuid.uuid4()}{self.getBlobName()}" + blob_name = f"{uuid.uuid4()}{self.get_blob_name()}" content_settings = ContentSettings( content_type=self.blob.content_type if self.blob.content_type else None, @@ -62,8 +62,8 @@ def uploadBlob(self): return blob_client.url raise ValueError("Noe gikk galt under filopplastningen") - def deleteBlob(self): - "Delete a blob by it's url" + def delete_blob(self): + """Delete a blob by it's url""" if not self.blobName and not self.containerName: raise ValueError("Du kan ikke slette en blob uten en url") diff --git a/app/common/enums.py b/app/common/enums.py index 68afaa78e..20d1f7b31 100644 --- a/app/common/enums.py +++ b/app/common/enums.py @@ -75,11 +75,11 @@ class AdminGroup(models.TextChoices): @classmethod def all(cls): - return (cls.HS, cls.INDEX, cls.NOK, cls.PROMO, cls.SOSIALEN, cls.KOK) + return cls.HS, cls.INDEX, cls.NOK, cls.PROMO, cls.SOSIALEN, cls.KOK @classmethod def admin(cls): - return (cls.HS, cls.INDEX) + return cls.HS, cls.INDEX class Groups(models.TextChoices): diff --git a/app/common/file_handler.py b/app/common/file_handler.py index 3b7450753..68a566ede 100644 --- a/app/common/file_handler.py +++ b/app/common/file_handler.py @@ -14,22 +14,22 @@ def __init__(self, blob=None): def get_or_create_container(self, name="default"): pass - def getBlobName(self): + def get_blob_name(self): return self.blob.name if self.blob.name else "" - def getContainerNameFromBlob(self): + def get_container_name_from_blob(self): return ( "".join(e for e in self.blob.content_type if e.isalnum()) if self.blob.content_type else "default" ) - def checkBlobSize(self): + def check_blob_size(self): if self.blob.size > self.SIZE_50_MB: raise ValueError("Filen kan ikke være større enn 50 MB") @abstractmethod - def uploadBlob(self): + def upload_blob(self): pass @@ -43,6 +43,6 @@ def replace_file(instance_image, validated_data_image): if instance_image and instance_image != validated_data_image: if settings.AZURE_BLOB_STORAGE_NAME in instance_image: try: - AzureFileHandler(url=instance_image).deleteBlob() + AzureFileHandler(url=instance_image).delete_blob() except Exception as e: capture_exception(e) 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/common/tasks.py b/app/common/tasks.py index a2fe0cc0d..97b4a2b41 100644 --- a/app/common/tasks.py +++ b/app/common/tasks.py @@ -3,7 +3,7 @@ @app.task(bind=True, base=BaseTask) -def delete_old_log_entries(self, *args, **kwargs): +def delete_old_log_entries(self, *_args, **_kwargs): from datetime import timedelta from django.contrib.admin.models import LogEntry diff --git a/app/common/tests/test_azure_filehandler.py b/app/common/tests/test_azure_filehandler.py index 5b9fcf6cb..b8a649eee 100644 --- a/app/common/tests/test_azure_filehandler.py +++ b/app/common/tests/test_azure_filehandler.py @@ -9,6 +9,6 @@ def test_get_getContainerAndNameFromUrl(): handler = AzureFileHandler( url=f"https://{settings.AZURE_BLOB_STORAGE_NAME}/{container_name}/{file_name}" ) - data = handler.getContainerAndNameFromUrl() + data = handler.get_container_and_name_from_url() assert data[0] == container_name assert data[1] == file_name diff --git a/app/communication/exceptions.py b/app/communication/exceptions.py index 26f6b3871..7ffc5deaf 100644 --- a/app/communication/exceptions.py +++ b/app/communication/exceptions.py @@ -8,17 +8,17 @@ class APIAnotherVisibleBannerException(APIException): default_detail = "Det finnes allerede et banner som er synlig i samme tidsrom" +class AnotherVisibleBannerError(ValidationError): + default_detail = "Det finnes allerede et banner som er synlig i samme tidsrom" + + class APIDatesMixedException(APIException): status_code = status.HTTP_400_BAD_REQUEST default_detail = "Datoen banneret er synlig til er satt etter datoen banneret for synlig fra. Bytt om disse to" -class AnotherVisibleBannerError(ValidationError): - pass - - class DatesMixedError(ValidationError): - pass + default_detail = "Datoen banneret er synlig til er satt etter datoen banneret for synlig fra. Bytt om disse to" class APIAllChannelsUnselected(APIException): @@ -27,4 +27,4 @@ class APIAllChannelsUnselected(APIException): class AllChannelsUnselected(ValueError): - pass + default_detail = "Du må velge minst en kommunikasjonsmetode" diff --git a/app/communication/factories/mail_factory.py b/app/communication/factories/mail_factory.py index 67da2a509..271ee0b8b 100644 --- a/app/communication/factories/mail_factory.py +++ b/app/communication/factories/mail_factory.py @@ -12,7 +12,7 @@ class Meta: body = factory.Faker("paragraph", nb_sentences=10) @factory.post_generation - def users(self, create, extracted, **kwargs): + def users(self, create, extracted, **_kwargs): """Add users to the mail: `MailFactory.create(users=(user1, user2, user3))`""" if not create or not extracted: # Simple build, or nothing to add, do nothing. diff --git a/app/communication/models/banner.py b/app/communication/models/banner.py index f40fb1dde..61f7cfe21 100644 --- a/app/communication/models/banner.py +++ b/app/communication/models/banner.py @@ -66,5 +66,5 @@ def is_visible(self): return self.visible_from <= now() <= self.visible_until @classmethod - def has_visible_permission(cls, request): + def has_visible_permission(cls, _request): return True diff --git a/app/communication/models/mail.py b/app/communication/models/mail.py index e4ba7ec26..6a4e13eaa 100644 --- a/app/communication/models/mail.py +++ b/app/communication/models/mail.py @@ -27,7 +27,7 @@ class Meta: def send(self, connection): from app.communication.notifier import send_html_email - emails = (user.email for user in self.users.all()) + emails = [user.email for user in self.users.all()] is_success = send_html_email( to_mails=emails, html=self.body, @@ -40,4 +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'}, {'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/communication/tasks.py b/app/communication/tasks.py index fd49f1d40..f28678750 100644 --- a/app/communication/tasks.py +++ b/app/communication/tasks.py @@ -5,7 +5,7 @@ @app.task(bind=True, base=BaseTask) -def send_due_mails(self, *args, **kwargs): +def send_due_mails(self, *_args, **_kwargs): from django.core.mail import get_connection from app.communication.models.mail import Mail diff --git a/app/communication/tests/test_banner_model.py b/app/communication/tests/test_banner_model.py index e46051d0a..cbb2936f6 100644 --- a/app/communication/tests/test_banner_model.py +++ b/app/communication/tests/test_banner_model.py @@ -7,7 +7,7 @@ DatesMixedError, ) from app.communication.factories.banner_factory import BannerFactory -from app.util.utils import getTimezone, now +from app.util.utils import get_timezone, now @pytest.mark.parametrize( @@ -27,8 +27,8 @@ def test_two_banners_can_not_be_visible_simultaneously_in_any_period( This test uses timedelta and parameterize to switch visible_from and visible_until between 5 days earlier or later.""" existing_banner = BannerFactory( - visible_from=datetime(2020, 1, 1, tzinfo=getTimezone()), - visible_until=datetime(2021, 1, 1, tzinfo=getTimezone()), + visible_from=datetime(2020, 1, 1, tzinfo=get_timezone()), + visible_until=datetime(2021, 1, 1, tzinfo=get_timezone()), ) with pytest.raises(AnotherVisibleBannerError): diff --git a/app/communication/views/banner.py b/app/communication/views/banner.py index 4dd0ddd3c..888f83195 100644 --- a/app/communication/views/banner.py +++ b/app/communication/views/banner.py @@ -21,12 +21,12 @@ class Meta: model = Banner fields = ["is_visible", "is_expired"] - def filter_is_visible(self, queryset, name, value): + def filter_is_visible(self, queryset, _name, value): if value: return queryset.filter(visible_from__lte=now(), visible_until__gte=now()) return queryset - def filter_is_expired(self, queryset, name, value): + def filter_is_expired(self, queryset, _name, value): if value: return queryset.filter(visible_until__lt=now()) return queryset diff --git a/app/communication/views/notification.py b/app/communication/views/notification.py index 40c614ee3..1b96fa26e 100644 --- a/app/communication/views/notification.py +++ b/app/communication/views/notification.py @@ -36,6 +36,6 @@ def update(self, request, pk): status=status.HTTP_200_OK, ) return Response( - {"detail": ("Kunne ikke oppdatere varslet")}, + {"detail": "Kunne ikke oppdatere varslet"}, status=status.HTTP_403_FORBIDDEN, ) diff --git a/app/communication/views/user_notification_setting.py b/app/communication/views/user_notification_setting.py index e24360c96..0786ac997 100644 --- a/app/communication/views/user_notification_setting.py +++ b/app/communication/views/user_notification_setting.py @@ -50,7 +50,7 @@ def create(self, request, *args, **kwargs): ) @action(detail=False, methods=["get"], url_path="choices") - def choices(self, request, *args, **kwargs): + def choices(self, _request, *_args, **_kwargs): return Response( list( map( 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/admin/admin.py b/app/content/admin/admin.py index 1a219f7e3..16191c23d 100644 --- a/app/content/admin/admin.py +++ b/app/content/admin/admin.py @@ -40,7 +40,7 @@ class StrikeAdmin(admin.ModelAdmin): ) -def admin_delete_registration(modeladmin, request, queryset): +def admin_delete_registration(_modeladmin, _request, queryset): for registration in queryset: registration.admin_unregister() diff --git a/app/content/exceptions.py b/app/content/exceptions.py index 73f07dfe6..31ca909d3 100644 --- a/app/content/exceptions.py +++ b/app/content/exceptions.py @@ -22,6 +22,10 @@ class APIEventSignOffDeadlineHasPassed(APIException): default_detail = "Du kan ikke melde deg av etter avmeldingsfristen" +class EventSignOffDeadlineHasPassed(ValueError): + default_detail = "Du kan ikke melde deg av etter avmeldingsfristen" + + class APIUnansweredFormException(APIException): status_code = status.HTTP_400_BAD_REQUEST default_detail = ( @@ -29,34 +33,32 @@ class APIUnansweredFormException(APIException): ) +class UnansweredFormError(ValueError): + default_detail = ( + "Du har ubesvarte evalueringsskjemaer som må besvares før du kan melde deg på" + ) + + class APIHasStrikeException(APIException): status_code = status.HTTP_403_FORBIDDEN default_detail = "Kan ikke melde deg på fordi du har en eller flere prikker" -class APIEventIsFullException(APIException): - status_code = status.HTTP_400_BAD_REQUEST - default_detail = "Du kan ikke flytte opp en fra ventelisten når arrangementet er fullt. Flytt en bruker ned først." - - -class EventSignOffDeadlineHasPassed(ValueError): - pass - - class StrikeError(ValueError): - pass + default_detail = "Kan ikke melde deg på fordi du har en eller flere prikker" -class UnansweredFormError(ValueError): - pass +class APIEventIsFullException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Du kan ikke flytte opp en fra ventelisten når arrangementet er fullt. Flytt en bruker ned først." class EventIsFullError(ValueError): - pass + default_detail = "Du kan ikke flytte opp en fra ventelisten når arrangementet er fullt. Flytt en bruker ned først." class RefundFailedError(ValueError): - pass + default_detail = "Tilbakebetaling feilet" class FeideError(ValueError): diff --git a/app/content/factories/priority_pool_factory.py b/app/content/factories/priority_pool_factory.py index ec2af803e..3e783d85a 100644 --- a/app/content/factories/priority_pool_factory.py +++ b/app/content/factories/priority_pool_factory.py @@ -12,7 +12,7 @@ class Meta: event = factory.SubFactory(EventFactory) @factory.post_generation - def groups(self, create, extracted, **kwargs): + def groups(self, create, extracted, **_kwargs): if not create or not extracted: # Simple build, do nothing. return diff --git a/app/content/filters/event.py b/app/content/filters/event.py index 48ad63d35..abf189871 100644 --- a/app/content/filters/event.py +++ b/app/content/filters/event.py @@ -26,7 +26,7 @@ class Meta: model = Event fields = ["category", "organizer", "end_range", "start_range"] - def filter_open_for_sign_up(self, queryset, name, value): + def filter_open_for_sign_up(self, queryset, _name, value): if value: return queryset.filter( sign_up=True, @@ -35,7 +35,7 @@ def filter_open_for_sign_up(self, queryset, name, value): ) return queryset - def filter_user_favorites(self, queryset, name, value): + def filter_user_favorites(self, queryset, _name, value): if value and self.request.user: return queryset.filter(favorite_users__user_id=self.request.user.user_id) return queryset diff --git a/app/content/filters/registration.py b/app/content/filters/registration.py index 94f35b037..92b7eee6a 100644 --- a/app/content/filters/registration.py +++ b/app/content/filters/registration.py @@ -1,9 +1,71 @@ +from django.db.models import Exists, OuterRef +from django_filters import rest_framework as filters from django_filters.rest_framework import FilterSet +from app.common.enums import NativeGroupType as GroupType from app.content.models.registration import Registration +from app.payment.enums import OrderStatus +from app.payment.models import Order class RegistrationFilter(FilterSet): + + study = filters.CharFilter( + field_name="user__groups__name", lookup_expr="icontains", method="filter_study" + ) + year = filters.CharFilter( + field_name="user__groups__name", lookup_expr="icontains", method="filter_year" + ) + + has_allergy = filters.BooleanFilter( + field_name="user__allergy", method="filter_has_allergy" + ) + + has_paid = filters.BooleanFilter( + field_name="event__orders__status", method="filter_has_paid" + ) + class Meta: model = Registration - fields = ["has_attended", "is_on_wait"] + fields = [ + "has_attended", + "is_on_wait", + "study", + "year", + "has_allergy", + "allow_photo", + "has_paid", + ] + + def filter_study(self, queryset, name, value): + return queryset.filter( + user__memberships__group__name__icontains=value, + user__memberships__group__type=GroupType.STUDY, + ) + + def filter_has_paid(self, queryset, name, value): + sale_order_exists = Order.objects.filter( + event=OuterRef("event_id"), + user=OuterRef("user_id"), + status=OrderStatus.SALE, + ) + + if value: + return queryset.filter(Exists(sale_order_exists)) + else: + return queryset.exclude(Exists(sale_order_exists)) + + def filter_year(self, queryset, name, value): + return queryset.filter( + user__memberships__group__name__icontains=value, + user__memberships__group__type=GroupType.STUDYYEAR, + ) + + def filter_has_allergy(self, queryset, name, value): + if value: + return queryset.exclude(user__allergy__isnull=True).exclude( + user__allergy__exact="" + ) + return queryset.filter(user__allergy__isnull=True) | queryset.filter( + user__allergy__exact="" + ) diff --git a/app/content/filters/strike.py b/app/content/filters/strike.py index f8b820003..75750908d 100644 --- a/app/content/filters/strike.py +++ b/app/content/filters/strike.py @@ -11,7 +11,7 @@ class StrikeFilter(FilterSet): study = filters.NumberFilter(method="filter_study") studyyear = filters.NumberFilter(method="filter_studyyear") - def filter_study(self, queryset, name, value): + def filter_study(self, queryset, _name, value): return queryset.filter( Exists( Membership.objects.filter( @@ -22,7 +22,7 @@ def filter_study(self, queryset, name, value): ) ) - def filter_studyyear(self, queryset, name, value): + def filter_studyyear(self, queryset, _name, value): return queryset.filter( Exists( Membership.objects.filter( diff --git a/app/content/filters/user.py b/app/content/filters/user.py index a189f2390..9b83b1ffa 100644 --- a/app/content/filters/user.py +++ b/app/content/filters/user.py @@ -35,28 +35,28 @@ class Meta: "in_group", ] - def filter_is_in_study(self, queryset, name, value): + def filter_is_in_study(self, queryset, _name, value): return queryset.filter( memberships__group__slug=value, memberships__group__type=GroupType.STUDY ) - def filter_is_in_studyyear(self, queryset, name, value): + def filter_is_in_studyyear(self, queryset, _name, value): return queryset.filter( memberships__group__slug=value, memberships__group__type=GroupType.STUDYYEAR ) - def filter_is_in_group(self, queryset, name, value): + def filter_is_in_group(self, queryset, _name, value): return queryset.filter(memberships__group__slug=value) - def filter_is_TIHLDE_member(self, queryset, name, value): + def filter_is_TIHLDE_member(self, queryset, _name, value): if value is False: return queryset.exclude(memberships__group__slug=Groups.TIHLDE) return queryset.filter(memberships__group__slug=Groups.TIHLDE) - def filter_has_active_strikes(self, queryset, name, value): + def filter_has_active_strikes(self, queryset, _name, value): if value is False: return queryset.exclude(strikes__in=Strike.objects.active()).distinct() return queryset.filter(strikes__in=Strike.objects.active()).distinct() - def filter_has_allowed_photo(self, queryset, name, value): + def filter_has_allowed_photo(self, queryset, _name, value): return queryset.filter(allows_photo_by_default=value) diff --git a/app/content/models/registration.py b/app/content/models/registration.py index 19c6c3790..20449987f 100644 --- a/app/content/models/registration.py +++ b/app/content/models/registration.py @@ -50,7 +50,7 @@ class Meta: verbose_name_plural = "Registrations" @classmethod - def has_retrieve_permission(cls, request): + def has_retrieve_permission(cls, _request): return True @classmethod @@ -165,7 +165,7 @@ def save(self, *args, **kwargs): and not self.is_on_wait and self in self.event.get_waiting_list() ): - raise EventIsFullError + raise EventIsFullError() self.send_notification_and_mail() return super().save(*args, **kwargs) @@ -415,6 +415,7 @@ def check_registration_has_ended(self): if self.event.end_registration_at < now(): raise ValidationError("Påmeldingsfristen har passert") + # noinspection PyShadowingBuiltins def get_submissions(self, type=None): from app.forms.models import EventForm, Submission diff --git a/app/content/models/short_link.py b/app/content/models/short_link.py index 22bc61a55..9cadea0d0 100644 --- a/app/content/models/short_link.py +++ b/app/content/models/short_link.py @@ -29,7 +29,7 @@ def has_create_permission(cls, request): return super().has_write_permission(request) @classmethod - def has_destroy_permission(cls, request): + def has_destroy_permission(cls, _request): return True def has_object_write_permission(self, request): diff --git a/app/content/models/strike.py b/app/content/models/strike.py index 696dc4463..872c1651e 100644 --- a/app/content/models/strike.py +++ b/app/content/models/strike.py @@ -10,7 +10,7 @@ from app.communication.enums import UserNotificationSettingType from app.content.models import Event from app.util.models import BaseModel -from app.util.utils import getTimezone, now +from app.util.utils import get_timezone, now class Holiday: @@ -117,10 +117,10 @@ def expires_at(self): end = holiday.end start_date = datetime( - self.created_at.year, start[0], start[1], tzinfo=getTimezone() + self.created_at.year, start[0], start[1], tzinfo=get_timezone() ) end_date = datetime( - self.created_at.year, end[0], end[1], tzinfo=getTimezone() + self.created_at.year, end[0], end[1], tzinfo=get_timezone() ) if end_date < start_date: @@ -133,7 +133,7 @@ def expires_at(self): expired_date += smallest_difference + timedelta(days=1) break - return expired_date.astimezone(getTimezone()) + return expired_date.astimezone(get_timezone()) @classmethod def has_destroy_permission(cls, request): diff --git a/app/content/models/user.py b/app/content/models/user.py index 1c4ce58b7..1230fb7a6 100644 --- a/app/content/models/user.py +++ b/app/content/models/user.py @@ -209,7 +209,7 @@ def has_write_permission(cls, request): ) @classmethod - def has_create_permission(cls, request): + def has_create_permission(cls, _request): return True def has_object_write_permission(self, request): @@ -234,9 +234,10 @@ def has_object_get_user_detail_strikes_permission(self, request): ) +# noinspection PyUnusedLocal @receiver(post_save, sender=settings.AUTH_USER_MODEL) @disable_for_loaddata -def create_auth_token(sender, instance=None, created=False, **kwargs): +def create_auth_token(sender, instance=None, created=False, **_kwargs): """Generate token at creation of user""" if created: Token.objects.create(user=instance) diff --git a/app/content/serializers/event.py b/app/content/serializers/event.py index f1042051f..661829160 100644 --- a/app/content/serializers/event.py +++ b/app/content/serializers/event.py @@ -15,6 +15,7 @@ from app.emoji.serializers.reaction import ReactionSerializer from app.group.models.group import Group from app.group.serializers.group import SimpleGroupSerializer +from app.payment.enums import OrderStatus from app.payment.models.paid_event import PaidEvent from app.payment.serializers.paid_event import PaidEventCreateSerializer @@ -263,6 +264,9 @@ class EventStatisticsSerializer(BaseModelSerializer): has_attended_count = serializers.SerializerMethodField() studyyears = serializers.SerializerMethodField() studies = serializers.SerializerMethodField() + has_allergy_count = serializers.SerializerMethodField() + has_not_paid_count = serializers.SerializerMethodField() + allow_photo_count = serializers.SerializerMethodField() class Meta: model = Event @@ -272,11 +276,22 @@ class Meta: "waiting_list_count", "studyyears", "studies", + "has_allergy_count", + "has_not_paid_count", + "allow_photo_count", ) - def get_has_attended_count(self, obj, *args, **kwargs): + def get_has_attended_count(self, obj, *_args, **_kwargs): return obj.registrations.filter(is_on_wait=False, has_attended=True).count() + def get_has_allergy_count(self, obj, *args, **kwargs): + return ( + obj.registrations.exclude(user__allergy__isnull=True) + .filter(is_on_wait=False) + .exclude(user__allergy__exact="") + .count() + ) + def get_studyyears(self, obj, *args, **kwargs): return filter( lambda studyyear: studyyear["amount"] > 0, @@ -291,7 +306,7 @@ def get_studyyears(self, obj, *args, **kwargs): ), ) - def get_studies(self, obj, *args, **kwargs): + def get_studies(self, obj, *_args, **_kwargs): return filter( lambda study: study["amount"] > 0, map( @@ -304,3 +319,13 @@ def get_studies(self, obj, *args, **kwargs): Group.objects.filter(type=GroupType.STUDY), ), ) + + def get_allow_photo_count(self, obj, *args, **kwargs): + return obj.registrations.filter(allow_photo=False, is_on_wait=False).count() + + def get_has_not_paid_count(self, obj, *args, **kwargs): + if obj.is_paid_event: + registrations = obj.registrations.filter(is_on_wait=False).count() + orders = obj.orders.filter(status=OrderStatus.SALE, event=obj).count() + return registrations - orders + return 0 diff --git a/app/content/tasks/event.py b/app/content/tasks/event.py index 12da3245c..ac2e6c8a1 100644 --- a/app/content/tasks/event.py +++ b/app/content/tasks/event.py @@ -19,7 +19,7 @@ @app.task(bind=True, base=BaseTask) -def run_sign_off_deadline_reminder(self, *args, **kwargs): +def run_sign_off_deadline_reminder(self, *_args, **_kwargs): from app.content.models.event import Event try: @@ -41,7 +41,7 @@ def run_sign_off_deadline_reminder(self, *args, **kwargs): @app.task(bind=True, base=BaseTask) -def run_post_event_actions(self, *args, **kwargs): +def run_post_event_actions(self, *_args, **_kwargs): from app.content.models.event import Event try: @@ -61,7 +61,7 @@ def run_post_event_actions(self, *args, **kwargs): @app.task(bind=True, base=BaseTask) -def run_sign_up_start_notifier(self, *args, **kwargs): +def run_sign_up_start_notifier(self, *_args, **_kwargs): from app.content.models.event import Event try: @@ -82,7 +82,7 @@ def run_sign_up_start_notifier(self, *args, **kwargs): capture_exception(e) -def __sign_off_deadline_reminder(event, *args, **kwargs): +def __sign_off_deadline_reminder(event, *_args, **_kwargs): from app.content.models import User users_not_on_wait = User.objects.filter( @@ -117,7 +117,7 @@ def __sign_off_deadline_reminder(event, *args, **kwargs): event.save(update_fields=["runned_sign_off_deadline_reminder"]) -def __post_event_actions(event, *args, **kwargs): +def __post_event_actions(event, *_args, **_kwargs): from app.content.models import User if event.can_cause_strikes: @@ -149,7 +149,7 @@ def __post_event_actions(event, *args, **kwargs): event.save(update_fields=["runned_post_event_actions"]) -def __sign_up_start_notifier(event, *args, **kwargs): +def __sign_up_start_notifier(event, *_args, **_kwargs): description = f'Påmelding til "{event.title}" har nå åpnet! 🏃 Arrangementet starter {datetime_format(event.start_date)} og har {event.limit} plasser. Påmeldingen er åpen frem til {datetime_format(event.end_registration_at)}, men husk at det kan bli fullt før det. ⏲️' CHANNEL_ID = ( diff --git a/app/content/tests/test_strike_model.py b/app/content/tests/test_strike_model.py index de90371c4..64afee292 100644 --- a/app/content/tests/test_strike_model.py +++ b/app/content/tests/test_strike_model.py @@ -5,7 +5,7 @@ from app.content.factories import StrikeFactory from app.content.models.strike import STRIKE_DURATION_IN_DAYS -from app.util.utils import getTimezone +from app.util.utils import get_timezone @pytest.mark.django_db @@ -30,8 +30,8 @@ def test_strike_is_active_or_not_with_freeze_through_winter_holiday( mock_now, today, created_at, expected_result ): """Strikes are frozen in winter from the 29th of November to the 1st of January.""" - today = today.replace(tzinfo=getTimezone()) - created_at = created_at.replace(tzinfo=getTimezone()) + today = today.replace(tzinfo=get_timezone()) + created_at = created_at.replace(tzinfo=get_timezone()) mock_now.return_value = today strike = StrikeFactory.build(created_at=created_at) @@ -63,8 +63,8 @@ def test_strike_is_active_or_not_with_freeze_through_summer_holiday( mock_now, today, created_at, expected_result ): """Strikes are frozen in summer from the 10th of May to 15th of August.""" - today = today.replace(tzinfo=getTimezone()) - created_at = created_at.replace(tzinfo=getTimezone()) + today = today.replace(tzinfo=get_timezone()) + created_at = created_at.replace(tzinfo=get_timezone()) mock_now.return_value = today strike = StrikeFactory.build(created_at=created_at) @@ -89,7 +89,7 @@ def test_strike_is_active_or_not_with_freeze_through_summer_holiday( ) def test_active_days_of_a_strike_with_freeze_through_holidays(created_at, days_active): """Days active is the amount of days a strike is active which is at least 20 days""" - created_at = created_at.replace(tzinfo=getTimezone()) + created_at = created_at.replace(tzinfo=get_timezone()) strike = StrikeFactory.build(created_at=created_at) @@ -117,7 +117,7 @@ def test_year_of_expire_date_different_than_created_year_with_freeze_through_win ): """A strike should have a different year of expired date if created less 20 days before the winter holiday""" - created_at = created_at.replace(tzinfo=getTimezone()) + created_at = created_at.replace(tzinfo=get_timezone()) strike = StrikeFactory.build(created_at=created_at) 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 d63431908..ad4239548 100644 --- a/app/content/util/event_utils.py +++ b/app/content/util/event_utils.py @@ -1,12 +1,9 @@ -import os -from datetime import datetime - from sentry_sdk import capture_exception from app.content.exceptions import RefundFailedError from app.payment.tasks import check_if_has_paid from app.payment.util.payment_utils import ( - get_new_access_token, + check_access_token, initiate_payment, refund_payment, ) @@ -45,13 +42,7 @@ def create_vipps_order(order_id, event, transaction_text, fallback): Creates vipps order, and returns the url. """ - access_token = os.environ.get("PAYMENT_ACCESS_TOKEN") - expires_at = os.environ.get("PAYMENT_ACCESS_TOKEN_EXPIRES_AT") - - if not access_token or datetime.now() >= datetime.fromtimestamp(int(expires_at)): - (expires_at, access_token) = get_new_access_token() - os.environ.update({"PAYMENT_ACCESS_TOKEN": access_token}) - os.environ.update({"PAYMENT_ACCESS_TOKEN_EXPIRES_AT": str(expires_at)}) + access_token = check_access_token() event_price = int(event.paid_information.price * 100) @@ -71,13 +62,7 @@ def refund_vipps_order(order_id, event, transaction_text): Refunds vipps order. """ - access_token = os.environ.get("PAYMENT_ACCESS_TOKEN") - expires_at = os.environ.get("PAYMENT_ACCESS_TOKEN_EXPIRES_AT") - - if not access_token or datetime.now() >= datetime.fromtimestamp(int(expires_at)): - (expires_at, access_token) = get_new_access_token() - os.environ.update({"PAYMENT_ACCESS_TOKEN": access_token}) - os.environ.update({"PAYMENT_ACCESS_TOKEN_EXPIRES_AT": str(expires_at)}) + access_token = check_access_token() event_price = int(event.paid_information.price) * 100 @@ -91,4 +76,4 @@ def refund_vipps_order(order_id, event, transaction_text): except Exception as refund_error: capture_exception(refund_error) - raise RefundFailedError("Tilbakebetaling feilet") + raise RefundFailedError() diff --git a/app/content/util/feide_utils.py b/app/content/util/feide_utils.py index bc38cc963..bbce707c3 100644 --- a/app/content/util/feide_utils.py +++ b/app/content/util/feide_utils.py @@ -52,7 +52,7 @@ def get_feide_tokens(code: str) -> tuple[str, str]: if "access_token" not in json or "id_token" not in json: raise FeideTokenNotFoundError() - return (json["access_token"], json["id_token"]) + return json["access_token"], json["id_token"] def get_feide_user_info_from_jwt(jwt_token: str) -> tuple[str, str]: @@ -73,7 +73,7 @@ def get_feide_user_info_from_jwt(jwt_token: str) -> tuple[str, str]: if not feide_username: raise FeideUsernameNotFoundError() - return (user_info["name"], feide_username) + return user_info["name"], feide_username def get_feide_user_groups(access_token: str) -> list[str]: 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 74a3bd982..545e1d351 100644 --- a/app/content/views/accept_form.py +++ b/app/content/views/accept_form.py @@ -23,7 +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])}{' og ' if len(types) > 1 else ''}{', '.join(types[-1:])}, {', '.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/event.py b/app/content/views/event.py index ecb522572..9827120cc 100644 --- a/app/content/views/event.py +++ b/app/content/views/event.py @@ -170,7 +170,7 @@ def destroy(self, request, *args, **kwargs): super().destroy(request, *args, **kwargs) return Response( - {"detail": ("Arrangementet ble slettet")}, status=status.HTTP_200_OK + {"detail": "Arrangementet ble slettet"}, status=status.HTTP_200_OK ) @action( @@ -179,7 +179,7 @@ def destroy(self, request, *args, **kwargs): url_path="favorite", permission_classes=(IsMember,), ) - def user_favorite(self, request, pk, *args, **kwargs): + def user_favorite(self, request, pk, *_args, **_kwargs): event = get_object_or_404(Event, id=pk) if request.method == "PUT": @@ -202,7 +202,7 @@ def user_favorite(self, request, pk, *args, **kwargs): methods=["get"], url_path="public_registrations", ) - def get_public_event_registrations(self, request, pk, *args, **kwargs): + def get_public_event_registrations(self, request, pk, *_args, **_kwargs): event = get_object_or_404(Event, id=pk) registrations = event.get_participants() return self.paginate_response( @@ -216,7 +216,7 @@ def get_public_event_registrations(self, request, pk, *args, **kwargs): methods=["post"], url_path="notify", ) - def notify_registered_users(self, request, *args, **kwargs): + def notify_registered_users(self, request, *_args, **_kwargs): try: title = request.data["title"] message = request.data["message"] @@ -242,7 +242,7 @@ def notify_registered_users(self, request, *args, **kwargs): ) @action(detail=False, methods=["get"], url_path="admin") - def get_events_where_is_admin(self, request, *args, **kwargs): + def get_events_where_is_admin(self, request, *_args, **_kwargs): if not self.request.user: return Response( {"detail": "Du har ikke tilgang til å opprette/redigere arrangementer"}, @@ -271,7 +271,7 @@ def get_events_where_is_admin(self, request, *args, **kwargs): ) @action(detail=True, methods=["get"], url_path="statistics") - def statistics(self, request, *args, **kwargs): + def statistics(self, request, *_args, **_kwargs): event = self.get_object() serializer = EventStatisticsSerializer(event, context={"request": request}) return Response(serializer.data, status=status.HTTP_200_OK) @@ -285,7 +285,7 @@ def statistics(self, request, *args, **kwargs): FormParser, ), ) - def mail_gift_cards(self, request, *args, **kwargs): + def mail_gift_cards(self, request, *_args, **_kwargs): event = self.get_object() dispatcher = request.user diff --git a/app/content/views/page.py b/app/content/views/page.py index a9f02b98c..d509a53c7 100644 --- a/app/content/views/page.py +++ b/app/content/views/page.py @@ -175,7 +175,7 @@ def destroy(self, request, *args, **kwargs): ) @action(detail=False, methods=["get"]) - def tree(self, request, *args, **kwargs): + def tree(self, _request, *_args, **_kwargs): root = Page.objects.get(parent=None) serializer = PageTreeSerializer(root) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/app/content/views/registration.py b/app/content/views/registration.py index 0aa13b3a8..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, ) @@ -154,10 +155,25 @@ def _admin_unregister(self, registration): ) @action(detail=False, methods=["post"], url_path="add") - def add_registration(self, request, *args, **kwargs): + 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.py b/app/content/views/user.py index bb3e67c42..fd9bf1ff4 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -148,7 +148,7 @@ def _get_user(self, request, pk): return get_object_or_404(User, user_id=pk) @action(detail=False, methods=["post"], url_path="me/slack") - def connect_to_slack(self, request, *args, **kwargs): + def connect_to_slack(self, request, *_args, **_kwargs): user = self.request.user self.check_object_permissions(self.request, user) @@ -171,7 +171,7 @@ def connect_to_slack(self, request, *args, **kwargs): ) @action(detail=False, methods=["get"], url_path="me/permissions") - def get_user_permissions(self, request, *args, **kwargs): + def get_user_permissions(self, request, *_args, **_kwargs): try: serializer = UserPermissionsSerializer( request.user, context={"request": request} @@ -184,7 +184,7 @@ def get_user_permissions(self, request, *args, **kwargs): ) @action(detail=True, methods=["get"], url_path="memberships") - def get_user_memberships(self, request, pk, *args, **kwargs): + def get_user_memberships(self, request, pk, *_args, **_kwargs): user = self._get_user(request, pk) self.check_object_permissions(self.request, user) @@ -198,7 +198,7 @@ def get_user_memberships(self, request, pk, *args, **kwargs): ) @action(detail=True, methods=["get"], url_path="memberships-with-fines") - def get_user_memberships_with_fines(self, request, pk, *args, **kwargs): + def get_user_memberships_with_fines(self, request, pk, *_args, **_kwargs): user = self._get_user(request, pk) self.check_object_permissions(self.request, user) @@ -212,7 +212,7 @@ def get_user_memberships_with_fines(self, request, pk, *args, **kwargs): ) @action(detail=True, methods=["get"], url_path="membership-histories") - def get_user_membership_histories(self, request, pk, *args, **kwargs): + def get_user_membership_histories(self, request, pk, *_args, **_kwargs): user = self._get_user(request, pk) self.check_object_permissions(self.request, user) @@ -225,7 +225,7 @@ def get_user_membership_histories(self, request, pk, *args, **kwargs): context={"request": request}, ) - def post_user_badges(self, request, *args, **kwargs): + def post_user_badges(self, request, *_args, **_kwargs): import uuid user = self.request.user @@ -265,7 +265,7 @@ def post_user_badges(self, request, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) - def get_user_detail_badges(self, request, *args, **kwargs): + def get_user_detail_badges(self, request, *_args, **kwargs): user = self._get_user(request, kwargs["pk"]) user_badges = user.user_badges.order_by("-created_at") badges = [ @@ -289,7 +289,7 @@ def get_or_post_detail_user_badges(self, request, *args, **kwargs): return self.post_user_badges(request, *args, **kwargs) @action(detail=False, methods=["get"], url_path="me/strikes") - def get_user_strikes(self, request, *args, **kwargs): + def get_user_strikes(self, request, *_args, **_kwargs): strikes = request.user.strikes.active() serializer = UserInfoStrikeSerializer( instance=strikes, many=True, context={"request": request} @@ -297,7 +297,7 @@ def get_user_strikes(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=True, methods=["get"], url_path="strikes") - def get_user_detail_strikes(self, request, *args, **kwargs): + def get_user_detail_strikes(self, request, *_args, **_kwargs): user = self.get_object() strikes = user.strikes.active() serializer = UserInfoStrikeSerializer( @@ -306,7 +306,7 @@ def get_user_detail_strikes(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=False, methods=["get"], url_path="me/events") - def get_user_events(self, request, *args, **kwargs): + def get_user_events(self, request, *_args, **_kwargs): filter_field = self.request.query_params.get("expired") event_has_ended = CaseInsensitiveBooleanQueryParam(filter_field).value @@ -326,7 +326,7 @@ def get_user_events(self, request, *args, **kwargs): ) @action(detail=False, methods=["get"], url_path="me/forms") - def get_user_forms(self, request, *args, **kwargs): + def get_user_forms(self, request, *_args, **_kwargs): forms = request.user.forms filter_field = request.query_params.get("unanswered") @@ -347,7 +347,7 @@ def get_user_forms(self, request, *args, **kwargs): url_path="activate", permission_classes=(IsHS | IsDev,), ) - def makeTIHLDEMember(self, request, *args, **kwargs): + def makeTIHLDEMember(self, request, *_args, **_kwargs): TIHLDE = Group.objects.get(slug=Groups.TIHLDE) user_id = request.data["user_id"] user = get_object_or_404(User, user_id=user_id) @@ -372,7 +372,7 @@ def makeTIHLDEMember(self, request, *args, **kwargs): url_path="decline", permission_classes=(IsHS | IsDev,), ) - def declineTIHLDEMember(self, request, *args, **kwargs): + def declineTIHLDEMember(self, request, *_args, **_kwargs): user_id = request.data["user_id"] try: reason = request.data["reason"] @@ -397,7 +397,7 @@ def declineTIHLDEMember(self, request, *args, **kwargs): ) @action(detail=False, methods=["get"], url_path="me/data") - def export_user_data(self, request, *args, **kwargs): + def export_user_data(self, request, *_args, **_kwargs): export_successfull = export_user_data(request, request.user) if export_successfull: @@ -414,7 +414,7 @@ def export_user_data(self, request, *args, **kwargs): ) @action(detail=False, methods=["get"], url_path="me/reservations") - def get_user_reservations(self, request, *args, **kwargs): + def get_user_reservations(self, request, *_args, **_kwargs): user = request.user reservations = Reservation.objects.filter(author=user).order_by("start_time") serializer = ReservationSerializer( diff --git a/app/content/views/user_bio.py b/app/content/views/user_bio.py index fcb7da4c0..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 fd480a5bf..59e5d4999 100644 --- a/app/content/views/user_calendar_events.py +++ b/app/content/views/user_calendar_events.py @@ -22,7 +22,8 @@ def __call__(self, request, *args, **kwargs): if not self.user.public_event_registrations: 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" + "detail": "Denne brukeren har skrudd av offentlig deling av påmeldinger til arrangementer. " + "Du kan derfor ikke hente ut brukerens arrangementer som .ics-fil" }, status=status.HTTP_403_FORBIDDEN, ) diff --git a/app/emoji/views/reaction.py b/app/emoji/views/reaction.py index 518364df2..38c19f2ef 100644 --- a/app/emoji/views/reaction.py +++ b/app/emoji/views/reaction.py @@ -31,7 +31,7 @@ def update(self, request, *args, **kwargs): reaction, data=request.data, context={"request": request} ) if serializer.is_valid(): - reaction = super().perform_update(serializer) + super().perform_update(serializer) return Response(serializer.data, status=status.HTTP_200_OK) def destroy(self, request, *args, **kwargs): diff --git a/app/feedback/serializers/feedback.py b/app/feedback/serializers/feedback.py index 5c5ffad1b..c0228e210 100644 --- a/app/feedback/serializers/feedback.py +++ b/app/feedback/serializers/feedback.py @@ -21,4 +21,5 @@ class Meta: "status", "created_at", "author", + "description", ) 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 82% rename from app/content/views/upload.py rename to app/files/views/upload.py index e203c0f76..d5c9d8b25 100644 --- a/app/content/views/upload.py +++ b/app/files/views/upload.py @@ -27,36 +27,34 @@ def upload(request): key = list(request.FILES.keys())[0] blob = request.FILES[key] - url = AzureFileHandler(blob).uploadBlob() + url = AzureFileHandler(blob).upload_blob() return Response( {"url": url}, status=status.HTTP_200_OK, ) - - except ValueError as value_error: + except ValueError: return Response( - {"detail": str(value_error)}, + {"detail": "En feil oppstod under behandlingen av forespørselen."}, status=status.HTTP_400_BAD_REQUEST, ) @api_view(["DELETE"]) @permission_classes([IsMember]) -def delete(request, container_name, blob_name): +def delete(_request, container_name, blob_name): """Method for deleting files from Azure Blob Storage, only allowed for members""" try: handler = AzureFileHandler() handler.blobName = blob_name handler.containerName = container_name - handler.deleteBlob() + handler.delete_blob() return Response( {"detail": "Filen ble slettet"}, status=status.HTTP_200_OK, ) - - except ValueError as value_error: + except ValueError: return Response( - {"detail": str(value_error)}, + {"detail": "En feil oppstod under behandlingen av forespørselen."}, status=status.HTTP_400_BAD_REQUEST, ) 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/forms/exceptions.py b/app/forms/exceptions.py index c5ad930d8..932613f86 100644 --- a/app/forms/exceptions.py +++ b/app/forms/exceptions.py @@ -8,7 +8,7 @@ class APIDuplicateSubmissionException(APIException): class DuplicateSubmission(ValueError): - pass + default_detail = "Spørreskjemaet tillater kun én innsending" class APIFormNotOpenForSubmissionException(APIException): @@ -17,7 +17,7 @@ class APIFormNotOpenForSubmissionException(APIException): class FormNotOpenForSubmission(ValueError): - pass + default_detail = "Spørreskjemaet er ikke åpent for innsending" class APIGroupFormOnlyForMembersException(APIException): @@ -26,4 +26,4 @@ class APIGroupFormOnlyForMembersException(APIException): class GroupFormOnlyForMembers(ValueError): - pass + default_detail = "Spørreskjemaet er kun åpent for medlemmer av gruppen" diff --git a/app/forms/models/forms.py b/app/forms/models/forms.py index 642654ec8..2385a4994 100644 --- a/app/forms/models/forms.py +++ b/app/forms/models/forms.py @@ -64,7 +64,7 @@ def has_retrieve_permission(cls, request): return True @classmethod - def has_statistics_permission(cls, request): + def has_statistics_permission(cls, _request): return True def has_object_statistics_permission(self, request): @@ -112,7 +112,6 @@ def has_object_read_permission(self, request): class EventForm(Form): - event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="forms") type = models.CharField( max_length=40, choices=EventFormType.choices, default=EventFormType.SURVEY @@ -160,7 +159,6 @@ def has_object_read_permission(self, request): class GroupForm(Form): - read_access = [Groups.TIHLDE] email_receiver_on_submit = models.EmailField(max_length=200, null=True, blank=True) group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name="forms") @@ -212,7 +210,6 @@ def has_object_write_permission(self, request): class Field(OrderedModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) title = models.CharField(max_length=400) form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name="fields") @@ -234,7 +231,6 @@ class Meta(OrderedModel.Meta): class Option(OrderedModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) title = models.CharField(max_length=400, default="") field = models.ForeignKey(Field, on_delete=models.CASCADE, related_name="options") @@ -298,7 +294,7 @@ def check_multiple_submissions(self): elif isinstance(self.form, GroupForm): self.check_group_form_can_submit_multiple() else: - raise DuplicateSubmission("Spørreskjemaet tillater kun én innsending") + raise DuplicateSubmission() def check_event_form_has_registration(self): user_has_registration = self.form.event.registrations.filter( @@ -311,21 +307,17 @@ def check_event_form_has_registration(self): def check_group_form_can_submit_multiple(self): if not self.form.can_submit_multiple: - raise DuplicateSubmission("Spørreskjemaet tillater kun én innsending") + raise DuplicateSubmission() def check_group_form_open_for_submissions(self): if not self.form.is_open_for_submissions: - raise FormNotOpenForSubmission( - "Spørreskjemaet er ikke åpent for innsending" - ) + raise FormNotOpenForSubmission() def check_group_form_only_for_members(self): if self.form.only_for_group_members and not self.user.is_member_of( self.form.group ): - raise GroupFormOnlyForMembers( - "Spørreskjemaet er kun åpent for medlemmer av gruppen" - ) + raise GroupFormOnlyForMembers() @classmethod def _get_form_from_request(cls, request): @@ -388,7 +380,6 @@ def has_download_permission(cls, request): class Answer(BaseModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) submission = models.ForeignKey( Submission, on_delete=models.CASCADE, related_name="answers" diff --git a/app/forms/views/form.py b/app/forms/views/form.py index 09ec32a4a..a5b75cfd9 100644 --- a/app/forms/views/form.py +++ b/app/forms/views/form.py @@ -52,7 +52,7 @@ def destroy(self, request, *args, **kwargs): return Response({"detail": "Skjemaet ble slettet"}, status=status.HTTP_200_OK) @action(detail=True, methods=["get"], url_path="statistics") - def statistics(self, request, *args, **kwargs): + def statistics(self, _request, *_args, **_kwargs): form = self.get_object() serializer = FormStatisticsSerializer(form) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/app/forms/views/submission.py b/app/forms/views/submission.py index b148a2126..0333d71c0 100644 --- a/app/forms/views/submission.py +++ b/app/forms/views/submission.py @@ -68,6 +68,6 @@ def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) @action(detail=False, methods=["get"], url_path="download") - def download(self, request, *args, **kwargs): + def download(self, _request, *_args, **_kwargs): """To return the response as csv, include header 'Accept: text/csv.""" return SubmissionsCsvWriter(self.get_queryset()).write_csv() 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/gallery/views/picture.py b/app/gallery/views/picture.py index fa3cf5403..7f395e5bf 100644 --- a/app/gallery/views/picture.py +++ b/app/gallery/views/picture.py @@ -36,7 +36,7 @@ def create(self, request, *args, **kwargs): errors = 0 for file in files: try: - url = AzureFileHandler(file).uploadBlob() + url = AzureFileHandler(file).upload_blob() Picture.objects.create(image=url, album=album) except Exception: errors += 1 diff --git a/app/group/exceptions.py b/app/group/exceptions.py index c0ca1867d..6c1c8a802 100644 --- a/app/group/exceptions.py +++ b/app/group/exceptions.py @@ -8,7 +8,7 @@ class APIUserIsNotInGroupException(APIException): class UserIsNotInGroup(ValidationError): - pass + default_detail = "En av brukerne er ikke medlem av gruppen" class APIGroupTypeNotInPublicGroupsException(APIException): @@ -17,4 +17,4 @@ class APIGroupTypeNotInPublicGroupsException(APIException): class GroupTypeNotInPublicGroups(ValueError): - pass + default_detail = "Ikke gylde gruppetype" diff --git a/app/group/filters/membership.py b/app/group/filters/membership.py index b975bb737..f04548c61 100644 --- a/app/group/filters/membership.py +++ b/app/group/filters/membership.py @@ -16,7 +16,7 @@ class Meta: model = Membership fields = ["onlyMembers"] - def filter_membership_type(self, queryset, name, value): + def filter_membership_type(self, queryset, _name, value): if value: return queryset.filter(membership_type=MembershipType.MEMBER) return queryset diff --git a/app/group/serializers/group.py b/app/group/serializers/group.py index b3ac02146..02c56144b 100644 --- a/app/group/serializers/group.py +++ b/app/group/serializers/group.py @@ -95,7 +95,7 @@ class Meta: model = Group fields = ("studyyears", "studies") - def get_studyyears(self, obj, *args, **kwargs): + def get_studyyears(self, obj, *_args, **_kwargs): return filter( lambda studyyear: studyyear["amount"] > 0, map( @@ -109,7 +109,7 @@ def get_studyyears(self, obj, *args, **kwargs): ), ) - def get_studies(self, obj, *args, **kwargs): + def get_studies(self, obj, *_args, **_kwargs): return filter( lambda study: study["amount"] > 0, map( diff --git a/app/group/views/fine.py b/app/group/views/fine.py index 8b443eaa1..de15f638a 100644 --- a/app/group/views/fine.py +++ b/app/group/views/fine.py @@ -42,6 +42,7 @@ def get_queryset(self): group__slug=self.kwargs["slug"], group__fines_activated=True ) + # noinspection PyShadowingNames def create(self, request, *args, **kwargs): context = { "group_slug": kwargs["slug"], @@ -85,7 +86,7 @@ def destroy(self, request, *args, **kwargs): return Response({"detail": ("Boten ble slettet")}, status=status.HTTP_200_OK) @action(detail=False, methods=["get"], url_path=r"users/(?P[^/.]+)") - def get_user_fines(self, request, *args, **kwargs): + def get_user_fines(self, _request, *_args, **kwargs): """Get the fines of a specific user in a group""" fines = ( @@ -96,7 +97,7 @@ def get_user_fines(self, request, *args, **kwargs): return self.paginate_response(data=fines, serializer=FineNoUserSerializer) @action(detail=False, methods=["get"], url_path="users") - def get_fine_users(self, request, *args, **kwargs): + def get_fine_users(self, _request, *_args, **_kwargs): """Get the users in a group which has fines and how many""" users = self.get_fine_filter_query() return self.paginate_response(data=users, serializer=UserFineSerializer) @@ -119,7 +120,7 @@ def get_fine_filter_query(self): ) @action(detail=False, methods=["put"], url_path="batch-update") - def batch_update_fines(self, request, *args, **kwargs): + def batch_update_fines(self, request, *_args, **_kwargs): """Update a batch of fines at once""" assert request.data["data"] fines = self.get_queryset().filter(id__in=request.data["fine_ids"]) @@ -140,7 +141,7 @@ def batch_update_fines(self, request, *args, **kwargs): ) @action(detail=False, methods=["put"], url_path=r"batch-update/(?P[^/.]+)") - def batch_update_user_fines(self, request, *args, **kwargs): + def batch_update_user_fines(self, request, *_args, **kwargs): """Update all the fines of a user in a specific group""" fines = self.get_queryset().filter(user__user_id=kwargs["user_id"]) serializer = FineUpdateCreateSerializer( @@ -160,12 +161,12 @@ def batch_update_user_fines(self, request, *args, **kwargs): ) @action(detail=False, methods=["get"], url_path="statistics") - def get_group_fine_statistics(self, request, *args, **kwargs): + def get_group_fine_statistics(self, _request, *_args, **kwargs): group = Group.objects.get(slug=kwargs["slug"]) return Response(FineStatisticsSerializer(group).data, status=status.HTTP_200_OK) @action(detail=True, methods=["put"], url_path="defense") - def update_defense(self, request, *args, **kwargs): + def update_defense(self, request, *_args, **_kwargs): fine = self.get_object() serializer = FineUpdateDefenseSerializer( fine, data=request.data, context={"request": request} diff --git a/app/group/views/group.py b/app/group/views/group.py index 80e24f399..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,14 +72,14 @@ 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, ) @action(detail=True, methods=["get"], url_path="statistics") - def statistics(self, request, *args, **kwargs): + def statistics(self, request, *_args, **_kwargs): group = self.get_object() serializer = GroupStatisticsSerializer(group, context={"request": request}) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/app/group/views/law.py b/app/group/views/law.py index 62786c350..83228bf9d 100644 --- a/app/group/views/law.py +++ b/app/group/views/law.py @@ -32,4 +32,4 @@ def create(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): super().destroy(request, *args, **kwargs) - return Response({"detail": ("Loven ble slettet")}, status=status.HTTP_200_OK) + return Response({"detail": "Loven ble slettet"}, status=status.HTTP_200_OK) diff --git a/app/group/views/membership.py b/app/group/views/membership.py index 8b7ff0411..f98db0ab7 100644 --- a/app/group/views/membership.py +++ b/app/group/views/membership.py @@ -57,9 +57,14 @@ def update(self, request, *args, **kwargs): f"Du er nå leder i {membership.group.name}", 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 diverse funksjonalitet på nettsiden. Du kan finne administrasjonspanelene du har tilgang til under "Admin" i din profil.' + 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'under "Admin" i din profil.' ).add_paragraph( - f'Som leder har du også fått administratorrettigheter i "{membership.group.name}". Det innebærer at du kan legge til og fjerne medlemmer, endre tidligere medlemskap og administrere gruppens spørreskjemaer. I gruppens innstillinger kan du endre gruppens beskrivelse og logo, samt aktivere botsystemet og velge en botsjef.' + 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." ).add_paragraph( "Gratulerer så mye og lykke til med ledervervet!" ).add_link( @@ -87,7 +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å nettsiden. Du kan finne administrasjonspanelene du har tilgang til under "Admin" 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/payment/models/order.py b/app/payment/models/order.py index dd2214675..e82fc55e2 100644 --- a/app/payment/models/order.py +++ b/app/payment/models/order.py @@ -75,10 +75,10 @@ def has_list_permission(cls, request): def has_read_all_permission(cls, request): return is_admin_user(request) - def has_object_update_permission(self, request): + def has_object_update_permission(self, _request): return False - def has_object_destroy_permission(self, request): + def has_object_destroy_permission(self, _request): return False def has_object_retrieve_permission(self, request): diff --git a/app/payment/tasks.py b/app/payment/tasks.py index 26e13fab9..f41386c22 100644 --- a/app/payment/tasks.py +++ b/app/payment/tasks.py @@ -7,7 +7,7 @@ @app.task(bind=True, base=BaseTask) -def check_if_has_paid(self, event_id, registration_id): +def check_if_has_paid(_self, event_id, registration_id): registration = Registration.objects.filter(registration_id=registration_id).first() event = Event.objects.filter(id=event_id).first() diff --git a/app/payment/util/payment_utils.py b/app/payment/util/payment_utils.py index e7e2f6118..b3bd93677 100644 --- a/app/payment/util/payment_utils.py +++ b/app/payment/util/payment_utils.py @@ -26,7 +26,7 @@ def get_new_access_token(): response = response.json() - return (response["expires_on"], response["access_token"]) + return response["expires_on"], response["access_token"] def check_access_token(): diff --git a/app/payment/views/vipps_callback.py b/app/payment/views/vipps_callback.py index 754564d79..f0224ff42 100644 --- a/app/payment/views/vipps_callback.py +++ b/app/payment/views/vipps_callback.py @@ -19,4 +19,4 @@ def force_payment(order_id): res = requests.post(url, headers=headers) status_code = res.status_code json = res.json() - return (json, status_code) + return json, status_code 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 379619927..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,25 @@ 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() + add_user_to_group_with_name(admin, AdminGroup.HS) + return admin + + @pytest.fixture() def feedback_bug(): return BugFactory() diff --git a/app/tests/content/test_registration_integration.py b/app/tests/content/test_registration_integration.py index 5b9f20f84..eb7a39909 100644 --- a/app/tests/content/test_registration_integration.py +++ b/app/tests/content/test_registration_integration.py @@ -4,15 +4,17 @@ 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 from app.content.factories import EventFactory, RegistrationFactory, UserFactory from app.content.factories.priority_pool_factory import PriorityPoolFactory from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import EventFormFactory, SubmissionFactory from app.group.factories import GroupFactory from app.payment.enums import OrderStatus +from app.payment.factories import OrderFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client from app.util.utils import now @@ -1036,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"), @@ -1071,3 +1177,135 @@ def test_delete_registration_with_paid_order_as_self( response = client.delete(url) assert response.status_code == status_code + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("filter_params", "participant_count", "status_code"), + [ + ({"has_allergy": True}, 2, status.HTTP_200_OK), + ({"year": "2050"}, 1, status.HTTP_200_OK), + ({"year": "2051"}, 1, status.HTTP_200_OK), + ({"study": StudyType.DATAING}, 2, status.HTTP_200_OK), + ({"year": "2050", "study": StudyType.DATAING}, 1, status.HTTP_200_OK), + ( + {"has_allergy": True, "year": "2051", "study": StudyType.DATAING}, + 1, + status.HTTP_200_OK, + ), + ( + {"has_allergy": True, "year": "2050", "study": StudyType.DATAING}, + 1, + status.HTTP_200_OK, + ), + ], +) +def test_filter_participants( + new_admin_user, member, event, filter_params, participant_count, status_code +): + """ + An admin should be able to filter the participants of an event using multiple parameters + """ + + member.allergy = "Pizza" + member.save() + + new_admin_user.allergy = "Fisk" + new_admin_user.save() + + add_user_to_group_with_name(member, StudyType.DATAING, GroupType.STUDY) + add_user_to_group_with_name(member, "2050", GroupType.STUDYYEAR) + + add_user_to_group_with_name(new_admin_user, "2051", GroupType.STUDYYEAR) + add_user_to_group_with_name(new_admin_user, StudyType.DATAING, GroupType.STUDY) + + RegistrationFactory(user=member, event=event) + RegistrationFactory(user=new_admin_user, event=event) + client = get_api_client(user=new_admin_user) + + # Build the query string with multiple filter parameters + url = ( + _get_registration_url(event) + + "?" + + "&".join([f"{key}={value}" for key, value in filter_params.items()]) + ) + response = client.get(url) + + assert participant_count == response.data["count"] + assert response.status_code == status_code + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("filter_params", "participant_count", "status_code"), + [ + ({"study": StudyType.DATAING, "has_paid": True}, 1, status.HTTP_200_OK), + ({"study": StudyType.DIGFOR, "has_paid": True}, 2, status.HTTP_200_OK), + ({"study": StudyType.DIGFOR, "has_paid": False}, 1, status.HTTP_200_OK), + ({"has_paid": True, "year": "2050"}, 1, status.HTTP_200_OK), + ({"has_paid": True, "year": "2051"}, 1, status.HTTP_200_OK), + ({"has_paid": True}, 4, status.HTTP_200_OK), + ({"has_paid": False}, 2, status.HTTP_200_OK), + ], +) +def test_filter_participants_paid_event( + new_admin_user, + member, + event, + paid_event, + filter_params, + participant_count, + status_code, +): + """ + An admin should be able to filter the participants of an event using multiple parameters + """ + + paid_event.event = event + + paid_event.save() + + member.allergy = "Pizza" + member.save() + + new_admin_user.allergy = "Fisk" + new_admin_user.save() + + new_user = UserFactory() + new_user2 = UserFactory() + new_user3 = UserFactory() + new_user4 = UserFactory() + + add_user_to_group_with_name(member, StudyType.DATAING, GroupType.STUDY) + add_user_to_group_with_name(member, "2050", GroupType.STUDYYEAR) + + add_user_to_group_with_name(new_admin_user, "2051", GroupType.STUDYYEAR) + add_user_to_group_with_name(new_admin_user, StudyType.DIGFOR, GroupType.STUDY) + add_user_to_group_with_name(new_user2, StudyType.DIGFOR, GroupType.STUDY) + add_user_to_group_with_name(new_user3, StudyType.DIGFOR, GroupType.STUDY) + + RegistrationFactory(user=member, event=event) + RegistrationFactory(user=new_admin_user, event=event) + RegistrationFactory(user=new_user, event=event) + RegistrationFactory(user=new_user2, event=event) + RegistrationFactory(user=new_user3, event=event) + RegistrationFactory(user=new_user4, event=event) + + OrderFactory(event=event, user=member, status=OrderStatus.SALE) + OrderFactory(event=event, user=new_admin_user, status=OrderStatus.SALE) + OrderFactory(event=event, user=new_user4, status=OrderStatus.SALE) + OrderFactory(event=event, user=new_user2, status=OrderStatus.SALE) + OrderFactory(event=event, user=new_user, status=OrderStatus.CANCEL) + OrderFactory(event=event, user=new_user3, status=OrderStatus.CANCEL) + + client = get_api_client(user=new_admin_user) + + # Build the query string with multiple filter parameters + url = ( + _get_registration_url(paid_event) + + "?" + + "&".join([f"{key}={value}" for key, value in filter_params.items()]) + ) + response = client.get(url) + assert participant_count == response.data["count"] + assert response.status_code == status_code 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/export_user_data.py b/app/util/export_user_data.py index b8fb2f74b..cdfda0fbf 100644 --- a/app/util/export_user_data.py +++ b/app/util/export_user_data.py @@ -104,8 +104,6 @@ def export_user_data(request, user): mails = MailGDPRSerializer(user.emails, many=True, context={"request": request}) data["eposter"] = mails.data - is_success = False - with tempfile.NamedTemporaryFile() as tmp: with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as archive: for model, json in data.items(): diff --git a/app/util/migrations.py b/app/util/migrations.py index 99dd1698b..087c25ab0 100644 --- a/app/util/migrations.py +++ b/app/util/migrations.py @@ -22,7 +22,7 @@ class UpdateContentType(migrations.RunPython): def _update_contenttype_func( self, old_app: str, old_model: str, new_app: str, new_model: str ): - def func(apps, schema_editor): + def func(_apps, _schema_editor): ContentType.objects.filter(app_label=old_app, model=old_model).update( app_label=new_app, model=new_model ) 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 diff --git a/app/util/utils.py b/app/util/utils.py index 4260acac1..19908b342 100644 --- a/app/util/utils.py +++ b/app/util/utils.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) -def getTimezone(): +def get_timezone(): return pytz_timezone(settings.TIME_ZONE) @@ -18,7 +18,7 @@ def yesterday(): def now(): - return datetime.now(tz=getTimezone()) + return datetime.now(tz=get_timezone()) def tomorrow(): diff --git a/requirements.txt b/requirements.txt index 4f6210f3f..7f1d547fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,12 +3,12 @@ logzero aiohttp-cors wheel mysqlclient == 2.1.1 -sentry-sdk == 2.8.0 +sentry-sdk == 2.14.0 celery == 5.4.0 azure-storage-blob == 12.23.1 python-dotenv ~= 1.0.1 gunicorn == 23.0.0 -uvicorn == 0.30.6 +uvicorn == 0.32.0 whitenoise == 6.7.0 django-ical == 1.9.2 slack-sdk == 3.33.1 @@ -44,7 +44,7 @@ isort flake8 flake8-django flake8-black -pre-commit == 3.8.0 +pre-commit == 4.0.1 # Testing # ------------------------------------------------------------------------------