From 2ad96e2eef22cca26836616e1822fb2f06fc1b83 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Wed, 25 Sep 2024 11:26:53 +0200 Subject: [PATCH] Late September Update (#879) * Feat(kontres)/add image to bookable item (#785) * added optional image to bookable item model * added update method in serializer to handle new images * linting * remove update method for images * Feat(kontres)/add approved by (#786) * added approved by field * endpoint will now set approved by * serializer will return full user object in approved_by_detail * created test for approved by * migration * remove unnecessary code * removed write-only field in approved-by context * Create minutes for Codex (#787) * init * format * Feat(minute)/viewset (#788) * added richer reponse on post and put * added to admin panel * added filter for minute * Feat(kontres)/add notification (#790) * created methods for sending notification to admin and user * endpoint will now send notification if needed * add migrations for new notification types * Memberships with fines activated (#791) init * Feat(user)/user bio (#758) * Created model, serializer and view for user-bio * Created user bio model and made migrations * Created user bio serializer + viewsets + added new endpoint * Tested create method + added bio serializer to user serializer * Format * Created update method and started testing * Debugging test failures in user retrieve * fixed model error * Created user_bio_factory + started testing put method * Created fixture for UserBio * Created custom excpetion for duplicate user bio * Added permissions and inherited from BaseModel * Modularized serializer for bio * Use correct serializers in viewset + added destroy method * Finished testing bio viewset integration + format * Changed environent file to .env to avoid pushing up keys * Fix: Flipped assertion statement in test, since user bio should not be deleted * skiped buggy test from kontres * added mark to pytest.skip * Moved keys to .env file and reverted docker variables * Skip buggy kontres test * format * Added str method to user_bio * Removed unused imports * format * Changed user relation to a OneToOne-field (same affect as ForeignKey(unique=True) + removed check for duplicate bio in serializer * Migrations + changed assertion status code in duplicate bio test (could try catch in serializer to produce 400 status code) * format * format * Changed limit for description 50 -> 500 + migrations * Migrate * added id to serializer * merged leaf nodes in migrations * format --------- Co-authored-by: Ester2109 <126612066+Ester2109@users.noreply.github.com> Co-authored-by: Mads Nylund Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Co-authored-by: Tam Le * Update CHANGELOG.md * added filter for allowed photos for user (#794) added filter for allowed photos * Upped payment time when coming from waiting list (#796) * fixed paymenttime saved to db (#798) * fixed bug (#800) * Disallow users to unregister when payment is done (#802) added 400 status code for deleting paid registration * update changelog * Added serializer for category in event (#804) added serializer for category in event * Permission middelware (#806) * added a check for existing user and id on request * format * Permission refactor of QR Codes (#807) * added permissions to qr code and refactored viewset * format * removed unused imports * Permissions for payment orders (#808) * added read permissions * added permissions for payment order and tests * format * chore(iac): updated docs and force https (#810) chore: updated docs and force https * feat(iac): add terraform guardrails so index don't nuke our infra (#811) feat: add guardrails so index don't fup * Automatic registration for new users with Feide (#809) * started on feide registration endpoint * made endpoint for creating user with Feide * added test for parse group * finished * format * removes three years if in digtrans * changelog update * Feide env variables Terraform (#814) added feid env variables * added delete endpoint for file (#815) * added delete endpoint for file * Trigger Build * changed workflow to checkout v4 * changed from docker-compose to docker compose * Update CHANGELOG.md * format * format * fixed permission for committee leaders for group forms * updated csv for forms (#818) * Permission for group forms and news (#820) added permission for committees to create news, and all leaders of groups to create group forms * Update reservation_seralizer.py (#822) * Update reservation_seralizer.py * Fixed linting * Put a band aid on it *smack* * Removed blank line.. * ???? * Group ownership of Minutes (#847) * Refactor MinuteFactory to include group field, and added validation for checkin group access * added validation for POST request * Changed endpoint response (#846) * Changed endpoint response * Fixed test * Update test_reservation_integration.py --------- Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> * updated changelog.md * finished events now appear in the correct order (newest first) (#849) * finished events now appear in the correct order (newest first) * added description of change in changelog * fixed formatting * updated method to use Django ORM instead of using python methods * Implement Swagger (#858) * started on removing choiceenums * refactored cheatsheet and membership * refacotered strike enum * refactored Groups enum * removed AppModel choiceenum * added swagger * Swagger GitHub Action (#860) * added github action for checking if Swagger is up * new action * try another * tried implementing check for container * added curl to docker image * added check if swagger is up * test if swagger does not get status code 200 * added ?format=openai to trigger error * checking that the request is working * updated CHANGELOG.md * Add new app (#862) * added script for adding new app to Lepton * added command to Makefile * Upgrade all dependencies to latest (#857) * Add endpoint to create new group as admin Signed-off-by: Tmpecho * Upgrade all dependencies to latest Signed-off-by: Tmpecho * remove bad exception handling in serializers/group.py * fix inheritance ordering in views/group.py * refactored group integration test, added non_public_groups to enums * fix linting * reformat files * remove unused import in groups/views/group.py * Upgrade dependency "black" Signed-off-by: Tmpecho * Upgrade dependency "sentry-sdk" Signed-off-by: Tmpecho * Upgrade dependency "azure-storage-blob" and remove outdated comment in requirements.txt Signed-off-by: Tmpecho * Upgrade all non-django dependencies Signed-off-by: Tmpecho * Upgrade dependency "Django" Signed-off-by: Tmpecho * Upgrade dependencies and remove ignored version from docker-compose.yml Signed-off-by: Tmpecho --------- Signed-off-by: Tmpecho Co-authored-by: 1Cezzo * Allow HS members to create a new group (#864) * Add endpoint to create new group as admin Signed-off-by: Tmpecho * remove bad exception handling in serializers/group.py * fix inheritance ordering in views/group.py * refactored group integration test, added non_public_groups to enums * fix linting * reformat files * remove unused import in groups/views/group.py --------- Signed-off-by: Tmpecho Co-authored-by: 1Cezzo * App Script Fix (#875) added serializers dir to script * Event registration payment orders (#876) * added list of payment orders for registrations * update CHANGELOG.md * chore(deps): update python-dotenv requirement from ~=0.21.1 to ~=1.0.1 (#871) Updates the requirements on [python-dotenv](https://github.com/theskumar/python-dotenv) to permit the latest version. - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v0.21.1...v1.0.1) --- updated-dependencies: - dependency-name: python-dotenv dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> * Chore(deps): Bump sentry-sdk from 1.14.0 to 2.8.0 (#866) Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 1.14.0 to 2.8.0. - [Release notes](https://github.com/getsentry/sentry-python/releases) - [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-python/compare/1.14.0...2.8.0) --- updated-dependencies: - dependency-name: sentry-sdk dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> * Codex Course (#852) * added models for course and registration, and viewset for course and tests * added validation for date checking for courses * added viewset for registration for codex courses * removed unused fields from course model * removed unused imports * added API error mixins as mother clas * fixed error mixin * refactored to event model * fixed wrong import * fixed tests * format * skipped broken tests, must be refactored * updated CHANGELOG.md * format --------- Signed-off-by: Tmpecho Signed-off-by: dependabot[bot] Co-authored-by: Erik Skjellevik <98759397+eriskjel@users.noreply.github.com> Co-authored-by: haruixu <114171733+haruixu@users.noreply.github.com> Co-authored-by: Ester2109 <126612066+Ester2109@users.noreply.github.com> Co-authored-by: Tam Le Co-authored-by: martcl Co-authored-by: Frikk Balder <33499052+MindChirp@users.noreply.github.com> Co-authored-by: Emil Johnsen <111747340+1Cezzo@users.noreply.github.com> Co-authored-by: Johannes Aamot-Skeidsvoll <82368148+Tmpecho@users.noreply.github.com> Co-authored-by: 1Cezzo Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 35 +++ CHANGELOG.md | 10 + Makefile | 8 +- app/badge/filters/badge.py | 2 +- app/codex/__init__.py | 0 app/codex/admin/__init__.py | 1 + app/codex/admin/admin.py | 7 + app/codex/apps.py | 0 app/codex/enums.py | 23 ++ app/codex/exceptions.py | 24 ++ app/codex/factories/__init__.py | 2 + app/codex/factories/event.py | 20 ++ app/codex/factories/registration.py | 15 + app/codex/filters/__init__.py | 1 + app/codex/filters/event.py | 20 ++ app/codex/migrations/0001_initial.py | 123 +++++++++ app/codex/migrations/__init__.py | 0 app/codex/mixins.py | 17 ++ app/codex/models/__init__.py | 0 app/codex/models/event.py | 93 +++++++ app/codex/models/registration.py | 52 ++++ app/codex/serializers/__init__.py | 10 + app/codex/serializers/event.py | 109 ++++++++ app/codex/serializers/registration.py | 26 ++ app/codex/tests/__init__.py | 0 app/codex/urls.py | 18 ++ app/codex/util/__init__.py | 2 + app/codex/util/event.py | 16 ++ app/codex/util/user.py | 5 + app/codex/views/__init__.py | 2 + app/codex/views/event.py | 85 ++++++ app/codex/views/registration.py | 61 ++++ app/common/enums.py | 106 ++++++- app/content/admin/admin.py | 2 +- app/content/enums.py | 4 +- app/content/factories/cheatsheet_factory.py | 4 +- app/content/factories/registration_factory.py | 6 +- app/content/factories/strike_factory.py | 2 +- app/content/filters/strike.py | 2 +- app/content/filters/user.py | 3 +- ...t_grade_alter_cheatsheet_study_and_more.py | 59 ++++ app/content/models/cheatsheet.py | 25 +- app/content/models/event.py | 6 +- app/content/models/registration.py | 4 +- app/content/models/user.py | 7 +- app/content/serializers/event.py | 10 +- app/content/serializers/minute.py | 6 +- app/content/serializers/registration.py | 9 +- app/content/serializers/user.py | 3 +- app/content/tests/test_registration_model.py | 4 +- app/content/tests/test_user_model.py | 2 +- app/content/views/minute.py | 4 +- app/content/views/strike.py | 4 +- app/content/views/user.py | 25 +- app/emoji/serializers/reaction.py | 4 +- app/forms/enums.py | 15 + .../migrations/0012_alter_eventform_type.py | 22 ++ app/forms/migrations/0013_alter_field_type.py | 26 ++ app/forms/models/forms.py | 12 +- app/forms/serializers/statistics.py | 2 +- app/forms/views/submission.py | 2 +- app/group/exceptions.py | 9 + app/group/factories/group_factory.py | 2 +- app/group/factories/membership_factory.py | 2 +- app/group/filters/group.py | 8 +- app/group/filters/membership.py | 2 +- app/group/migrations/0019_alter_group_type.py | 31 +++ ...ter_membership_membership_type_and_more.py | 31 +++ app/group/mixins.py | 16 +- app/group/models/group.py | 21 +- app/group/models/membership.py | 16 +- app/group/serializers/group.py | 30 +- app/group/tests/test_membership_model.py | 4 +- app/group/views/group.py | 43 ++- app/group/views/membership.py | 3 +- app/kontres/models/reservation.py | 3 +- app/payment/serializers/order.py | 6 + app/settings.py | 41 +-- .../test_badge_and_category_integration.py | 3 +- .../badge/test_user_badge_integration.py | 3 +- app/tests/codex/__init__.py | 0 .../codex/test_codex_event_integration.py | 261 ++++++++++++++++++ ...st_codex_event_registration_integration.py | 149 ++++++++++ ...t_user_notification_setting_integration.py | 8 +- app/tests/conftest.py | 14 +- .../content/test_cheatsheet_integration.py | 12 +- app/tests/content/test_event_integration.py | 6 +- app/tests/content/test_minute_integration.py | 2 +- app/tests/content/test_news_integration.py | 4 +- .../content/test_registration_integration.py | 6 +- app/tests/content/test_strike_integration.py | 5 +- .../content/test_user_bio_integration.py | 70 +++++ app/tests/content/test_user_integration.py | 11 +- app/tests/forms/test_eventform_integration.py | 10 +- app/tests/forms/test_form_integration.py | 2 +- .../forms/test_group_form_integration.py | 4 +- .../forms/test_submission_integration.py | 4 +- app/tests/groups/test_fine_integration.py | 3 +- app/tests/groups/test_group_integration.py | 61 ++-- app/tests/groups/test_law_integration.py | 3 +- .../test_membership_history_integration.py | 3 +- .../groups/test_membership_integration.py | 3 +- .../payment/test_paid_event_integration.py | 2 +- app/urls.py | 22 ++ app/util/test_utils.py | 4 +- compose/Dockerfile | 2 + docker-compose.yml | 2 - requirements.txt | 55 ++-- scripts/app.py | 73 +++++ 109 files changed, 2013 insertions(+), 229 deletions(-) create mode 100644 app/codex/__init__.py create mode 100644 app/codex/admin/__init__.py create mode 100644 app/codex/admin/admin.py create mode 100644 app/codex/apps.py create mode 100644 app/codex/enums.py create mode 100644 app/codex/exceptions.py create mode 100644 app/codex/factories/__init__.py create mode 100644 app/codex/factories/event.py create mode 100644 app/codex/factories/registration.py create mode 100644 app/codex/filters/__init__.py create mode 100644 app/codex/filters/event.py create mode 100644 app/codex/migrations/0001_initial.py create mode 100644 app/codex/migrations/__init__.py create mode 100644 app/codex/mixins.py create mode 100644 app/codex/models/__init__.py create mode 100644 app/codex/models/event.py create mode 100644 app/codex/models/registration.py create mode 100644 app/codex/serializers/__init__.py create mode 100644 app/codex/serializers/event.py create mode 100644 app/codex/serializers/registration.py create mode 100644 app/codex/tests/__init__.py create mode 100644 app/codex/urls.py create mode 100644 app/codex/util/__init__.py create mode 100644 app/codex/util/event.py create mode 100644 app/codex/util/user.py create mode 100644 app/codex/views/__init__.py create mode 100644 app/codex/views/event.py create mode 100644 app/codex/views/registration.py create mode 100644 app/content/migrations/0068_alter_cheatsheet_grade_alter_cheatsheet_study_and_more.py create mode 100644 app/forms/migrations/0012_alter_eventform_type.py create mode 100644 app/forms/migrations/0013_alter_field_type.py create mode 100644 app/group/migrations/0019_alter_group_type.py create mode 100644 app/group/migrations/0020_alter_membership_membership_type_and_more.py create mode 100644 app/tests/codex/__init__.py create mode 100644 app/tests/codex/test_codex_event_integration.py create mode 100644 app/tests/codex/test_codex_event_registration_integration.py create mode 100644 scripts/app.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f6babc08e..4f3389b07 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -74,5 +74,40 @@ jobs: - name: Check for unstaged migrations run: docker compose run --rm web python manage.py makemigrations --check --no-input + - name: Tear down the Stack + run: docker compose down + + swagger: + runs-on: ubuntu-latest + steps: + + - name: Checkout Code Repository + uses: actions/checkout@v4 + + - name: Build the Stack + run: docker compose build + + - name: Run the Stack + run: docker compose up -d + + - name: Wait for Docker container to be up + run: | + retries=10 + until docker compose exec web curl -f http://localhost:8000/ || [ $retries -eq 0 ]; do + echo "Waiting for container to be up..." + retries=$((retries - 1)) + sleep 5 + done + + - name: Make HTTP Request to Swagger + run: | + status_code=$(curl -o /dev/null -s -w "%{http_code}" http://localhost:8000/swagger/?format=openapi) + if [ "$status_code" -eq 200 ]; then + echo "Swagger UI is up" + else + echo "Failed to reach Swagger UI, status code: $status_code" + exit 1 + fi + - name: Tear down the Stack run: docker compose down \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 48850105a..04e14785b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ + # CHANGELOG ## Tegnforklaring @@ -14,6 +15,15 @@ ## Neste versjon +## Versjon 2024.09.25 + +- ✨**Codex arrangementer**. Det kan nå opprettes arrangementer på Codex, som medlemmer av Codex kan melde seg på. +- ⚡**Betalingsordre**. Man kan nå se historikk over betalingsordre for en påmelding til et arrangement. +- ✨**Gruppe**. HS kan nå opprette en ny gruppe. +- ⚡**Swagger**. La til en GitHub Action for å verifisere at Swagger er oppe og går. +- ✨**Swagger**. API dokumentasjon er nå tilgjengelig med Swagger. +- ⚡**Profil**. Endret rekkefølge på tidligere arrangementer slik at nyeste kommer først. + ## Versjon 2024.09.14 - ⚡**Codex**. Det er nå et skille mellom dokumenter opprettet av Drift og Index. diff --git a/Makefile b/Makefile index 7807594dd..a9d0a3553 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ createsuperuser: ## Create a new django superuser .PHONY: makemigrations makemigrations: ## Create migration files - docker compose run --rm web python manage.py makemigrations + docker compose run --rm web python manage.py makemigrations ${args} .PHONY: migrate migrate: ## Run django migrations @@ -96,4 +96,8 @@ pr: ## Pull Request format and checks .PHONY: shell shell: ## Open an interactive Django shell - docker compose run --rm web python manage.py shell \ No newline at end of file + docker compose run --rm web python manage.py shell + +.PHONY: app +app: ## Create a new Django app + python scripts/app.py \ No newline at end of file diff --git a/app/badge/filters/badge.py b/app/badge/filters/badge.py index 30950dc68..91e91a038 100644 --- a/app/badge/filters/badge.py +++ b/app/badge/filters/badge.py @@ -4,7 +4,7 @@ from app.badge.models import BadgeCategory, UserBadge from app.badge.models.badge import Badge -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.content.models import User from app.group.models.membership import Membership diff --git a/app/codex/__init__.py b/app/codex/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/codex/admin/__init__.py b/app/codex/admin/__init__.py new file mode 100644 index 000000000..14fed587e --- /dev/null +++ b/app/codex/admin/__init__.py @@ -0,0 +1 @@ +from app.codex.admin import admin diff --git a/app/codex/admin/admin.py b/app/codex/admin/admin.py new file mode 100644 index 000000000..e192be93c --- /dev/null +++ b/app/codex/admin/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from app.codex.models.event import CodexEvent +from app.codex.models.registration import CodexEventRegistration + +admin.site.register(CodexEvent) +admin.site.register(CodexEventRegistration) diff --git a/app/codex/apps.py b/app/codex/apps.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/codex/enums.py b/app/codex/enums.py new file mode 100644 index 000000000..ba9a554dd --- /dev/null +++ b/app/codex/enums.py @@ -0,0 +1,23 @@ +from django.db import models + + +class CodexGroups(models.TextChoices): + DRIFT = "Drift" + INDEX = "Index" + + @classmethod + def all(cls) -> list: + return [cls.DRIFT, cls.INDEX] + + @classmethod + def reverse(cls) -> list: + return [cls.INDEX, cls.DRIFT] + + +class CodexEventTags(models.TextChoices): + WORKSHOP = "Workshop" + LECTURE = "Lecture" + + @classmethod + def all(cls) -> list: + return [cls.WORKSHOP, cls.LECTURE] diff --git a/app/codex/exceptions.py b/app/codex/exceptions.py new file mode 100644 index 000000000..fd68fcce9 --- /dev/null +++ b/app/codex/exceptions.py @@ -0,0 +1,24 @@ +from rest_framework import status +from rest_framework.exceptions import APIException + + +class APICodexEventEndRegistrationDateAfterStartDate(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = ( + "Sluttdatoen for påmelding kan ikke være etter startdatoen for kurset" + ) + + +class APICodexEventEndRegistrationDateBeforeStartRegistrationDate(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = ( + "Sluttdatoen for påmelding kan ikke være før startdatoen for påmelding" + ) + + +class CodexEventEndRegistrationDateAfterStartDate(ValueError): + pass + + +class CodexEventEndRegistrationDateBeforeStartRegistrationDate(ValueError): + pass diff --git a/app/codex/factories/__init__.py b/app/codex/factories/__init__.py new file mode 100644 index 000000000..4f10aa806 --- /dev/null +++ b/app/codex/factories/__init__.py @@ -0,0 +1,2 @@ +from app.codex.factories.event import CodexEventFactory +from app.codex.factories.registration import CodexEventRegistrationFactory diff --git a/app/codex/factories/event.py b/app/codex/factories/event.py new file mode 100644 index 000000000..17b040067 --- /dev/null +++ b/app/codex/factories/event.py @@ -0,0 +1,20 @@ +from datetime import timedelta + +from django.utils import timezone + +import factory +from factory.django import DjangoModelFactory + +from app.codex.models.event import CodexEvent + + +class CodexEventFactory(DjangoModelFactory): + class Meta: + model = CodexEvent + + title = factory.Sequence(lambda n: f"Event {n}") + description = factory.Faker("text") + start_date = timezone.now() + timedelta(days=10) + + start_registration_at = timezone.now() - timedelta(days=1) + end_registration_at = timezone.now() + timedelta(days=9) diff --git a/app/codex/factories/registration.py b/app/codex/factories/registration.py new file mode 100644 index 000000000..cba9db1cf --- /dev/null +++ b/app/codex/factories/registration.py @@ -0,0 +1,15 @@ +import factory +from factory.django import DjangoModelFactory + +from app.codex.factories.event import CodexEventFactory +from app.codex.models.registration import CodexEventRegistration +from app.content.factories.user_factory import UserFactory + + +class CodexEventRegistrationFactory(DjangoModelFactory): + class Meta: + model = CodexEventRegistration + + user = factory.SubFactory(UserFactory) + event = factory.SubFactory(CodexEventFactory) + order = 0 diff --git a/app/codex/filters/__init__.py b/app/codex/filters/__init__.py new file mode 100644 index 000000000..d2410acb5 --- /dev/null +++ b/app/codex/filters/__init__.py @@ -0,0 +1 @@ +from app.codex.filters.event import CodexEventFilter diff --git a/app/codex/filters/event.py b/app/codex/filters/event.py new file mode 100644 index 000000000..7d9decb88 --- /dev/null +++ b/app/codex/filters/event.py @@ -0,0 +1,20 @@ +from django_filters.rest_framework import ( + DateTimeFilter, + FilterSet, + OrderingFilter, +) + +from app.codex.models.event import CodexEvent + + +class CodexEventFilter(FilterSet): + """Filters events by tag and expired. Works with search query""" + + end_range = DateTimeFilter(field_name="start_date", lookup_expr="lte") + start_range = DateTimeFilter(field_name="end_date", lookup_expr="gte") + + ordering = OrderingFilter("start_date", "tag") + + class Meta: + model = CodexEvent + fields = ["tag", "end_range", "start_range", "organizer"] diff --git a/app/codex/migrations/0001_initial.py b/app/codex/migrations/0001_initial.py new file mode 100644 index 000000000..60d4cb467 --- /dev/null +++ b/app/codex/migrations/0001_initial.py @@ -0,0 +1,123 @@ +# Generated by Django 4.2.16 on 2024-09-24 16:44 + +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), + ("group", "0020_alter_membership_membership_type_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="CodexEvent", + 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=255)), + ("description", models.TextField(blank=True, default="")), + ("start_date", models.DateTimeField()), + ( + "start_registration_at", + models.DateTimeField(blank=True, default=None, null=True), + ), + ( + "end_registration_at", + models.DateTimeField(blank=True, default=None, null=True), + ), + ( + "tag", + models.CharField( + choices=[("Workshop", "Workshop"), ("Lecture", "Lecture")], + default="Lecture", + max_length=50, + ), + ), + ("location", models.CharField(max_length=200, null=True)), + ("mazemap_link", models.URLField(max_length=2000, null=True)), + ( + "lecturer", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="codex_events", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "organizer", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="codex_events", + to="group.group", + ), + ), + ], + options={ + "verbose_name_plural": "Events", + "ordering": ("start_date",), + }, + ), + migrations.CreateModel( + name="CodexEventRegistration", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "registration_id", + models.AutoField(primary_key=True, serialize=False), + ), + ("order", models.IntegerField(default=0)), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="codex_event_registrations", + to="codex.codexevent", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="codex_event_registrations", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ("order", "created_at"), + "unique_together": {("user", "event")}, + }, + ), + migrations.AddField( + model_name="codexevent", + name="registrations", + field=models.ManyToManyField( + blank=True, + default=None, + through="codex.CodexEventRegistration", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/app/codex/migrations/__init__.py b/app/codex/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/codex/mixins.py b/app/codex/mixins.py new file mode 100644 index 000000000..373092083 --- /dev/null +++ b/app/codex/mixins.py @@ -0,0 +1,17 @@ +from app.codex.exceptions import ( + APICodexEventEndRegistrationDateAfterStartDate, + APICodexEventEndRegistrationDateBeforeStartRegistrationDate, + CodexEventEndRegistrationDateAfterStartDate, + CodexEventEndRegistrationDateBeforeStartRegistrationDate, +) +from app.util.mixins import APIErrorsMixin + + +class APICodexEventErrorsMixin(APIErrorsMixin): + @property + def expected_exceptions(self): + return { + **super().expected_exceptions, + CodexEventEndRegistrationDateAfterStartDate: APICodexEventEndRegistrationDateAfterStartDate, + CodexEventEndRegistrationDateBeforeStartRegistrationDate: APICodexEventEndRegistrationDateBeforeStartRegistrationDate, + } diff --git a/app/codex/models/__init__.py b/app/codex/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/codex/models/event.py b/app/codex/models/event.py new file mode 100644 index 000000000..ad5c077aa --- /dev/null +++ b/app/codex/models/event.py @@ -0,0 +1,93 @@ +from django.db import models + +from app.codex.enums import CodexEventTags, CodexGroups +from app.codex.util import user_is_leader_of_codex_group +from app.common.permissions import BasePermissionModel +from app.content.models import User +from app.group.models import Group +from app.util.models import BaseModel + + +class CodexEvent(BaseModel, BasePermissionModel): + read_access = CodexGroups.all() + write_access = CodexGroups.all() + + title = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + + start_date = models.DateTimeField() + + start_registration_at = models.DateTimeField(blank=True, null=True, default=None) + end_registration_at = models.DateTimeField(blank=True, null=True, default=None) + + tag = models.CharField( + max_length=50, choices=CodexEventTags.choices, default=CodexEventTags.LECTURE + ) + + location = models.CharField(max_length=200, null=True) + mazemap_link = models.URLField(max_length=2000, null=True) + + organizer = models.ForeignKey( + Group, + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + related_name="codex_events", + ) + lecturer = models.ForeignKey( + User, + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + related_name="codex_events", + ) + + registrations = models.ManyToManyField( + User, + through="CodexEventRegistration", + through_fields=("event", "user"), + blank=True, + default=None, + ) + + class Meta: + verbose_name_plural = "Events" + ordering = ("start_date",) + + def __str__(self): + return f"{self.title} - starting {self.start_date} at {self.location}" + + @property + def list_count(self): + return self.registrations.count() + + @classmethod + def has_write_permission(cls, request): + user = request.user + return user_is_leader_of_codex_group(user) + + @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_retrieve_permission(cls, request): + return cls.has_read_permission(request) + + def has_object_write_permission(self, request): + return self.has_write_permission(request) + + def has_object_update_permission(self, request): + return self.has_write_permission(request) + + def has_object_destroy_permission(self, request): + return self.has_write_permission(request) + + def has_object_retrieve_permission(self, request): + return self.has_read_permission(request) diff --git a/app/codex/models/registration.py b/app/codex/models/registration.py new file mode 100644 index 000000000..c4fa83080 --- /dev/null +++ b/app/codex/models/registration.py @@ -0,0 +1,52 @@ +from django.db import models + +from app.codex.enums import CodexGroups +from app.codex.models.event import CodexEvent +from app.codex.util import user_is_leader_of_codex_group +from app.common.permissions import BasePermissionModel +from app.content.models import User +from app.util.models import BaseModel + + +class CodexEventRegistration(BaseModel, BasePermissionModel): + read_access = CodexGroups.all() + write_access = CodexGroups.all() + + registration_id = models.AutoField(primary_key=True) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="codex_event_registrations" + ) + event = models.ForeignKey( + CodexEvent, on_delete=models.CASCADE, related_name="codex_event_registrations" + ) + order = models.IntegerField(default=0) + + class Meta: + ordering = ("order", "created_at") + unique_together = ("user", "event") + + def __str__(self): + return f"{self.user} - {self.event.title} - {self.created_at}" + + @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_retrieve_permission(cls, request): + return cls.has_read_permission(request) + + def has_object_update_permission(self, request): + user = request.user + return user == self.user or user_is_leader_of_codex_group(user) + + def has_object_destroy_permission(self, request): + user = request.user + return user == self.user or user_is_leader_of_codex_group(user) + + def has_object_retrieve_permission(self, request): + return self.has_retrieve_permission(request) diff --git a/app/codex/serializers/__init__.py b/app/codex/serializers/__init__.py new file mode 100644 index 000000000..e21a9f51e --- /dev/null +++ b/app/codex/serializers/__init__.py @@ -0,0 +1,10 @@ +from app.codex.serializers.event import ( + CodexEventSerializer, + CodexEventListSerializer, + CodexEventCreateSerializer, + CodexEventUpdateSerializer, +) +from app.codex.serializers.registration import ( + RegistrationListSerializer, + RegistrationCreateSerializer, +) diff --git a/app/codex/serializers/event.py b/app/codex/serializers/event.py new file mode 100644 index 000000000..8f878f026 --- /dev/null +++ b/app/codex/serializers/event.py @@ -0,0 +1,109 @@ +from rest_framework import serializers + +from dry_rest_permissions.generics import DRYPermissionsField + +from app.codex.models.event import CodexEvent +from app.codex.util import validate_event_dates +from app.common.serializers import BaseModelSerializer +from app.content.serializers.user import UserListSerializer +from app.group.serializers.group import SimpleGroupSerializer + + +class CodexEventSerializer(BaseModelSerializer): + lecturer = UserListSerializer() + organizer = SimpleGroupSerializer() + permissions = DRYPermissionsField( + actions=[ + "write", + "update", + "destroy", + ], + object_only=True, + ) + viewer_is_registered = serializers.SerializerMethodField() + + class Meta: + model = CodexEvent + fields = ( + "id", + "title", + "description", + "start_date", + "start_registration_at", + "end_registration_at", + "location", + "mazemap_link", + "organizer", + "lecturer", + "tag", + "permissions", + "viewer_is_registered", + ) + + def get_viewer_is_registered(self, obj): + request = self.context.get("request") + return obj.registrations.filter(user_id=request.user.user_id).exists() + + +class CodexEventListSerializer(BaseModelSerializer): + number_of_registrations = serializers.SerializerMethodField() + lecturer = UserListSerializer() + organizer = SimpleGroupSerializer() + + class Meta: + model = CodexEvent + fields = ( + "id", + "title", + "start_date", + "location", + "organizer", + "lecturer", + "number_of_registrations", + "tag", + ) + + def get_number_of_registrations(self, obj): + return obj.registrations.count() + + +class CodexEventCreateSerializer(BaseModelSerializer): + class Meta: + model = CodexEvent + fields = ( + "title", + "description", + "start_date", + "start_registration_at", + "end_registration_at", + "tag", + "location", + "mazemap_link", + "organizer", + "lecturer", + ) + + def create(self, validated_data): + validate_event_dates(validated_data) + return super().create(validated_data) + + +class CodexEventUpdateSerializer(BaseModelSerializer): + class Meta: + model = CodexEvent + fields = ( + "title", + "description", + "start_date", + "start_registration_at", + "end_registration_at", + "tag", + "location", + "mazemap_link", + "organizer", + "lecturer", + ) + + def update(self, instance, validated_data): + validate_event_dates(validated_data) + return super().update(instance, validated_data) diff --git a/app/codex/serializers/registration.py b/app/codex/serializers/registration.py new file mode 100644 index 000000000..66f47d6cb --- /dev/null +++ b/app/codex/serializers/registration.py @@ -0,0 +1,26 @@ +from app.codex.models.registration import CodexEventRegistration +from app.common.serializers import BaseModelSerializer +from app.content.serializers.user import UserListSerializer + + +class RegistrationListSerializer(BaseModelSerializer): + user_info = UserListSerializer(source="user", read_only=True) + + class Meta: + model = CodexEventRegistration + fields = ("registration_id", "user_info", "order") + + +class RegistrationCreateSerializer(BaseModelSerializer): + class Meta: + model = CodexEventRegistration + fields = ("event",) + + def create(self, validated_data): + last_order = ( + CodexEventRegistration.objects.filter(event=validated_data["event"]).count() + - 1 + ) + validated_data["order"] = last_order + 1 + + return CodexEventRegistration.objects.create(**validated_data) diff --git a/app/codex/tests/__init__.py b/app/codex/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/codex/urls.py b/app/codex/urls.py new file mode 100644 index 000000000..869398ed8 --- /dev/null +++ b/app/codex/urls.py @@ -0,0 +1,18 @@ +from django.urls import include, re_path +from rest_framework import routers + +from app.codex.views import CodexEventViewSet, RegistrationViewSet + +router = routers.DefaultRouter() + +router.register("events", CodexEventViewSet) +router.register( + r"events/(?P\d+)/registrations", + RegistrationViewSet, + basename="registration", +) + + +urlpatterns = [ + re_path(r"", include(router.urls)), +] diff --git a/app/codex/util/__init__.py b/app/codex/util/__init__.py new file mode 100644 index 000000000..200b165af --- /dev/null +++ b/app/codex/util/__init__.py @@ -0,0 +1,2 @@ +from app.codex.util.user import user_is_leader_of_codex_group +from app.codex.util.event import validate_event_dates diff --git a/app/codex/util/event.py b/app/codex/util/event.py new file mode 100644 index 000000000..00b3979f1 --- /dev/null +++ b/app/codex/util/event.py @@ -0,0 +1,16 @@ +from app.codex.exceptions import ( + CodexEventEndRegistrationDateAfterStartDate, + CodexEventEndRegistrationDateBeforeStartRegistrationDate, +) + + +def validate_event_dates(data: dict): + if data["end_registration_at"] > data["start_date"]: + raise CodexEventEndRegistrationDateAfterStartDate( + "Påmeldingsslutt kan ikke være etter kursstart" + ) + + if data["end_registration_at"] < data["start_registration_at"]: + raise CodexEventEndRegistrationDateBeforeStartRegistrationDate( + "Påmeldingsslutt kan ikke være før påmeldingsstart" + ) diff --git a/app/codex/util/user.py b/app/codex/util/user.py new file mode 100644 index 000000000..d0ecf6c44 --- /dev/null +++ b/app/codex/util/user.py @@ -0,0 +1,5 @@ +from app.codex.enums import CodexGroups + + +def user_is_leader_of_codex_group(user): + return user.is_leader_of(CodexGroups.DRIFT) or user.is_leader_of(CodexGroups.INDEX) diff --git a/app/codex/views/__init__.py b/app/codex/views/__init__.py new file mode 100644 index 000000000..04bfb705d --- /dev/null +++ b/app/codex/views/__init__.py @@ -0,0 +1,2 @@ +from app.codex.views.event import CodexEventViewSet +from app.codex.views.registration import RegistrationViewSet diff --git a/app/codex/views/event.py b/app/codex/views/event.py new file mode 100644 index 000000000..eb3814360 --- /dev/null +++ b/app/codex/views/event.py @@ -0,0 +1,85 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, status +from rest_framework.response import Response + +from app.codex.filters import CodexEventFilter +from app.codex.mixins import APICodexEventErrorsMixin +from app.codex.models.event import CodexEvent +from app.codex.serializers import ( + CodexEventCreateSerializer, + CodexEventListSerializer, + CodexEventSerializer, + CodexEventUpdateSerializer, +) +from app.common.pagination import BasePagination +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet + + +class CodexEventViewSet(APICodexEventErrorsMixin, BaseViewSet): + serializer_class = CodexEventSerializer + permission_classes = [BasicViewPermission] + queryset = CodexEvent.objects.all() + pagination_class = BasePagination + + filter_backends = [DjangoFilterBackend, filters.SearchFilter] + filterset_class = CodexEventFilter + search_fields = ["title"] + + def get_serializer_class(self): + if hasattr(self, "action") and self.action == "list": + return CodexEventListSerializer + return super().get_serializer_class() + + def retrieve(self, request, *args, **kwargs): + try: + event = self.get_object() + serializer = CodexEventSerializer( + event, context={"request": request}, many=False + ) + return Response(serializer.data) + except CodexEvent.DoesNotExist: + return Response( + {"detail": "Fant ikke arrangementet"}, + status=status.HTTP_404_NOT_FOUND, + ) + + def create(self, request, *args, **kwargs): + data = request.data + serializer = CodexEventCreateSerializer(data=data, context={"request": request}) + + if serializer.is_valid(): + event = super().perform_create(serializer) + serializer = CodexEventSerializer( + event, context={"request": request}, many=False + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def update(self, request, *args, **kwargs): + event = self.get_object() + serializer = CodexEventUpdateSerializer( + event, data=request.data, context={"request": request} + ) + + if serializer.is_valid(): + event = super().perform_update(serializer) + serializer = CodexEventSerializer( + event, context={"request": request}, many=False + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + return Response( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, *args, **kwargs): + super().destroy(request, *args, **kwargs) + return Response( + {"detail": "Arrangementet ble slettet"}, status=status.HTTP_200_OK + ) diff --git a/app/codex/views/registration.py b/app/codex/views/registration.py new file mode 100644 index 000000000..4ce8c77d2 --- /dev/null +++ b/app/codex/views/registration.py @@ -0,0 +1,61 @@ +from rest_framework import status +from rest_framework.response import Response + +from app.codex.models.registration import CodexEventRegistration +from app.codex.serializers import ( + RegistrationCreateSerializer, + RegistrationListSerializer, +) +from app.common.pagination import BasePagination +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet + + +class RegistrationViewSet(BaseViewSet): + serializer_class = RegistrationListSerializer + permission_classes = [BasicViewPermission] + pagination_class = BasePagination + + def get_queryset(self): + event_id = self.kwargs.get("event_id") + return CodexEventRegistration.objects.filter(event__pk=event_id).select_related( + "user" + ) + + def retrieve(self, request, *args, **kwargs): + try: + registration = self.get_object() + serializer = RegistrationListSerializer( + registration, context={"request": request}, many=False + ) + return Response(serializer.data) + except CodexEventRegistration.DoesNotExist: + return Response( + {"detail": "Fant ikke påmeldingen for arrangementet"}, + status=status.HTTP_404_NOT_FOUND, + ) + + def create(self, request, *args, **kwargs): + data = request.data + serializer = RegistrationCreateSerializer( + data=data, context={"request": request} + ) + + if serializer.is_valid(): + registration = super().perform_create(serializer, user=request.user) + serializer = RegistrationListSerializer( + registration, context={"request": request}, many=False + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, *args, **kwargs): + super().destroy(request, *args, **kwargs) + return Response( + {"detail": "Påmeldingen for arrangementet ble slettet"}, + status=status.HTTP_200_OK, + ) diff --git a/app/common/enums.py b/app/common/enums.py index 15f8eb057..10d63dcfd 100644 --- a/app/common/enums.py +++ b/app/common/enums.py @@ -5,6 +5,7 @@ from enumchoicefield import ChoiceEnum +# This can't be removed because it is used in the migrations. It is not used in the code. class UserClass(ChoiceEnum): FIRST = "1. Klasse" SECOND = "2. Klasse" @@ -14,6 +15,23 @@ class UserClass(ChoiceEnum): ALUMNI = "Alumni" +class NativeUserClass(models.TextChoices): + FIRST = "FIRST", "1. Klasse" + SECOND = "SECOND", "2. Klasse" + THIRD = "THIRD", "3. Klasse" + FOURTH = "FOURTH", "4. Klasse" + FIFTH = "FIFTH", "5. Klasse" + ALUMNI = "ALUMNI", "Alumni" + + +def get_user_class_number(user_class: NativeUserClass) -> int: + _class = user_class.label + if user_class == NativeUserClass.ALUMNI: + return 6 + return int(_class.split(".")[0]) + + +# This can't be removed because it is used in the migrations. It is not used in the code class UserStudy(ChoiceEnum): DATAING = "Dataingeniør" DIGFOR = "Digital forretningsutvikling" @@ -23,7 +41,16 @@ class UserStudy(ChoiceEnum): INFO = "Informasjonsbehandling" -class AdminGroup(ChoiceEnum): +class NativeUserStudy(models.TextChoices): + DATAING = "DATAING", "Dataingeniør" + DIGFOR = "DIGFOR", "Digital forretningsutvikling" + DIGINC = "DIGINC", "Digital infrastruktur og cybersikkerhet" + DIGSAM = "DIGSAM", "Digital samhandling" + DRIFT = "DRIFT", "Drift" + INFO = "INFO", "Informasjonsbehandling" + + +class AdminGroup(models.TextChoices): HS = "HS" INDEX = "Index" NOK = "Nok" @@ -40,7 +67,7 @@ def admin(cls): return (cls.HS, cls.INDEX) -class Groups(ChoiceEnum): +class Groups(models.TextChoices): TIHLDE = "TIHLDE" JUBKOM = "JubKom" REDAKSJONEN = "Redaksjonen" @@ -48,16 +75,19 @@ class Groups(ChoiceEnum): PLASK = "Plask" DRIFT = "Drift" - -class AppModel(ChoiceEnum): - EVENT = "Event" - JOBPOST = "Jobpost" - NEWS = "News" - USER = "User" - CHEATSHEET = "Cheatsheet" - WEEKLY_BUSINESS = "Weekly Business" + @classmethod + def all(cls): + return ( + cls.TIHLDE, + cls.JUBKOM, + cls.REDAKSJONEN, + cls.FONDET, + cls.PLASK, + cls.DRIFT, + ) +# This can't be removed because it is used in the migrations. It is not used in the code. class GroupType(ChoiceEnum): TIHLDE = "TIHLDE" BOARD = "Styre" @@ -72,9 +102,24 @@ class GroupType(ChoiceEnum): def public_groups(cls): return [cls.BOARD, cls.SUBGROUP, cls.COMMITTEE, cls.INTERESTGROUP] + +class NativeGroupType(models.TextChoices): + TIHLDE = "TIHLDE", "TIHLDE" + BOARD = "BOARD", "Styre" + SUBGROUP = "SUBGROUP", "Undergruppe" + COMMITTEE = "COMMITTEE", "Komité" + STUDYYEAR = "STUDYYEAR", "Studieår" + STUDY = "STUDY", "Studie" + INTERESTGROUP = "INTERESTGROUP", "Interesse Gruppe" + OTHER = "OTHER", "Annet" + @classmethod - def all(cls): - return list(map(lambda c: (c.name, c.value), cls)) + def public_groups(cls): + return [cls.BOARD, cls.SUBGROUP, cls.COMMITTEE, cls.INTERESTGROUP] + + @classmethod + def non_public_groups(cls): + return [cls.TIHLDE, cls.STUDYYEAR, cls.STUDY, cls.OTHER] class EnvironmentOptions(Enum): @@ -83,6 +128,7 @@ class EnvironmentOptions(Enum): PRODUCTION = "PRODUCTION" +# This can't be removed because it is used in the migrations. It is not used in the code class CheatsheetType(ChoiceEnum): FILE = "Fil" GITHUB = "GitHub" @@ -90,6 +136,14 @@ class CheatsheetType(ChoiceEnum): OTHER = "Annet" +class NativeCheatsheetType(models.TextChoices): + FILE = "FILE", "Fil" + GITHUB = "GITHUB", "GitHub" + LINK = "LINK", "Link" + OTHER = "OTHER", "Annet" + + +# This can't be removed because it is used in the migrations. It is not used in the code class MembershipType(ChoiceEnum): LEADER = "Leader" MEMBER = "Member" @@ -103,6 +157,16 @@ def all(cls): return tuple((i.name, i.value) for i in cls) +class NativeMembershipType(models.TextChoices): + LEADER = "LEADER", "Leader" + MEMBER = "MEMBER", "Member" + + @classmethod + def board_members(cls): + return (cls.LEADER,) + + +# This can't be removed because it is used in the migrations. It is not used in the code class StrikeEnum(ChoiceEnum): PAST_DEADLINE = "PAST_DEADLINE" NO_SHOW = "NO_SHOW" @@ -111,6 +175,24 @@ class StrikeEnum(ChoiceEnum): EVAL_FORM = "EVAL_FORM" +class NativeStrikeEnum(models.TextChoices): + PAST_DEADLINE = "PAST_DEADLINE" + NO_SHOW = "NO_SHOW" + LATE = "LATE" + BAD_BEHAVIOR = "BAD_BEHAVIOR" + EVAL_FORM = "EVAL_FORM" + + @classmethod + def all(cls): + return [ + cls.PAST_DEADLINE, + cls.NO_SHOW, + cls.LATE, + cls.BAD_BEHAVIOR, + cls.EVAL_FORM, + ] + + class CodexGroups(models.TextChoices): DRIFT = "Drift" INDEX = "Index" diff --git a/app/content/admin/admin.py b/app/content/admin/admin.py index 0ac3488fc..1a219f7e3 100644 --- a/app/content/admin/admin.py +++ b/app/content/admin/admin.py @@ -5,7 +5,7 @@ from django.utils.html import escape from django.utils.safestring import mark_safe -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.content import models from app.group.models.membership import Membership diff --git a/app/content/enums.py b/app/content/enums.py index 5d2332a87..c9d7bc8a8 100644 --- a/app/content/enums.py +++ b/app/content/enums.py @@ -1,7 +1,5 @@ from django.db import models -from enumchoicefield import ChoiceEnum - class UserClass(models.IntegerChoices): FIRST = 1 @@ -11,7 +9,7 @@ class UserClass(models.IntegerChoices): FIFTH = 5 -class CategoryEnum(ChoiceEnum): +class CategoryEnum(models.TextChoices): ACTIVITY = "Aktivitet" SOSIALT = "Sosialt" BEDPRES = "Bedpres" diff --git a/app/content/factories/cheatsheet_factory.py b/app/content/factories/cheatsheet_factory.py index 73923fa5a..49dc9366c 100644 --- a/app/content/factories/cheatsheet_factory.py +++ b/app/content/factories/cheatsheet_factory.py @@ -3,7 +3,9 @@ import factory from factory.django import DjangoModelFactory -from app.common.enums import CheatsheetType, UserClass, UserStudy +from app.common.enums import NativeCheatsheetType as CheatsheetType +from app.common.enums import NativeUserClass as UserClass +from app.common.enums import NativeUserStudy as UserStudy from app.content.models import Cheatsheet diff --git a/app/content/factories/registration_factory.py b/app/content/factories/registration_factory.py index f6cc67c5a..a189b277d 100644 --- a/app/content/factories/registration_factory.py +++ b/app/content/factories/registration_factory.py @@ -14,7 +14,7 @@ class Meta: user = factory.SubFactory(UserFactory) is_on_wait = factory.LazyAttribute( - lambda registration: False - if registration.event.limit == 0 - else registration.event.is_full + lambda registration: ( + False if registration.event.limit == 0 else registration.event.is_full + ) ) diff --git a/app/content/factories/strike_factory.py b/app/content/factories/strike_factory.py index 102eed01e..275d27ce2 100644 --- a/app/content/factories/strike_factory.py +++ b/app/content/factories/strike_factory.py @@ -5,7 +5,7 @@ import factory from factory.django import DjangoModelFactory -from app.common.enums import StrikeEnum +from app.common.enums import NativeStrikeEnum as StrikeEnum from app.content.factories.event_factory import EventFactory from app.content.factories.user_factory import UserFactory from app.content.models.strike import Strike diff --git a/app/content/filters/strike.py b/app/content/filters/strike.py index 7cec2b863..f8b820003 100644 --- a/app/content/filters/strike.py +++ b/app/content/filters/strike.py @@ -2,7 +2,7 @@ from django_filters import filters from django_filters.rest_framework.filterset import FilterSet -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.content.models import Strike from app.group.models.membership import Membership diff --git a/app/content/filters/user.py b/app/content/filters/user.py index 69ffd2e63..a189f2390 100644 --- a/app/content/filters/user.py +++ b/app/content/filters/user.py @@ -1,6 +1,7 @@ from django_filters.rest_framework import BooleanFilter, CharFilter, FilterSet -from app.common.enums import Groups, GroupType +from app.common.enums import Groups +from app.common.enums import NativeGroupType as GroupType from app.content.models import User from app.content.models.strike import Strike diff --git a/app/content/migrations/0068_alter_cheatsheet_grade_alter_cheatsheet_study_and_more.py b/app/content/migrations/0068_alter_cheatsheet_grade_alter_cheatsheet_study_and_more.py new file mode 100644 index 000000000..10657218c --- /dev/null +++ b/app/content/migrations/0068_alter_cheatsheet_grade_alter_cheatsheet_study_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.5 on 2024-09-20 08:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0067_alter_minute_group"), + ] + + operations = [ + migrations.AlterField( + model_name="cheatsheet", + name="grade", + field=models.CharField( + choices=[ + ("FIRST", "1. Klasse"), + ("SECOND", "2. Klasse"), + ("THIRD", "3. Klasse"), + ("FOURTH", "4. Klasse"), + ("FIFTH", "5. Klasse"), + ("ALUMNI", "Alumni"), + ], + default="FIRST", + max_length=50, + ), + ), + migrations.AlterField( + model_name="cheatsheet", + name="study", + field=models.CharField( + choices=[ + ("DATAING", "Dataingeniør"), + ("DIGFOR", "Digital forretningsutvikling"), + ("DIGINC", "Digital infrastruktur og cybersikkerhet"), + ("DIGSAM", "Digital samhandling"), + ("DRIFT", "Drift"), + ("INFO", "Informasjonsbehandling"), + ], + default="DATAING", + max_length=50, + ), + ), + migrations.AlterField( + model_name="cheatsheet", + name="type", + field=models.CharField( + choices=[ + ("FILE", "Fil"), + ("GITHUB", "GitHub"), + ("LINK", "Link"), + ("OTHER", "Annet"), + ], + default="LINK", + max_length=50, + ), + ), + ] diff --git a/app/content/models/cheatsheet.py b/app/content/models/cheatsheet.py index d27c7ae4d..2b8d20a11 100644 --- a/app/content/models/cheatsheet.py +++ b/app/content/models/cheatsheet.py @@ -2,15 +2,10 @@ from django.db import models -from enumchoicefield import EnumChoiceField - -from app.common.enums import ( - AdminGroup, - CheatsheetType, - Groups, - UserClass, - UserStudy, -) +from app.common.enums import AdminGroup, Groups +from app.common.enums import NativeCheatsheetType as CheatsheetType +from app.common.enums import NativeUserClass as UserClass +from app.common.enums import NativeUserStudy as UserStudy from app.common.permissions import BasePermissionModel from app.util.models import BaseModel @@ -21,10 +16,16 @@ class Cheatsheet(BaseModel, BasePermissionModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) title = models.CharField(max_length=200) creator = models.CharField(max_length=200) - grade = EnumChoiceField(UserClass, default=UserClass.FIRST) - study = EnumChoiceField(UserStudy, default=UserStudy.DATAING) + grade = models.CharField( + max_length=50, choices=UserClass.choices, default=UserClass.FIRST + ) + study = models.CharField( + max_length=50, choices=UserStudy.choices, default=UserStudy.DATAING + ) course = models.CharField(max_length=200) - type = EnumChoiceField(CheatsheetType, default=CheatsheetType.LINK) + type = models.CharField( + max_length=50, choices=CheatsheetType.choices, default=CheatsheetType.LINK + ) official = models.BooleanField(default=False) url = models.URLField(max_length=600) diff --git a/app/content/models/event.py b/app/content/models/event.py index 0accdbf11..c23401fc5 100644 --- a/app/content/models/event.py +++ b/app/content/models/event.py @@ -13,7 +13,7 @@ from app.content.models import Category from app.content.models.user import User from app.emoji.models.reaction import Reaction -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType from app.group.models.group import Group from app.util.models import BaseModel, OptionalImage from app.util.utils import now, yesterday @@ -174,11 +174,11 @@ def has_priorities(self): @property def evaluation(self): - return self.forms.filter(type=EventFormType.EVALUATION).first() + return self.forms.filter(type=NativeEventFormType.EVALUATION).first() @property def survey(self): - return self.forms.filter(type=EventFormType.SURVEY).first() + return self.forms.filter(type=NativeEventFormType.SURVEY).first() def check_request_user_has_access_through_organizer(self, user, organizer): return user.memberships_with_events_access.filter(group=organizer).exists() diff --git a/app/content/models/registration.py b/app/content/models/registration.py index 92224b564..19c6c3790 100644 --- a/app/content/models/registration.py +++ b/app/content/models/registration.py @@ -6,7 +6,7 @@ from sentry_sdk import capture_exception -from app.common.enums import StrikeEnum +from app.common.enums import NativeStrikeEnum as StrikeEnum from app.common.permissions import BasePermissionModel from app.communication.enums import UserNotificationSettingType from app.communication.notifier import Notify @@ -20,7 +20,7 @@ from app.content.models.strike import create_strike from app.content.models.user import User from app.content.util.registration_utils import get_payment_expiredate -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.payment.util.order_utils import check_if_order_is_paid, has_paid_order from app.util import now from app.util.models import BaseModel diff --git a/app/content/models/user.py b/app/content/models/user.py index 1ab2db3a3..1c4ce58b7 100644 --- a/app/content/models/user.py +++ b/app/content/models/user.py @@ -13,7 +13,9 @@ from django.dispatch import receiver from rest_framework.authtoken.models import Token -from app.common.enums import AdminGroup, Groups, GroupType, MembershipType +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.permissions import check_has_access from app.util.models import BaseModel, OptionalImage from app.util.utils import disable_for_loaddata, now @@ -171,7 +173,8 @@ def has_unanswered_evaluations_for(self, event): return self.get_unanswered_evaluations().filter(event=event).exists() def get_unanswered_evaluations(self): - from app.forms.models.forms import EventForm, EventFormType + from app.forms.enums import NativeEventFormType as EventFormType + from app.forms.models.forms import EventForm date_30_days_ago = now() - timedelta(days=30) registrations = self.registrations.filter(has_attended=True) diff --git a/app/content/serializers/event.py b/app/content/serializers/event.py index f2deee1b1..f1042051f 100644 --- a/app/content/serializers/event.py +++ b/app/content/serializers/event.py @@ -3,7 +3,7 @@ from dry_rest_permissions.generics import DRYPermissionsField from sentry_sdk import capture_exception -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.common.serializers import BaseModelSerializer from app.content.models import Event, PriorityPool from app.content.serializers.category import SimpleCategorySerializer @@ -21,16 +21,18 @@ class EventSerializer(serializers.ModelSerializer): expired = serializers.BooleanField(read_only=True) - priority_pools = PriorityPoolSerializer(many=True, required=False) + priority_pools = PriorityPoolSerializer(many=True, read_only=True, required=False) evaluation = serializers.PrimaryKeyRelatedField(many=False, read_only=True) survey = serializers.PrimaryKeyRelatedField(many=False, read_only=True) organizer = SimpleGroupSerializer(read_only=True) - permissions = DRYPermissionsField(actions=["write", "read"], object_only=True) + permissions = DRYPermissionsField( + actions=["write", "read"], object_only=True, read_only=True + ) paid_information = serializers.SerializerMethodField( required=False, allow_null=True ) contact_person = DefaultUserSerializer(read_only=True, required=False) - reactions = ReactionSerializer(required=False, many=True) + reactions = ReactionSerializer(read_only=True, many=True) category = SimpleCategorySerializer(read_only=True) class Meta: diff --git a/app/content/serializers/minute.py b/app/content/serializers/minute.py index 78eeb3d8a..26c1ad472 100644 --- a/app/content/serializers/minute.py +++ b/app/content/serializers/minute.py @@ -4,7 +4,7 @@ from app.group.serializers import SimpleGroupSerializer -class SimpleUserSerializer(serializers.ModelSerializer): +class SimpleMinuteUserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ("user_id", "first_name", "last_name", "image") @@ -22,7 +22,7 @@ def create(self, validated_data): class MinuteSerializer(serializers.ModelSerializer): - author = SimpleUserSerializer(read_only=True) + author = SimpleMinuteUserSerializer(read_only=True) group = SimpleGroupSerializer(read_only=True) class Meta: @@ -49,7 +49,7 @@ def update(self, instance, validated_data): class MinuteListSerializer(serializers.ModelSerializer): - author = SimpleUserSerializer(read_only=True) + author = SimpleMinuteUserSerializer(read_only=True) group = SimpleGroupSerializer(read_only=True) class Meta: diff --git a/app/content/serializers/registration.py b/app/content/serializers/registration.py index 724c2c697..339cc1dcc 100644 --- a/app/content/serializers/registration.py +++ b/app/content/serializers/registration.py @@ -7,9 +7,10 @@ UserListSerializer, ) from app.content.util.registration_utils import get_payment_expiredate -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.serializers.submission import SubmissionInRegistrationSerializer from app.payment.enums import OrderStatus +from app.payment.serializers.order import OrderEventRegistrationSerializer from app.payment.util.order_utils import has_paid_order from app.payment.util.payment_utils import get_payment_order_status @@ -20,6 +21,7 @@ class RegistrationSerializer(BaseModelSerializer): has_unanswered_evaluation = serializers.SerializerMethodField() has_paid_order = serializers.SerializerMethodField(required=False) wait_queue_number = serializers.SerializerMethodField(required=False) + payment_orders = serializers.SerializerMethodField(required=False) class Meta: model = Registration @@ -36,6 +38,7 @@ class Meta: "has_paid_order", "wait_queue_number", "created_by_admin", + "payment_orders", ) def get_survey_submission(self, obj): @@ -55,6 +58,10 @@ def get_has_paid_order(self, obj): return has_paid_order(orders) + def get_payment_orders(self, obj): + orders = obj.event.orders.filter(user=obj.user) + return OrderEventRegistrationSerializer(orders, many=True, read_only=True).data + def create(self, validated_data): event = validated_data["event"] diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index fc2d91625..2378c5624 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -4,7 +4,8 @@ from dry_rest_permissions.generics import DRYGlobalPermissionsField -from app.common.enums import Groups, GroupType +from app.common.enums import Groups +from app.common.enums import NativeGroupType as GroupType from app.common.serializers import BaseModelSerializer from app.communication.enums import UserNotificationSettingType from app.communication.notifier import Notify diff --git a/app/content/tests/test_registration_model.py b/app/content/tests/test_registration_model.py index e59301a82..e6a68e012 100644 --- a/app/content/tests/test_registration_model.py +++ b/app/content/tests/test_registration_model.py @@ -4,7 +4,7 @@ import pytest -from app.common.enums import MembershipType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories import ( EventFactory, PriorityPoolFactory, @@ -12,7 +12,7 @@ StrikeFactory, UserFactory, ) -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.models.forms import Submission from app.forms.tests.form_factories import EventFormFactory, SubmissionFactory from app.group.factories import GroupFactory, MembershipFactory diff --git a/app/content/tests/test_user_model.py b/app/content/tests/test_user_model.py index a4d6f51ee..b3e6ddd1b 100644 --- a/app/content/tests/test_user_model.py +++ b/app/content/tests/test_user_model.py @@ -1,7 +1,7 @@ import pytest from app.content.factories import RegistrationFactory, UserFactory -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import EventFormFactory, SubmissionFactory pytestmark = pytest.mark.django_db diff --git a/app/content/views/minute.py b/app/content/views/minute.py index 8ebef6601..ceb779b81 100644 --- a/app/content/views/minute.py +++ b/app/content/views/minute.py @@ -3,7 +3,7 @@ from rest_framework.exceptions import NotFound from rest_framework.response import Response -from app.common.enums import CodexGroups +from app.codex.enums import CodexGroups from app.common.pagination import BasePagination from app.common.permissions import ( BasicViewPermission, @@ -79,4 +79,4 @@ def update(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): super().destroy(request, *args, **kwargs) - return Response({"detail": "The minute was deleted"}, status=status.HTTP_200_OK) + return Response({"detail": "Dokumentet ble slettet"}, status=status.HTTP_200_OK) diff --git a/app/content/views/strike.py b/app/content/views/strike.py index 9303ef7cd..f8c7d3c4e 100644 --- a/app/content/views/strike.py +++ b/app/content/views/strike.py @@ -3,7 +3,7 @@ from rest_framework import filters, status from rest_framework.response import Response -from app.common.enums import StrikeEnum +from app.common.enums import NativeStrikeEnum as StrikeEnum from app.common.pagination import BasePagination from app.common.permissions import BasicViewPermission from app.common.viewsets import BaseViewSet @@ -42,7 +42,7 @@ def update(self, request, *args, **kwargs): def create(self, request): if "enum" in request.data: enum = request.data["enum"] - if enum not in StrikeEnum._member_names_: + if enum not in StrikeEnum.all(): return Response( {"detail": "Fant ikke Enum"}, status=status.HTTP_404_NOT_FOUND ) diff --git a/app/content/views/user.py b/app/content/views/user.py index b8ef4e18d..bb3e67c42 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -1,5 +1,6 @@ from django.db import IntegrityError from django.shortcuts import get_object_or_404 +from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, status from rest_framework.decorators import action @@ -7,7 +8,8 @@ from app.badge.models import Badge, UserBadge from app.badge.serializers import BadgeSerializer, UserBadgeSerializer -from app.common.enums import Groups, GroupType +from app.common.enums import Groups +from app.common.enums import NativeGroupType as GroupType from app.common.mixins import ActionMixin from app.common.pagination import BasePagination from app.common.permissions import ( @@ -21,6 +23,7 @@ from app.communication.notifier import Notify from app.content.filters import UserFilter from app.content.models import User +from app.content.models.event import Event from app.content.serializers import ( DefaultUserSerializer, EventListSerializer, @@ -304,17 +307,19 @@ def get_user_detail_strikes(self, request, *args, **kwargs): @action(detail=False, methods=["get"], url_path="me/events") def get_user_events(self, request, *args, **kwargs): - registrations = request.user.registrations.all() - - # Apply the filter filter_field = self.request.query_params.get("expired") - event_has_ended = CaseInsensitiveBooleanQueryParam(filter_field) + event_has_ended = CaseInsensitiveBooleanQueryParam(filter_field).value - events = [ - registration.event - for registration in registrations - if registration.event.expired == event_has_ended.value - ] + now = timezone.now() + + if event_has_ended: + events = Event.objects.filter( + registered_users_list=request.user, end_date__lte=now + ).order_by("-start_date") + else: + events = Event.objects.filter( + registered_users_list=request.user, end_date__gt=now + ).order_by("start_date") return self.paginate_response( data=events, serializer=EventListSerializer, context={"request": request} diff --git a/app/emoji/serializers/reaction.py b/app/emoji/serializers/reaction.py index 8fdd92dfb..db3576ace 100644 --- a/app/emoji/serializers/reaction.py +++ b/app/emoji/serializers/reaction.py @@ -15,14 +15,14 @@ from app.emoji.models.reaction import Reaction -class SimpleUserSerializer(serializers.ModelSerializer): +class SimpleReactionUserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ("user_id", "first_name", "last_name", "image") class ReactionSerializer(BaseModelSerializer): - user = SimpleUserSerializer(read_only=True) + user = SimpleReactionUserSerializer(read_only=True) class Meta: model = Reaction diff --git a/app/forms/enums.py b/app/forms/enums.py index 0f9829858..dd30ba48a 100644 --- a/app/forms/enums.py +++ b/app/forms/enums.py @@ -1,12 +1,27 @@ +from django.db import models + from enumchoicefield import ChoiceEnum +# This must be here because of the migrations files class EventFormType(ChoiceEnum): SURVEY = "Survey" EVALUATION = "Evaluation" +class NativeEventFormType(models.TextChoices): + SURVEY = "SURVEY", "Survey" + EVALUATION = "EVALUATION", "Evaluation" + + +# This must be here because of the migrations files class FormFieldType(ChoiceEnum): TEXT_ANSWER = "Text answer" MULTIPLE_SELECT = "Multiple select" SINGLE_SELECT = "Single select" + + +class NativeFormFieldType(models.TextChoices): + TEXT_ANSWER = "TEXT_ANSWER", "Text answer" + MULTIPLE_SELECT = "MULTIPLE_SELECT", "Multiple select" + SINGLE_SELECT = "SINGLE_SELECT", "Single select" diff --git a/app/forms/migrations/0012_alter_eventform_type.py b/app/forms/migrations/0012_alter_eventform_type.py new file mode 100644 index 000000000..3bcf254f6 --- /dev/null +++ b/app/forms/migrations/0012_alter_eventform_type.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.5 on 2024-09-20 07:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("forms", "0011_alter_form_polymorphic_ctype"), + ] + + operations = [ + migrations.AlterField( + model_name="eventform", + name="type", + field=models.CharField( + choices=[("SURVEY", "Survey"), ("EVALUATION", "Evaluation")], + default="SURVEY", + max_length=40, + ), + ), + ] diff --git a/app/forms/migrations/0013_alter_field_type.py b/app/forms/migrations/0013_alter_field_type.py new file mode 100644 index 000000000..22098fc6e --- /dev/null +++ b/app/forms/migrations/0013_alter_field_type.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.5 on 2024-09-20 07:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("forms", "0012_alter_eventform_type"), + ] + + operations = [ + migrations.AlterField( + model_name="field", + name="type", + field=models.CharField( + choices=[ + ("TEXT_ANSWER", "Text answer"), + ("MULTIPLE_SELECT", "Multiple select"), + ("SINGLE_SELECT", "Single select"), + ], + default="TEXT_ANSWER", + max_length=40, + ), + ), + ] diff --git a/app/forms/models/forms.py b/app/forms/models/forms.py index f933f661f..08e2ff97d 100644 --- a/app/forms/models/forms.py +++ b/app/forms/models/forms.py @@ -2,7 +2,6 @@ from django.db import models, transaction -from enumchoicefield import EnumChoiceField from ordered_model.models import OrderedModel from polymorphic.models import PolymorphicModel @@ -10,7 +9,8 @@ from app.common.permissions import BasePermissionModel, check_has_access from app.content.models.event import Event from app.content.models.user import User -from app.forms.enums import EventFormType, FormFieldType +from app.forms.enums import NativeEventFormType as EventFormType +from app.forms.enums import NativeFormFieldType as FormFieldType from app.forms.exceptions import ( DuplicateSubmission, FormNotOpenForSubmission, @@ -113,7 +113,9 @@ def has_object_read_permission(self, request): class EventForm(Form): event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="forms") - type = EnumChoiceField(EventFormType, default=EventFormType.SURVEY) + type = models.CharField( + max_length=40, choices=EventFormType.choices, default=EventFormType.SURVEY + ) class Meta: unique_together = ("event", "type") @@ -213,7 +215,9 @@ 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") - type = EnumChoiceField(FormFieldType, default=FormFieldType.TEXT_ANSWER) + type = models.CharField( + max_length=40, choices=FormFieldType.choices, default=FormFieldType.TEXT_ANSWER + ) required = models.BooleanField(default=False) order_with_respect_to = "form" diff --git a/app/forms/serializers/statistics.py b/app/forms/serializers/statistics.py index 902eb4840..a218373c9 100644 --- a/app/forms/serializers/statistics.py +++ b/app/forms/serializers/statistics.py @@ -4,7 +4,7 @@ from rest_polymorphic.serializers import PolymorphicSerializer from app.common.serializers import BaseModelSerializer -from app.forms.enums import FormFieldType +from app.forms.enums import NativeFormFieldType as FormFieldType from app.forms.models import EventForm, Field, Form, Option from app.forms.models.forms import Answer diff --git a/app/forms/views/submission.py b/app/forms/views/submission.py index 62dba0106..b148a2126 100644 --- a/app/forms/views/submission.py +++ b/app/forms/views/submission.py @@ -10,7 +10,7 @@ from app.common.permissions import BasicViewPermission from app.common.viewsets import BaseViewSet from app.forms.csv_writer import SubmissionsCsvWriter -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.mixins import APIFormErrorsMixin from app.forms.models.forms import EventForm, Form, Submission from app.forms.serializers.submission import SubmissionSerializer diff --git a/app/group/exceptions.py b/app/group/exceptions.py index db1ee0c37..c0ca1867d 100644 --- a/app/group/exceptions.py +++ b/app/group/exceptions.py @@ -9,3 +9,12 @@ class APIUserIsNotInGroupException(APIException): class UserIsNotInGroup(ValidationError): pass + + +class APIGroupTypeNotInPublicGroupsException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Ikke gylde gruppetype" + + +class GroupTypeNotInPublicGroups(ValueError): + pass diff --git a/app/group/factories/group_factory.py b/app/group/factories/group_factory.py index 4c2e2f562..0c98efe98 100644 --- a/app/group/factories/group_factory.py +++ b/app/group/factories/group_factory.py @@ -1,7 +1,7 @@ import factory from factory.django import DjangoModelFactory -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.content.factories.user_factory import UserFactory from app.group.models import Group diff --git a/app/group/factories/membership_factory.py b/app/group/factories/membership_factory.py index ab4b2496a..8e63cf237 100644 --- a/app/group/factories/membership_factory.py +++ b/app/group/factories/membership_factory.py @@ -5,7 +5,7 @@ import factory from factory.django import DjangoModelFactory -from app.common.enums import MembershipType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories import UserFactory from app.group.factories import GroupFactory from app.group.models import Membership, MembershipHistory diff --git a/app/group/filters/group.py b/app/group/filters/group.py index a5eb77299..fd3a79047 100644 --- a/app/group/filters/group.py +++ b/app/group/filters/group.py @@ -1,25 +1,25 @@ from django_filters import MultipleChoiceFilter from django_filters.rest_framework import BooleanFilter, FilterSet -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.common.permissions import is_admin_user from app.group.models import Group class GroupFilter(FilterSet): - type = MultipleChoiceFilter(method="filter_type", choices=GroupType.all()) + type = MultipleChoiceFilter(method="filter_type", choices=GroupType.choices) overview = BooleanFilter(method="filter_overview", label="Oversikt") class Meta: model: Group fields = ["type", "overview"] - def filter_type(self, queryset, name, value): + def filter_type(self, queryset, _, value): """Django Rest does not know hot to convert incoming string values into EnumChoiceField values and we must do this manually.""" mapped = list(GroupType[v] for v in value) return queryset.filter(type__in=mapped) - def filter_overview(self, queryset, name, value): + def filter_overview(self, queryset, _): if is_admin_user(self.request): return queryset return queryset.filter(type__in=GroupType.public_groups()) diff --git a/app/group/filters/membership.py b/app/group/filters/membership.py index 02e8986cb..b975bb737 100644 --- a/app/group/filters/membership.py +++ b/app/group/filters/membership.py @@ -1,7 +1,7 @@ from django_filters.filters import BooleanFilter from django_filters.rest_framework import FilterSet -from app.common.enums import MembershipType +from app.common.enums import NativeMembershipType as MembershipType from app.group.models import Membership diff --git a/app/group/migrations/0019_alter_group_type.py b/app/group/migrations/0019_alter_group_type.py new file mode 100644 index 000000000..d40425abf --- /dev/null +++ b/app/group/migrations/0019_alter_group_type.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.5 on 2024-09-20 07:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("group", "0018_fine_defense"), + ] + + operations = [ + migrations.AlterField( + model_name="group", + name="type", + field=models.CharField( + choices=[ + ("TIHLDE", "TIHLDE"), + ("BOARD", "Styre"), + ("SUBGROUP", "Undergruppe"), + ("COMMITTEE", "Komité"), + ("STUDYYEAR", "Studieår"), + ("STUDY", "Studie"), + ("INTERESTGROUP", "Interesse Gruppe"), + ("OTHER", "Annet"), + ], + default="OTHER", + max_length=50, + ), + ), + ] diff --git a/app/group/migrations/0020_alter_membership_membership_type_and_more.py b/app/group/migrations/0020_alter_membership_membership_type_and_more.py new file mode 100644 index 000000000..a38f08038 --- /dev/null +++ b/app/group/migrations/0020_alter_membership_membership_type_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.5 on 2024-09-20 08:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("group", "0019_alter_group_type"), + ] + + operations = [ + migrations.AlterField( + model_name="membership", + name="membership_type", + field=models.CharField( + choices=[("LEADER", "Leader"), ("MEMBER", "Member")], + default="MEMBER", + max_length=50, + ), + ), + migrations.AlterField( + model_name="membershiphistory", + name="membership_type", + field=models.CharField( + choices=[("LEADER", "Leader"), ("MEMBER", "Member")], + default="MEMBER", + max_length=50, + ), + ), + ] diff --git a/app/group/mixins.py b/app/group/mixins.py index d376e9348..4640412e5 100644 --- a/app/group/mixins.py +++ b/app/group/mixins.py @@ -1,4 +1,9 @@ -from app.group.exceptions import APIUserIsNotInGroupException, UserIsNotInGroup +from app.group.exceptions import ( + APIGroupTypeNotInPublicGroupsException, + APIUserIsNotInGroupException, + GroupTypeNotInPublicGroups, + UserIsNotInGroup, +) from app.util.mixins import APIErrorsMixin @@ -9,3 +14,12 @@ def expected_exceptions(self): **super().expected_exceptions, UserIsNotInGroup: APIUserIsNotInGroupException, } + + +class APIGroupErrorsMixin(APIErrorsMixin): + @property + def expected_exceptions(self): + return { + **super().expected_exceptions, + GroupTypeNotInPublicGroups: APIGroupTypeNotInPublicGroupsException, + } diff --git a/app/group/models/group.py b/app/group/models/group.py index f3b7d48a3..6d4bbded3 100644 --- a/app/group/models/group.py +++ b/app/group/models/group.py @@ -1,17 +1,20 @@ from django.db import models from django.utils.text import slugify -from enumchoicefield import EnumChoiceField - -from app.common.enums import AdminGroup, GroupType -from app.common.permissions import BasePermissionModel, set_user_id +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.permissions import ( + BasePermissionModel, + check_has_access, + set_user_id, +) from app.communication.enums import UserNotificationSettingType from app.content.models.user import User from app.util.models import BaseModel, OptionalImage class Group(OptionalImage, BaseModel, BasePermissionModel): - + read_access = [] write_access = AdminGroup.admin() name = models.CharField(max_length=50) @@ -19,7 +22,9 @@ class Group(OptionalImage, BaseModel, BasePermissionModel): description = models.TextField(max_length=1000, null=True, blank=True) contact_email = models.EmailField(max_length=200, null=True, blank=True) fine_info = models.TextField(default="", blank=True) - type = EnumChoiceField(GroupType, default=GroupType.OTHER) + type = models.CharField( + max_length=50, choices=GroupType.choices, default=GroupType.OTHER + ) fines_activated = models.BooleanField(default=False) members = models.ManyToManyField( User, @@ -133,6 +138,10 @@ def has_write_permission(cls, request): except (Membership.DoesNotExist, KeyError, AssertionError): return super().has_write_permission(request) + @classmethod + def has_create_permission(cls, request): + return check_has_access(cls.write_access, request) + def has_object_write_permission(self, request): from app.group.models import Membership diff --git a/app/group/models/membership.py b/app/group/models/membership.py index 54da4ed78..657bf8376 100644 --- a/app/group/models/membership.py +++ b/app/group/models/membership.py @@ -1,9 +1,9 @@ from django.db import models from django.db.transaction import atomic -from enumchoicefield import EnumChoiceField - -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.common.permissions import BasePermissionModel from app.content.models.user import User from app.group.models.group import Group @@ -22,7 +22,9 @@ class MembershipHistory(BaseModel): group = models.ForeignKey( Group, on_delete=models.CASCADE, related_name="membership_histories" ) - membership_type = EnumChoiceField(MembershipType, default=MembershipType.MEMBER) + membership_type = models.CharField( + max_length=50, choices=MembershipType.choices, default=MembershipType.MEMBER + ) start_date = models.DateTimeField() end_date = models.DateTimeField() @@ -69,7 +71,9 @@ class Membership(BaseModel, BasePermissionModel): group = models.ForeignKey( Group, on_delete=models.CASCADE, related_name="memberships" ) - membership_type = EnumChoiceField(MembershipType, default=MembershipType.MEMBER) + membership_type = models.CharField( + max_length=50, choices=MembershipType.choices, default=MembershipType.MEMBER + ) expiration_date = models.DateField(null=True, blank=True) class Meta: @@ -109,7 +113,7 @@ def is_leader(self): return self.membership_type == MembershipType.LEADER def is_board_member(self): - return self.membership_type in MembershipType.board_members + return self.membership_type in MembershipType.board_members() def clean(self): if ( diff --git a/app/group/serializers/group.py b/app/group/serializers/group.py index fd45dd6ce..b3ac02146 100644 --- a/app/group/serializers/group.py +++ b/app/group/serializers/group.py @@ -2,10 +2,12 @@ from dry_rest_permissions.generics import DRYPermissionsField -from app.common.enums import GroupType, MembershipType +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.common.serializers import BaseModelSerializer from app.content.models.user import User from app.content.serializers.user import DefaultUserSerializer +from app.group.exceptions import GroupTypeNotInPublicGroups from app.group.models import Group, Membership @@ -33,7 +35,6 @@ def get_viewer_is_member(self, obj): class GroupListSerializer(SimpleGroupSerializer): - leader = serializers.SerializerMethodField() class Meta: @@ -56,7 +57,6 @@ def get_leader(self, obj): class GroupSerializer(GroupListSerializer): - permissions = DRYPermissionsField( actions=["write", "read", "group_form"], object_only=True ) @@ -86,10 +86,6 @@ def update(self, instance, validated_data): instance.fines_admin = self.get_fine_admin_user() return super().update(instance, validated_data) - def create(self, validated_data): - fines_admin = self.get_fine_admin_user() - return Group.objects.create(fines_admin=fines_admin, **validated_data) - class GroupStatisticsSerializer(BaseModelSerializer): studyyears = serializers.SerializerMethodField() @@ -126,3 +122,23 @@ def get_studies(self, obj, *args, **kwargs): Group.objects.filter(type=GroupType.STUDY), ), ) + + +class GroupCreateSerializer( + BaseModelSerializer, +): + class Meta: + model = Group + fields = ( + "name", + "slug", + "type", + ) + + def create(self, validated_data): + group_type = validated_data["type"] + + if group_type not in GroupType.public_groups(): + raise GroupTypeNotInPublicGroups() + + return super().create(validated_data) diff --git a/app/group/tests/test_membership_model.py b/app/group/tests/test_membership_model.py index 496937f51..269765981 100644 --- a/app/group/tests/test_membership_model.py +++ b/app/group/tests/test_membership_model.py @@ -1,6 +1,8 @@ import pytest -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.group.factories import MembershipFactory from app.group.factories.group_factory import GroupFactory from app.group.models.membership import Membership, MembershipHistory diff --git a/app/group/views/group.py b/app/group/views/group.py index 5341495ce..80e24f399 100644 --- a/app/group/views/group.py +++ b/app/group/views/group.py @@ -2,17 +2,23 @@ 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 from app.common.viewsets import BaseViewSet from app.group.filters.group import GroupFilter +from app.group.mixins import APIGroupErrorsMixin from app.group.models import Group from app.group.serializers import GroupSerializer, GroupStatisticsSerializer -from app.group.serializers.group import GroupListSerializer +from app.group.serializers.group import ( + GroupCreateSerializer, + GroupListSerializer, + SimpleGroupSerializer, +) -class GroupViewSet(BaseViewSet, ActionMixin): +class GroupViewSet(APIGroupErrorsMixin, BaseViewSet, ActionMixin): serializer_class = GroupSerializer permission_classes = [BasicViewPermission] filter_backends = [DjangoFilterBackend] @@ -35,7 +41,7 @@ def retrieve(self, request, slug): return Response(data=serializer.data, status=status.HTTP_200_OK) except Group.DoesNotExist: return Response( - {"detail": ("Gruppen eksisterer ikke")}, + {"detail": "Gruppen eksisterer ikke"}, status=status.HTTP_404_NOT_FOUND, ) @@ -55,30 +61,23 @@ def update(self, request, *args, **kwargs): ) except Group.DoesNotExist: return Response( - {"detail": ("Gruppen eksisterer ikke")}, + {"detail": "Gruppen eksisterer ikke"}, status=status.HTTP_404_NOT_FOUND, ) def create(self, request, *args, **kwargs): """Creates a group if it does not exist""" - try: - slug = request.data["slug"] - group = Group.objects.get_or_create(slug=slug) - serializer = GroupSerializer( - group[0], data=request.data, context={"request": request} - ) - if serializer.is_valid(): - super().perform_create(serializer) - return Response(data=serializer.data, status=status.HTTP_200_OK) - else: - return Response( - {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST - ) - except Group.DoesNotExist: - return Response( - {"detail": ("Gruppen eksisterer ikke")}, - status=status.HTTP_404_NOT_FOUND, - ) + serializer = GroupCreateSerializer( + data=request.data, context={"request": request} + ) + 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( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) @action(detail=True, methods=["get"], url_path="statistics") def statistics(self, request, *args, **kwargs): diff --git a/app/group/views/membership.py b/app/group/views/membership.py index 1bf05e502..8b7ff0411 100644 --- a/app/group/views/membership.py +++ b/app/group/views/membership.py @@ -4,7 +4,8 @@ from rest_framework import status from rest_framework.response import Response -from app.common.enums import GroupType, MembershipType +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.common.pagination import BasePagination from app.common.permissions import BasicViewPermission, IsLeader, is_admin_user from app.common.viewsets import BaseViewSet diff --git a/app/kontres/models/reservation.py b/app/kontres/models/reservation.py index 22f544cae..87f91ea61 100644 --- a/app/kontres/models/reservation.py +++ b/app/kontres/models/reservation.py @@ -2,7 +2,8 @@ from django.db import models -from app.common.enums import AdminGroup, Groups, MembershipType +from app.common.enums import AdminGroup, Groups +from app.common.enums import NativeMembershipType as MembershipType from app.common.permissions import BasePermissionModel, check_has_access from app.communication.enums import UserNotificationSettingType from app.communication.notifier import Notify diff --git a/app/payment/serializers/order.py b/app/payment/serializers/order.py index 3541023e8..c8f171d9f 100644 --- a/app/payment/serializers/order.py +++ b/app/payment/serializers/order.py @@ -68,3 +68,9 @@ def create(self, validated_data): ) return order + + +class OrderEventRegistrationSerializer(BaseModelSerializer): + class Meta: + model = Order + fields = ("order_id", "status", "created_at") diff --git a/app/settings.py b/app/settings.py index d375fd4b8..d7a79e4d0 100644 --- a/app/settings.py +++ b/app/settings.py @@ -37,17 +37,21 @@ ENVIRONMENT = ( EnvironmentOptions.PRODUCTION if os.environ.get("PROD") - else EnvironmentOptions.DEVELOPMENT - if os.environ.get("DEV") - else EnvironmentOptions.LOCAL + else ( + EnvironmentOptions.DEVELOPMENT + if os.environ.get("DEV") + else EnvironmentOptions.LOCAL + ) ) WEBSITE_URL = ( "https://tihlde.org" if ENVIRONMENT == EnvironmentOptions.PRODUCTION - else "https://dev.tihlde.org" - if ENVIRONMENT == EnvironmentOptions.DEVELOPMENT - else "http://localhost:3000" + else ( + "https://dev.tihlde.org" + if ENVIRONMENT == EnvironmentOptions.DEVELOPMENT + else "http://localhost:3000" + ) ) AZURE_BLOB_STORAGE_NAME = "tihldestorage.blob.core.windows.net" @@ -87,6 +91,7 @@ "dj_rest_auth", "dry_rest_permissions", "polymorphic", + "drf_yasg", # Our apps "app.common", "app.communication", @@ -101,6 +106,7 @@ "app.payment", "app.kontres", "app.emoji", + "app.codex", ] # Django rest framework @@ -112,17 +118,18 @@ "EXCEPTION_HANDLER": "app.util.exceptions.exception_handler", "TEST_REQUEST_DEFAULT_FORMAT": "json", } -SWAGGER_SETTINGS = { - "SECURITY_DEFINITIONS": { - "DRF Token": { - "type": "apiKey", - "description": "Auth token to be passed as a header as custom authentication. " - "Can be found in the django admin panel.", - "name": "X-CSRF-Token", - "in": "header", - } - } -} +# SWAGGER_SETTINGS = { +# "SECURITY_DEFINITIONS": { +# "DRF Token": { +# "type": "apiKey", +# "description": "Auth token to be passed as a header as custom authentication. " +# "Can be found in the django admin panel.", +# "name": "X-CSRF-Token", +# "in": "header", +# } +# } +# } +SWAGGER_SETTINGS = {"SECURITY_DEFINITIONS": {"Basic": {"type": "basic"}}} # Django rest auth framework REST_AUTH_SERIALIZERS = { "PASSWORD_RESET_SERIALIZER": "app.authentication.serializers.reset_password.PasswordResetSerializer", diff --git a/app/tests/badge/test_badge_and_category_integration.py b/app/tests/badge/test_badge_and_category_integration.py index 82b1dd880..7141c1456 100644 --- a/app/tests/badge/test_badge_and_category_integration.py +++ b/app/tests/badge/test_badge_and_category_integration.py @@ -87,7 +87,8 @@ def test_no_badges_are_shown_when_none_are_public(api_client, admin_user): def test_completion_percentage_excludes_all_unregistered_users(badge, api_client): """Unregistered users are all users who do not have a membership in the TIHLDE group When calculating completing percentage, only members in this group are counted. - Creates 4 valid users plus one unregistered user. Result should be 25 == 1/4 * 100""" + Creates 4 valid users plus one unregistered user. Result should be 25 == 1/4 * 100 + """ member_with_badge = UserFactory() diff --git a/app/tests/badge/test_user_badge_integration.py b/app/tests/badge/test_user_badge_integration.py index 7faeb0753..745766609 100644 --- a/app/tests/badge/test_user_badge_integration.py +++ b/app/tests/badge/test_user_badge_integration.py @@ -98,7 +98,8 @@ def test_create_user_badge_for_different_active_dates( ): """Tests several arguments for active_from and active_to dates and checks whether response is expected. Badge is not active if active_from is later than now or active_to is earlier than now. - If active_to/from is None, it is treated as infinitly far in the past for active_from and in the future for active_to.""" + If active_to/from is None, it is treated as infinitly far in the past for active_from and in the future for active_to. + """ badge = BadgeFactory(active_to=active_to, active_from=active_from) url = _get_user_badges_url() diff --git a/app/tests/codex/__init__.py b/app/tests/codex/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/tests/codex/test_codex_event_integration.py b/app/tests/codex/test_codex_event_integration.py new file mode 100644 index 000000000..b11253f74 --- /dev/null +++ b/app/tests/codex/test_codex_event_integration.py @@ -0,0 +1,261 @@ +from datetime import timedelta + +from django.utils import timezone +from rest_framework import status + +import pytest + +from app.codex.enums import CodexGroups +from app.codex.factories import CodexEventFactory +from app.common.enums import NativeMembershipType as MembershipType +from app.util.test_utils import add_user_to_group_with_name, get_api_client + +CODEX_EVENT_BASE_URL = "/codex/events/" + + +def get_event_data( + title: str = "Test event", + description: str = "Test Description", + organizer: str = None, + lecturer: str = None, + start_date: str = timezone.now() + timedelta(days=10), + registration_start_at: str = timezone.now() + timedelta(days=1), + registration_end_at: str = timezone.now() + timedelta(days=9), +): + data = { + "title": title, + "description": description, + "start_date": start_date, + "start_registration_at": registration_start_at, + "end_registration_at": registration_end_at, + "location": "Test Location", + "maxemap_link": "https://example.com", + } + + if organizer: + data["organizer"] = organizer + if lecturer: + data["lecturer"] = lecturer + + return data + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_list_codex_events_as_codex_member(member, codex_group): + """A codex member should be able to list all codex events""" + add_user_to_group_with_name(member, codex_group) + + CodexEventFactory.create_batch(5) + + url = CODEX_EVENT_BASE_URL + client = get_api_client(user=member) + response = client.get(url) + + count = response.data["count"] + + assert response.status_code == status.HTTP_200_OK + assert count == 5 + + +@pytest.mark.django_db +def test_list_codex_event_as_member(member): + """A member should not be able to list codex events""" + url = CODEX_EVENT_BASE_URL + client = get_api_client(user=member) + response = client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_retrieve_codex_event_as_member(member, codex_event): + """A member should not be able to retrieve a codex event""" + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + client = get_api_client(user=member) + response = client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_retrieve_codex_event_as_codex_member(member, codex_group, codex_event): + """A codex member should be able to retrieve a codex event""" + add_user_to_group_with_name(member, codex_group) + + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + client = get_api_client(user=member) + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == codex_event.id + + +@pytest.mark.django_db +def test_create_codex_event_as_member(member): + """A member should not be able to create a codex event""" + url = CODEX_EVENT_BASE_URL + data = get_event_data() + client = get_api_client(user=member) + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_as_codex_member(member, codex_group): + """A normal codex member should not be able to create a codex event""" + add_user_to_group_with_name(member, codex_group) + + url = CODEX_EVENT_BASE_URL + data = get_event_data(organizer=codex_group, lecturer=member.user_id) + client = get_api_client(user=member) + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_as_codex_group_leader(member, codex_group): + """A codex group leader should be able to create a codex event""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + url = CODEX_EVENT_BASE_URL + data = get_event_data(organizer=codex_group, lecturer=member.user_id) + client = get_api_client(user=member) + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_with_end_registration_before_start_registration( + member, codex_group +): + """A codex group leader should not be able to create a codex event with end registration before start registration""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + url = CODEX_EVENT_BASE_URL + data = get_event_data( + organizer=codex_group, + lecturer=member.user_id, + registration_start_at=timezone.now() + timedelta(days=10), + registration_end_at=timezone.now() + timedelta(days=9), + ) + client = get_api_client(user=member) + response = client.post(url, data=data) + print(response.data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_with_end_registration_before_start_date( + member, codex_group +): + """A codex group leader should not be able to create a codex event with end registration before start date""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + url = CODEX_EVENT_BASE_URL + data = get_event_data( + organizer=codex_group, + lecturer=member.user_id, + start_date=timezone.now() + timedelta(days=10), + registration_start_at=timezone.now() + timedelta(days=9), + registration_end_at=timezone.now() + timedelta(days=8), + ) + client = get_api_client(user=member) + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_update_codex_event_as_member(member, codex_event): + """A member should not be able to update a codex event""" + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + data = get_event_data() + client = get_api_client(user=member) + response = client.put(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_update_codex_event_as_codex_member(member, codex_group, codex_event): + """A codex member should not be able to update a codex event""" + add_user_to_group_with_name(member, codex_group) + + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + data = get_event_data() + client = get_api_client(user=member) + response = client.put(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_update_codex_event_as_codex_group_leader(member, codex_group, codex_event): + """A codex group leader should be able to update a codex event""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + data = get_event_data() + client = get_api_client(user=member) + response = client.put(url, data=data) + + assert response.status_code == status.HTTP_200_OK + assert response.data["title"] == data["title"] + + +@pytest.mark.django_db +def test_destroy_codex_event_as_member(member, codex_event): + """A member should not be able to destroy a codex event""" + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + client = get_api_client(user=member) + response = client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_destroy_codex_event_as_codex_member(member, codex_group, codex_event): + """A codex member should not be able to destroy a codex event""" + add_user_to_group_with_name(member, codex_group) + + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + client = get_api_client(user=member) + response = client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_destroy_codex_event_as_codex_group_leader(member, codex_group, codex_event): + """A codex group leader should be able to destroy a codex event""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + client = get_api_client(user=member) + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK diff --git a/app/tests/codex/test_codex_event_registration_integration.py b/app/tests/codex/test_codex_event_registration_integration.py new file mode 100644 index 000000000..f093dd959 --- /dev/null +++ b/app/tests/codex/test_codex_event_registration_integration.py @@ -0,0 +1,149 @@ +from rest_framework import status + +import pytest + +from app.codex.enums import CodexGroups +from app.common.enums import NativeMembershipType as MembershipType +from app.util.test_utils import add_user_to_group_with_name, get_api_client + +CODEX_EVENT_BASE_URL = "/codex/events/" + + +def get_registration_url(event_id): + return f"{CODEX_EVENT_BASE_URL}{event_id}/registrations/" + + +def get_registration_detail_url(event_id, registration_id): + return f"{CODEX_EVENT_BASE_URL}{event_id}/registrations/{registration_id}/" + + +def get_registration_data(event): + return { + "event": event.id, + } + + +@pytest.mark.django_db +def test_create_codex_event_registration_as_anonymous_user(codex_event): + """An anonymous user should not be able to create a registration for a event.""" + client = get_api_client() + + url = get_registration_url(codex_event.id) + data = get_registration_data(codex_event) + + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_create_codex_event_registration_as_authenticated_user(member, codex_event): + """An authenticated user should not be able to create a registration for a event.""" + client = get_api_client(member) + + url = get_registration_url(codex_event.id) + data = get_registration_data(codex_event) + + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_registration_as_codex_member( + member, codex_group, codex_event +): + """A codex member should be able to create a registration for a event.""" + add_user_to_group_with_name(member, codex_group) + + client = get_api_client(member) + url = get_registration_url(codex_event.id) + data = get_registration_data(codex_event) + + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["user_info"]["user_id"] == member.user_id + assert response.data["order"] == 0 + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_registration_as_codex_member_with_correct_order( + member, codex_group, codex_event, codex_event_registration +): + """A codex member should be able to create a registration and the order should be correct.""" + add_user_to_group_with_name(member, codex_group) + + codex_event_registration.event = codex_event + codex_event_registration.save() + + client = get_api_client(member) + url = get_registration_url(codex_event.id) + data = get_registration_data(codex_event) + + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["user_info"]["user_id"] == member.user_id + assert response.data["order"] == codex_event_registration.order + 1 + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_delete_own_codex_event_registration_as_codex_member( + member, codex_group, codex_event_registration +): + """A codex member should be able to delete their own registration.""" + add_user_to_group_with_name(member, codex_group) + + codex_event_registration.user = member + codex_event_registration.save() + + client = get_api_client(member) + url = get_registration_detail_url( + codex_event_registration.event.id, codex_event_registration.registration_id + ) + + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_delete_other_codex_event_registration_as_codex_member( + member, codex_group, codex_event_registration +): + """A codex member should not be able to delete another user's registration.""" + add_user_to_group_with_name(member, codex_group) + + client = get_api_client(member) + url = get_registration_detail_url( + codex_event_registration.event.id, codex_event_registration.registration_id + ) + + response = client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_delete_other_codex_event_registration_as_codex_group_leader( + member, codex_group, codex_event_registration +): + """A codex group leader should be able to delete another user's registration.""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + client = get_api_client(member) + url = get_registration_detail_url( + codex_event_registration.event.id, codex_event_registration.registration_id + ) + + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK diff --git a/app/tests/communication/test_user_notification_setting_integration.py b/app/tests/communication/test_user_notification_setting_integration.py index e43253f33..f05e72789 100644 --- a/app/tests/communication/test_user_notification_setting_integration.py +++ b/app/tests/communication/test_user_notification_setting_integration.py @@ -13,9 +13,11 @@ def _get_user_notification_setting_post_data(user_notification_setting=None): "email": True, "website": True, "slack": True, - "notification_type": user_notification_setting.notification_type - if user_notification_setting - else UserNotificationSettingType.EVENT_SIGN_OFF_DEADLINE, + "notification_type": ( + user_notification_setting.notification_type + if user_notification_setting + else UserNotificationSettingType.EVENT_SIGN_OFF_DEADLINE + ), } diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 14aae4af4..e2efd0300 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -5,7 +5,9 @@ from app.badge.factories import BadgeFactory, UserBadgeFactory from app.career.factories import WeeklyBusinessFactory -from app.common.enums import AdminGroup, Groups, MembershipType +from app.codex.factories import CodexEventFactory, CodexEventRegistrationFactory +from app.common.enums import AdminGroup, Groups +from app.common.enums import NativeMembershipType as MembershipType from app.communication.factories import ( BannerFactory, NotificationFactory, @@ -299,3 +301,13 @@ def user_bio(): @pytest.fixture() def minute(user): return MinuteFactory(author=user) + + +@pytest.fixture() +def codex_event(): + return CodexEventFactory() + + +@pytest.fixture() +def codex_event_registration(): + return CodexEventRegistrationFactory() diff --git a/app/tests/content/test_cheatsheet_integration.py b/app/tests/content/test_cheatsheet_integration.py index fb10c8e9f..a148e10d9 100644 --- a/app/tests/content/test_cheatsheet_integration.py +++ b/app/tests/content/test_cheatsheet_integration.py @@ -2,18 +2,22 @@ import pytest -from app.common.enums import AdminGroup, UserClass, UserStudy +from app.common.enums import AdminGroup +from app.common.enums import NativeUserClass as UserClass +from app.common.enums import NativeUserStudy as UserStudy +from app.common.enums import get_user_class_number from app.util.test_utils import get_api_client API_CHEATSHEET_BASE_URL = "/cheatsheets/" def get_study(study): - return UserStudy(study).name + return UserStudy(study) def get_grade(grade): - return UserClass(grade).value + user_class = UserClass(grade) + return get_user_class_number(user_class) def _get_cheatsheet_url(cheatsheet): @@ -115,7 +119,7 @@ def test_delete_as_user(user, cheatsheet): @pytest.mark.django_db -def test_delete_as_admin_user(user, cheatsheet, admin_user): +def test_delete_as_admin_user(cheatsheet, admin_user): """A user should be able to to delete an cheatsheet entity.""" client = get_api_client(user=admin_user) diff --git a/app/tests/content/test_event_integration.py b/app/tests/content/test_event_integration.py index a4b4d3f29..2877434e1 100644 --- a/app/tests/content/test_event_integration.py +++ b/app/tests/content/test_event_integration.py @@ -5,10 +5,12 @@ import pytest -from app.common.enums import AdminGroup, Groups, GroupType, MembershipType +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.content.factories import EventFactory, RegistrationFactory, UserFactory from app.content.models import Category, Event -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import EventFormFactory from app.group.factories import GroupFactory from app.group.models import Group diff --git a/app/tests/content/test_minute_integration.py b/app/tests/content/test_minute_integration.py index 3a6c98445..e78e51596 100644 --- a/app/tests/content/test_minute_integration.py +++ b/app/tests/content/test_minute_integration.py @@ -2,7 +2,7 @@ import pytest -from app.common.enums import CodexGroups +from app.codex.enums import CodexGroups from app.content.factories import MinuteFactory from app.group.models import Group from app.util.test_utils import add_user_to_group_with_name, get_api_client diff --git a/app/tests/content/test_news_integration.py b/app/tests/content/test_news_integration.py index 463c3e575..04deff89b 100644 --- a/app/tests/content/test_news_integration.py +++ b/app/tests/content/test_news_integration.py @@ -2,7 +2,9 @@ import pytest -from app.common.enums import AdminGroup, Groups, GroupType, MembershipType +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.content.factories.news_factory import NewsFactory from app.content.factories.user_factory import UserFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client diff --git a/app/tests/content/test_registration_integration.py b/app/tests/content/test_registration_integration.py index 36fda9960..5b9f20f84 100644 --- a/app/tests/content/test_registration_integration.py +++ b/app/tests/content/test_registration_integration.py @@ -4,10 +4,12 @@ import pytest -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories import EventFactory, RegistrationFactory, UserFactory from app.content.factories.priority_pool_factory import PriorityPoolFactory -from app.forms.enums import EventFormType +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 diff --git a/app/tests/content/test_strike_integration.py b/app/tests/content/test_strike_integration.py index bdb662970..088cc89e8 100644 --- a/app/tests/content/test_strike_integration.py +++ b/app/tests/content/test_strike_integration.py @@ -4,7 +4,8 @@ import pytest -from app.common.enums import AdminGroup, Groups, StrikeEnum +from app.common.enums import AdminGroup, Groups +from app.common.enums import NativeStrikeEnum as StrikeEnum from app.content.factories import StrikeFactory from app.content.factories.event_factory import EventFactory from app.content.factories.user_factory import UserFactory @@ -98,7 +99,7 @@ def test_all_strike_enums_are_valid( admin_user, strike_enum, expected_status_code, strike_post_data ): """If a strike enum is not recognized, a 404 is returned""" - strike_post_data["enum"] = str(strike_enum) + strike_post_data["enum"] = strike_enum client = get_api_client(user=admin_user) response = client.post(API_STRIKE_BASE_URL, strike_post_data) diff --git a/app/tests/content/test_user_bio_integration.py b/app/tests/content/test_user_bio_integration.py index a55978d3c..b1afc5c7c 100644 --- a/app/tests/content/test_user_bio_integration.py +++ b/app/tests/content/test_user_bio_integration.py @@ -1,7 +1,13 @@ +from datetime import timedelta + +from django.utils import timezone from rest_framework import status import pytest +from app.content.factories.event_factory import EventFactory +from app.content.factories.registration_factory import RegistrationFactory +from app.content.models.registration import Registration from app.content.models.user_bio import UserBio from app.util.test_utils import get_api_client @@ -122,3 +128,67 @@ def test_destroy_other_bios(member, user_bio): assert response.status_code == status.HTTP_403_FORBIDDEN assert len(UserBio.objects.filter(id=user_bio.id)) + + +@pytest.mark.django_db +def test_get_user_events_sorted_when_expired_true(member, api_client): + """When the expired filter is 'true', the events should be sorted by start_date in descending order""" + event1 = EventFactory( + start_date=timezone.now() - timedelta(days=5), + end_date=timezone.now() - timedelta(days=4), + ) + event2 = EventFactory( + start_date=timezone.now() - timedelta(days=10), + end_date=timezone.now() - timedelta(days=9), + ) + event3 = EventFactory( + start_date=timezone.now() - timedelta(days=2), + end_date=timezone.now() - timedelta(days=1), + ) + + RegistrationFactory(user=member, event=event1) + RegistrationFactory(user=member, event=event2) + RegistrationFactory(user=member, event=event3) + + client = api_client(user=member) + response = client.get("/users/me/events/?page=1&expired=true") + + assert response.status_code == status.HTTP_200_OK + + event_ids = [event3.id, event1.id, event2.id] + returned_event_ids = [event["id"] for event in response.data["results"]] + + assert returned_event_ids == event_ids + + +@pytest.mark.django_db +def test_get_user_events_unsorted_when_expired_false(member, api_client): + """When the expired filter is not 'true', the events should not be sorted by start_date""" + event1 = EventFactory( + start_date=timezone.now() + timedelta(days=5), + end_date=timezone.now() + timedelta(days=6), + ) + event2 = EventFactory( + start_date=timezone.now() + timedelta(days=10), + end_date=timezone.now() + timedelta(days=11), + ) + event3 = EventFactory( + start_date=timezone.now() + timedelta(days=1), + end_date=timezone.now() + timedelta(days=2), + ) + + RegistrationFactory(user=member, event=event1) + RegistrationFactory(user=member, event=event2) + RegistrationFactory(user=member, event=event3) + + client = api_client(user=member) + response = client.get("/users/me/events/?page=1&expired=false") + + assert response.status_code == status.HTTP_200_OK + + registration_ids = Registration.objects.filter(user=member).values_list( + "event_id", flat=True + ) + returned_event_ids = [event["id"] for event in response.data["results"]] + + assert returned_event_ids == list(registration_ids) diff --git a/app/tests/content/test_user_integration.py b/app/tests/content/test_user_integration.py index bd36027e7..30e34da38 100644 --- a/app/tests/content/test_user_integration.py +++ b/app/tests/content/test_user_integration.py @@ -6,13 +6,14 @@ import pytest -from app.common.enums import AdminGroup, GroupType +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType from app.content.factories.event_factory import EventFactory from app.content.factories.registration_factory import RegistrationFactory from app.content.factories.strike_factory import StrikeFactory from app.content.factories.user_factory import UserFactory from app.content.models import User -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import EventFormFactory, SubmissionFactory from app.group.models import Group from app.util.test_utils import add_user_to_group_with_name @@ -373,6 +374,7 @@ def test_update_other_user_as_index_user(member, user, api_client): assert user.last_name == data["last_name"] +@pytest.mark.skip(reason="Must be refactored") def test_create_as_anonymous(default_client): """An anonymous user should be able to create a new user.""" data = _get_user_post_data() @@ -381,6 +383,7 @@ def test_create_as_anonymous(default_client): assert response.status_code == status.HTTP_201_CREATED +@pytest.mark.skip(reason="Must be refactored") def test_create_correctly_assigns_fields(api_client): client = api_client() data = _get_user_post_data() @@ -394,6 +397,7 @@ def test_create_correctly_assigns_fields(api_client): assert user.last_name == data["last_name"] +@pytest.mark.skip(reason="Must be refactored") def test_create_adds_user_to_class_group(api_client, dataing, group2019): data = _get_user_post_data() response = api_client().post(API_USER_BASE_URL, data) @@ -403,6 +407,7 @@ def test_create_adds_user_to_class_group(api_client, dataing, group2019): assert dataing.members.filter(user_id=user_id).exists() +@pytest.mark.skip(reason="Must be refactored") def test_create_adds_user_to_study_group(api_client, dataing, group2019): data = _get_user_post_data() response = api_client().post(API_USER_BASE_URL, data) @@ -412,6 +417,7 @@ def test_create_adds_user_to_study_group(api_client, dataing, group2019): assert group2019.members.filter(user_id=user_id).exists() +@pytest.mark.skip(reason="Must be refactored") def test_that_user_can_be_created_without_any_groups(api_client): data = _get_user_post_data() data["study"] = None @@ -422,6 +428,7 @@ def test_that_user_can_be_created_without_any_groups(api_client): assert response.status_code == status.HTTP_201_CREATED +@pytest.mark.skip(reason="Must be refactored") def test_create_duplicate_user(default_client): """ An anonymous user should not be able to create a new user diff --git a/app/tests/forms/test_eventform_integration.py b/app/tests/forms/test_eventform_integration.py index 701af1338..bba7e41e1 100644 --- a/app/tests/forms/test_eventform_integration.py +++ b/app/tests/forms/test_eventform_integration.py @@ -2,10 +2,12 @@ import pytest -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories import EventFactory, RegistrationFactory from app.content.serializers import EventListSerializer -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import EventFormFactory from app.group.factories import GroupFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client @@ -134,7 +136,7 @@ def test_list_forms_data(admin_user): "resource_type": "EventForm", "title": form.title, "event": EventListSerializer(form.event).data, - "type": form.type.name, + "type": form.type, "viewer_has_answered": False, "fields": [ { @@ -147,7 +149,7 @@ def test_list_forms_data(admin_user): "order": option.order, } ], - "type": field.type.name, + "type": field.type, "required": field.required, "order": field.order, } diff --git a/app/tests/forms/test_form_integration.py b/app/tests/forms/test_form_integration.py index 7b564ade6..757d64f46 100644 --- a/app/tests/forms/test_form_integration.py +++ b/app/tests/forms/test_form_integration.py @@ -100,7 +100,7 @@ def test_list_form_templates_data(admin_user): "order": option.order, } ], - "type": field.type.name, + "type": field.type, "required": field.required, "order": field.order, } diff --git a/app/tests/forms/test_group_form_integration.py b/app/tests/forms/test_group_form_integration.py index 6f9dce628..d45f46ecc 100644 --- a/app/tests/forms/test_group_form_integration.py +++ b/app/tests/forms/test_group_form_integration.py @@ -2,7 +2,9 @@ import pytest -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.forms.tests.form_factories import GroupFormFactory from app.group.factories import GroupFactory, MembershipFactory from app.group.models import Group diff --git a/app/tests/forms/test_submission_integration.py b/app/tests/forms/test_submission_integration.py index 573820b51..4a4eeb050 100644 --- a/app/tests/forms/test_submission_integration.py +++ b/app/tests/forms/test_submission_integration.py @@ -2,9 +2,9 @@ import pytest -from app.common.enums import MembershipType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories import RegistrationFactory -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import ( AnswerFactory, EventFormFactory, diff --git a/app/tests/groups/test_fine_integration.py b/app/tests/groups/test_fine_integration.py index b14483843..39de650fc 100644 --- a/app/tests/groups/test_fine_integration.py +++ b/app/tests/groups/test_fine_integration.py @@ -2,7 +2,8 @@ import pytest -from app.common.enums import AdminGroup, MembershipType +from app.common.enums import AdminGroup +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories.user_factory import UserFactory from app.group.factories.fine_factory import FineFactory from app.group.factories.group_factory import GroupFactory diff --git a/app/tests/groups/test_group_integration.py b/app/tests/groups/test_group_integration.py index 719040f59..96675cf9c 100644 --- a/app/tests/groups/test_group_integration.py +++ b/app/tests/groups/test_group_integration.py @@ -3,6 +3,7 @@ import pytest from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType from app.util.test_utils import get_api_client GROUP_URL = "/groups/" @@ -23,6 +24,10 @@ def _get_group_put_data(group): return {**_get_group_post_data(group), "description": "New Description"} +def get_group_post_data(type): + return {"name": "navn", "slug": "slug", "type": type} + + @pytest.mark.django_db def test_list_as_anonymous_user(default_client): """Tests if an anonymous user can list groups""" @@ -107,33 +112,53 @@ def test_update_as_group_user( @pytest.mark.django_db -def test_create_makes_group_if_not_found(group, user): - """Tests if that a group is created if it doesn't exits""" +@pytest.mark.parametrize("group_type", GroupType.public_groups()) +def test_create_new_group_as_member(member, group_type): + """Member should not be able to create a new group""" + client = get_api_client(user=member) + url = GROUP_URL + data = get_group_post_data(group_type) - name = group.name + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("group_type", GroupType.public_groups()) +def test_create_new_group_as_hs(group_type, admin_user): + """HS members should be allowed to create a new group""" + client = get_api_client(user=admin_user) + url = GROUP_URL + data = get_group_post_data(group_type) - client = get_api_client(user=user, group_name=AdminGroup.HS) - url = _get_group_url() - data = _get_group_post_data(group=group) response = client.post(url, data=data) - group.refresh_from_db() - assert group.name == name - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_201_CREATED @pytest.mark.django_db -def test_create_return_group_if_found(group, user): +@pytest.mark.parametrize("group_type", GroupType.public_groups()) +def test_create_new_group_as_index(group_type, index_member): + """Index members should be allowed to create a new group""" + client = get_api_client(user=index_member) + url = GROUP_URL + data = get_group_post_data(group_type) - """Tests if that a group is returned if it does exits when trying to create a group""" + response = client.post(url, data=data) - name = group.name + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +@pytest.mark.parametrize("group_type", GroupType.non_public_groups()) +def test_create_new_group_with_invalid_group_type_as_index(group_type, index_member): + """Index members with invalid group type should not be allowed to create a new group""" + client = get_api_client(user=index_member) + url = GROUP_URL + data = get_group_post_data(group_type) - client = get_api_client(user=user, group_name=AdminGroup.HS) - url = _get_group_url() - data = _get_group_post_data(group=group) response = client.post(url, data=data) - group.refresh_from_db() - assert group.name == name - assert response.status_code == status.HTTP_200_OK + print(response) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/app/tests/groups/test_law_integration.py b/app/tests/groups/test_law_integration.py index 1bb1dcfec..3dca0e0ec 100644 --- a/app/tests/groups/test_law_integration.py +++ b/app/tests/groups/test_law_integration.py @@ -4,7 +4,8 @@ import pytest -from app.common.enums import AdminGroup, MembershipType +from app.common.enums import AdminGroup +from app.common.enums import NativeMembershipType as MembershipType from app.group.factories.group_factory import GroupFactory from app.group.factories.law_factory import LawFactory from app.group.factories.membership_factory import MembershipFactory diff --git a/app/tests/groups/test_membership_history_integration.py b/app/tests/groups/test_membership_history_integration.py index 033682875..690fa402f 100644 --- a/app/tests/groups/test_membership_history_integration.py +++ b/app/tests/groups/test_membership_history_integration.py @@ -2,7 +2,8 @@ import pytest -from app.common.enums import AdminGroup, MembershipType +from app.common.enums import AdminGroup +from app.common.enums import NativeMembershipType as MembershipType from app.group.factories.membership_factory import MembershipHistoryFactory from app.util.test_utils import get_api_client diff --git a/app/tests/groups/test_membership_integration.py b/app/tests/groups/test_membership_integration.py index 4902dbcf9..2b16ee29b 100644 --- a/app/tests/groups/test_membership_integration.py +++ b/app/tests/groups/test_membership_integration.py @@ -2,7 +2,8 @@ import pytest -from app.common.enums import AdminGroup, MembershipType +from app.common.enums import AdminGroup +from app.common.enums import NativeMembershipType as MembershipType from app.group.factories.membership_factory import MembershipFactory from app.util.test_utils import get_api_client diff --git a/app/tests/payment/test_paid_event_integration.py b/app/tests/payment/test_paid_event_integration.py index a0aaac879..23ccb00a0 100644 --- a/app/tests/payment/test_paid_event_integration.py +++ b/app/tests/payment/test_paid_event_integration.py @@ -4,7 +4,7 @@ import pytest -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.content.models.event import Event from app.group.models.group import Group from app.payment.factories.paid_event_factory import PaidEventFactory diff --git a/app/urls.py b/app/urls.py index 3e8029cd9..d4fb286ba 100644 --- a/app/urls.py +++ b/app/urls.py @@ -16,10 +16,31 @@ from django.contrib import admin from django.urls import include, path +from rest_framework import permissions + +from drf_yasg import openapi +from drf_yasg.views import get_schema_view + +schema_view = get_schema_view( + openapi.Info( + title="Lepton API", + default_version="v1", + description="API for our Django backend", + contact=openapi.Contact(email="teknologiminister@tihlde.org"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) urlpatterns = [ path("admin/", admin.site.urls), path("", include("rest_framework.urls")), + path( + "swagger/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), # Our endpoints path("", include("app.career.urls")), path("", include("app.communication.urls")), @@ -33,4 +54,5 @@ path("badges/", include("app.badge.urls")), path("kontres/", include("app.kontres.urls")), path("emojis/", include("app.emoji.urls")), + path("codex/", include("app.codex.urls")), ] diff --git a/app/util/test_utils.py b/app/util/test_utils.py index 1f6b32578..78682ec2c 100644 --- a/app/util/test_utils.py +++ b/app/util/test_utils.py @@ -2,7 +2,9 @@ from rest_framework.authtoken.models import Token from rest_framework.test import APIClient -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.group.models import Group, Membership diff --git a/compose/Dockerfile b/compose/Dockerfile index 3e7579d93..f9e738323 100644 --- a/compose/Dockerfile +++ b/compose/Dockerfile @@ -12,6 +12,8 @@ RUN apt-get update \ && apt-get install -y build-essential \ # mysqlclient dependencies && apt-get install -y default-libmysqlclient-dev \ + # curl for making HTTP requests + && apt-get install -y curl \ # cleaning up unused files && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ && rm -rf /var/lib/apt/lists/* diff --git a/docker-compose.yml b/docker-compose.yml index 6bf689022..508419481 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: db: image: mysql:8.0 diff --git a/requirements.txt b/requirements.txt index b3975ffb8..f62db6e71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,29 +2,30 @@ requests logzero aiohttp-cors wheel -mysqlclient == 2.1.0 -sentry-sdk == 1.9.8 # 1.9.9 has a bug that causes the following https://github.com/TIHLDE/Lepton/actions/runs/3153138974/jobs/5129248894 -celery == 5.2.2 -azure-storage-blob == 12.12.0 -python-dotenv ~= 0.21 -gunicorn == 20.1.0 -uvicorn == 0.19.0 -whitenoise == 6.2.0 -django-ical == 1.8.0 -slack-sdk == 3.19.3 -pyjwt ~= 2.6.0 +mysqlclient == 2.1.1 +sentry-sdk == 2.8.0 +celery == 5.4.0 +azure-storage-blob == 12.13.1 +python-dotenv ~= 1.0.1 +gunicorn == 23.0.0 +uvicorn == 0.30.6 +whitenoise == 6.7.0 +django-ical == 1.9.2 +slack-sdk == 3.33.1 +pyjwt ~= 2.9.0 # Django # ------------------------------------------------------------------------------ -Django==4.2.5 -django-enumchoicefield == 3.0.0 -django-filter == 22.1 -django-ordered-model~=3.6 +Django == 4.2.16 +django-enumchoicefield == 3.0.1 +django-filter == 24.3 +django-ordered-model ~= 3.7.4 # Django REST Framework -djangorestframework==3.14.0 +djangorestframework == 3.14.0 django-cors-headers -dj-rest-auth == 2.2.3 +dj-rest-auth == 6.0.0 +drf-yasg == 1.21.7 #django dry rest permissions django-dry-rest-permissions == 1.2.0 @@ -33,28 +34,28 @@ django-dry-rest-permissions == 1.2.0 django-polymorphic ~= 3.1 django-rest-polymorphic == 0.1.9 -django-mptt == 0.14.0 +django-mptt == 0.16.0 # Code quality # ------------------------------------------------------------------------------ pylint -black == 22.10.0 +black == 24.3.0 isort flake8 flake8-django flake8-black -pre-commit == 2.20.0 +pre-commit == 3.8.0 # Testing # ------------------------------------------------------------------------------ coverage pdbpp -pytest == 7.1.1 -pytest-cov == 4.0.0 -pytest-django == 4.5.2 -factory-boy == 3.2.1 -pytest-factoryboy == 2.5.0 -pytest-lazy-fixture==0.6.3 +pytest == 7.4.4 +pytest-cov == 5.0.0 +pytest-django == 4.9.0 +factory-boy == 3.3.1 +pytest-factoryboy == 2.7.0 +pytest-lazy-fixture == 0.6.3 # CSV -djangorestframework-csv==2.1.1 +djangorestframework-csv == 3.0.2 diff --git a/scripts/app.py b/scripts/app.py new file mode 100644 index 000000000..0987e9935 --- /dev/null +++ b/scripts/app.py @@ -0,0 +1,73 @@ +import os + + +def create_app(): + """ + Create a Django app directory with all the necessary directories and files. + """ + try: + app_name = input("Enter the app name: ").strip().lower() + + BASE_PATH = "app" + + if app_name in os.listdir(BASE_PATH): + print(f"App '{app_name}' already exists.") + return + + app_path = os.path.join(BASE_PATH, app_name) + + # Create the app directory + os.makedirs(app_path, exist_ok=True) + + # Create the app's directories + init_dir(app_path, "admin") + init_dir(app_path, "factories") + init_dir(app_path, "filters") + init_dir(app_path, "migrations") + init_dir(app_path, "models") + init_dir(app_path, "tasks") + init_dir(app_path, "tests") + init_dir(app_path, "util") + init_dir(app_path, "views") + init_dir(app_path, "serializers") + + # Create the app's files + init_app_file(app_path, "__init__.py") + + config_content = f"""from django.apps import AppConfig + + +class {app_name.capitalize()}Config(AppConfig): + name = "app.{app_name}" + """ + + init_app_file(app_path, "app.py", content=config_content) + init_app_file(app_path, "enums.py") + init_app_file(app_path, "exceptions.py") + init_app_file(app_path, "mixins.py") + init_app_file(app_path, "urls.py") + + print(f"App '{app_name}' created successfully.") + print("Don't forget to add the app to the INSTALLED_APPS in the settings.py file.") + except Exception as e: + print(f"\nAn error occurred: {e}") + return + + +def init_dir(app_path: str, dir_name: str): + """Create a directory in the app directory, with a __init__.py file.""" + dir_path = os.path.join(app_path, dir_name) + os.makedirs(dir_path, exist_ok=True) + with open(os.path.join(dir_path, "__init__.py"), "w") as f: + f.write("") + + +def init_app_file(app_path: str, file_name: str, content: str = ""): + """Create a file in the app directory.""" + file_path = os.path.join(app_path, file_name) + with open(file_path, "w") as f: + f.write(content) + + +if __name__ == "__main__": + create_app()