From 8f5ab6a66b5fe3b55de8d2b3186b445ad001b3d2 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:41:24 +0200 Subject: [PATCH 1/4] Late September Update Fix (#880) * 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 * fixed filtering of groups and made tests --------- 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> --- app/group/filters/group.py | 6 ++-- app/tests/groups/test_group_integration.py | 35 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/group/filters/group.py b/app/group/filters/group.py index fd3a7904..2164e5c7 100644 --- a/app/group/filters/group.py +++ b/app/group/filters/group.py @@ -15,11 +15,9 @@ class Meta: fields = ["type", "overview"] 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) + return queryset.filter(type__in=value) - def filter_overview(self, queryset, _): + 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/tests/groups/test_group_integration.py b/app/tests/groups/test_group_integration.py index 96675cf9..b7be6840 100644 --- a/app/tests/groups/test_group_integration.py +++ b/app/tests/groups/test_group_integration.py @@ -4,6 +4,7 @@ from app.common.enums import AdminGroup from app.common.enums import NativeGroupType as GroupType +from app.group.factories import GroupFactory from app.util.test_utils import get_api_client GROUP_URL = "/groups/" @@ -13,6 +14,14 @@ def _get_group_url(group=None): return f"{GROUP_URL}{group.slug}/" if (group) else f"{GROUP_URL}" +def _get_overview_group_url(): + return f"{GROUP_URL}?overview=true" + + +def _get_group_type_filtered_list(slug: str): + return f"{GROUP_URL}?type={slug}" + + def _get_group_post_data(group): return { "name": group.name, @@ -38,6 +47,32 @@ def test_list_as_anonymous_user(default_client): assert response.status_code == status.HTTP_200_OK +@pytest.mark.django_db +def test_list_overview_of_groups_as_member(member): + """Tests if a member can list an overview of groups""" + + client = get_api_client(user=member) + url = _get_overview_group_url() + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_list_type_of_groups_as_member(member): + """Tests if a member can list a type of groups""" + + GroupFactory(type=GroupType.BOARD) + GroupFactory(type=GroupType.COMMITTEE) + + client = get_api_client(user=member) + url = _get_group_type_filtered_list(GroupType.BOARD.value) + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + + @pytest.mark.django_db def test_retrieve_as_anonymous_user(default_client, group): """Tests if an anonymous user can retrieve a group""" From 3af9f5e0b9154111b9468b2ed70e23477e64dc38 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Wed, 25 Sep 2024 14:33:05 +0200 Subject: [PATCH 2/4] Cheatsheet List Fix (#881) * 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 * fixed filtering of groups and made tests * fixed list endpoint for cheatsheets * trigger * 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> --- app/common/enums.py | 15 +++++++++++++++ app/content/views/cheatsheet.py | 6 ++++-- app/tests/content/test_cheatsheet_integration.py | 5 +++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/common/enums.py b/app/common/enums.py index 10d63dcf..68afaa78 100644 --- a/app/common/enums.py +++ b/app/common/enums.py @@ -31,6 +31,21 @@ def get_user_class_number(user_class: NativeUserClass) -> int: return int(_class.split(".")[0]) +def get_user_class_name(user_class: int): + if user_class == 1: + return NativeUserClass.FIRST + elif user_class == 2: + return NativeUserClass.SECOND + elif user_class == 3: + return NativeUserClass.THIRD + elif user_class == 4: + return NativeUserClass.FOURTH + elif user_class == 5: + return NativeUserClass.FIFTH + else: + return NativeUserClass.ALUMNI + + # 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" diff --git a/app/content/views/cheatsheet.py b/app/content/views/cheatsheet.py index 8037f48b..56304dd5 100644 --- a/app/content/views/cheatsheet.py +++ b/app/content/views/cheatsheet.py @@ -4,7 +4,8 @@ from sentry_sdk import capture_exception -from app.common.enums import UserClass, UserStudy +from app.common.enums import NativeUserStudy as UserStudy +from app.common.enums import get_user_class_name from app.common.pagination import BasePagination from app.common.permissions import BasicViewPermission, is_admin_user from app.common.viewsets import BaseViewSet @@ -24,8 +25,9 @@ class CheatsheetViewSet(BaseViewSet): def get_object(self): if "pk" not in self.kwargs: + grade = get_user_class_name(int(self.kwargs["grade"])) return self.filter_queryset(self.queryset).filter( - grade=UserClass(int(self.kwargs["grade"])), + grade=grade, study=UserStudy[self.kwargs["study"]], ) diff --git a/app/tests/content/test_cheatsheet_integration.py b/app/tests/content/test_cheatsheet_integration.py index a148e10d..7e08d6f6 100644 --- a/app/tests/content/test_cheatsheet_integration.py +++ b/app/tests/content/test_cheatsheet_integration.py @@ -47,16 +47,17 @@ def test_list_as_anonymous_user(default_client, cheatsheet): @pytest.mark.django_db -def test_list_as_member(cheatsheet, member): +def test_list_cheatsheets_as_member(cheatsheet, member): """ A member of TIHLDE should be able to list all cheatsheets. """ client = get_api_client(user=member) url = _get_cheatsheet_url(cheatsheet) response = client.get(url) + data = response.data assert response.status_code == status.HTTP_200_OK - assert len(response.json()) + assert len(data.get("results")) @pytest.mark.django_db From 8c8199a33a693cc1a2e917727770be5b67423873 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Fri, 11 Oct 2024 20:33:15 +0200 Subject: [PATCH 3/4] Mid October Update (#903) * 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 * fixed filtering of groups and made tests * fixed list endpoint for cheatsheets * trigger * format * Endpoint for sending email (#883) * fix formatting * created tests for send_email endpoint * Fix code scanning alert no. 45: Information exposure through an exception Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fixed errors in send_email and in tests * lint * added tests for empty lists and for sending mail to multiple users --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * chore(deps): bump black from 24.3.0 to 24.8.0 (#869) Bumps [black](https://github.com/psf/black) from 24.3.0 to 24.8.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/24.3.0...24.8.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... 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 azure-storage-blob from 12.13.1 to 12.23.1 (#885) Bumps [azure-storage-blob](https://github.com/Azure/azure-sdk-for-python) from 12.13.1 to 12.23.1. - [Release notes](https://github.com/Azure/azure-sdk-for-python/releases) - [Changelog](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/esrp_release.md) - [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-storage-blob_12.13.1...azure-storage-blob_12.23.1) --- updated-dependencies: - dependency-name: azure-storage-blob dependency-type: direct:production update-type: version-update:semver-minor ... 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> * Added admin.py to root in new app, and added app dir to tests (#892) added admin.py to root in new app, and added app dir to tests * Description to forms (#894) added description to form * Bug report system (#865) * Created New App named Index * created model * Refactor: Change admin file * admin * added serializer and viewsets for list * fixed typing error for permission_classes * Started on the create serializer for feedback * Made tests for create feedback * Implemented update serializer for feedback * made destroy method and testing create method as member * Made destroy method and tests * Fixed linting * Fixed linting --------- Co-authored-by: Tam Le Co-authored-by: Josefine Arntsen Co-authored-by: Mads Nylund Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> * Renaming of index app to feedback (#901) renamed index app to feedback * chore(deps): bump django from 4.2.16 to 5.1.1 (#889) Bumps [django](https://github.com/django/django) from 4.2.16 to 5.1.1. - [Commits](https://github.com/django/django/compare/4.2.16...5.1.1) --- updated-dependencies: - dependency-name: django dependency-type: direct:production update-type: version-update:semver-major ... 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> * Event registration race condition (#902) * started on fixing race condition * fixed race condition * upadted CHANGELOG.md --------- 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> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Josefine Arntsen <128137082+josefinearntsen@users.noreply.github.com> Co-authored-by: Josefine Arntsen --- CHANGELOG.md | 5 +- app/communication/enums.py | 6 + ...ernotificationsetting_notification_type.py | 37 +++ app/communication/tests/test_send_email.py | 216 ++++++++++++++++++ app/content/serializers/user.py | 1 + app/content/urls.py | 2 + app/content/views/__init__.py | 1 + app/content/views/registration.py | 4 +- app/content/views/send_email.py | 84 +++++++ app/feedback/__init__.py | 0 app/feedback/admin.py | 5 + app/feedback/app.py | 5 + app/feedback/enums.py | 8 + app/feedback/exceptions.py | 0 app/feedback/factories/__init__.py | 2 + app/feedback/factories/bug_factory.py | 13 ++ app/feedback/factories/idea_factory.py | 13 ++ app/feedback/filters/__init__.py | 0 app/feedback/migrations/0001_initial.py | 112 +++++++++ app/feedback/migrations/__init__.py | 0 app/feedback/mixins.py | 0 app/feedback/models/__init__.py | 3 + app/feedback/models/bug.py | 5 + app/feedback/models/feedback.py | 76 ++++++ app/feedback/models/idea.py | 5 + app/feedback/serializers/__init__.py | 3 + app/feedback/serializers/bug.py | 17 ++ app/feedback/serializers/feedback.py | 80 +++++++ app/feedback/serializers/idea.py | 17 ++ app/feedback/tasks/__init__.py | 0 app/feedback/tests/__init__.py | 0 app/feedback/urls.py | 8 + app/feedback/util/__init__.py | 0 app/feedback/views/__init__.py | 0 app/feedback/views/feedback.py | 81 +++++++ app/forms/migrations/0014_form_description.py | 18 ++ app/forms/models/forms.py | 1 + app/forms/serializers/forms.py | 1 + app/settings.py | 1 + app/tests/conftest.py | 11 + app/tests/index/__init__.py | 0 app/tests/index/test_feedback_integration.py | 174 ++++++++++++++ app/urls.py | 1 + requirements.txt | 6 +- scripts/app.py | 12 +- 45 files changed, 1028 insertions(+), 6 deletions(-) create mode 100644 app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py create mode 100644 app/communication/tests/test_send_email.py create mode 100644 app/content/views/send_email.py create mode 100644 app/feedback/__init__.py create mode 100644 app/feedback/admin.py create mode 100644 app/feedback/app.py create mode 100644 app/feedback/enums.py create mode 100644 app/feedback/exceptions.py create mode 100644 app/feedback/factories/__init__.py create mode 100644 app/feedback/factories/bug_factory.py create mode 100644 app/feedback/factories/idea_factory.py create mode 100644 app/feedback/filters/__init__.py create mode 100644 app/feedback/migrations/0001_initial.py create mode 100644 app/feedback/migrations/__init__.py create mode 100644 app/feedback/mixins.py create mode 100644 app/feedback/models/__init__.py create mode 100644 app/feedback/models/bug.py create mode 100644 app/feedback/models/feedback.py create mode 100644 app/feedback/models/idea.py create mode 100644 app/feedback/serializers/__init__.py create mode 100644 app/feedback/serializers/bug.py create mode 100644 app/feedback/serializers/feedback.py create mode 100644 app/feedback/serializers/idea.py create mode 100644 app/feedback/tasks/__init__.py create mode 100644 app/feedback/tests/__init__.py create mode 100644 app/feedback/urls.py create mode 100644 app/feedback/util/__init__.py create mode 100644 app/feedback/views/__init__.py create mode 100644 app/feedback/views/feedback.py create mode 100644 app/forms/migrations/0014_form_description.py create mode 100644 app/tests/index/__init__.py create mode 100644 app/tests/index/test_feedback_integration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e14785..ae1eaec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,11 @@ ## Neste versjon -## Versjon 2024.09.25 +## Versjon 2024.10.11 +- ✨ **Tilbakemelding-funksjon**. Man kan nå opprette tilbakemeldinger for bugs og idé. +- 🦟 **Påmelding**. Det vil nå ikke være mulig med flere påmeldinger på et arrangement enn maksgrensen. +## 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. diff --git a/app/communication/enums.py b/app/communication/enums.py index d9c6fc60..f664be7f 100644 --- a/app/communication/enums.py +++ b/app/communication/enums.py @@ -18,3 +18,9 @@ class UserNotificationSettingType(models.TextChoices): RESERVATION_NEW = "RESERVATION NEW", "Ny reservasjon" RESERVATION_APPROVED = "RESERVATION APPROVED", "Godkjent reservasjon" RESERVATION_CANCELLED = "RESERVATION CANCELLED", "Avslått reservasjon" + KONTRES = "KONTRES", "Kontres" + BLITZED = "BLITZED", "Blitzed" + + @classmethod + def get_kontres_and_blitzed(cls): + return [cls.KONTRES, cls.BLITZED] diff --git a/app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py b/app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py new file mode 100644 index 00000000..b368b134 --- /dev/null +++ b/app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.5 on 2024-09-23 18:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("communication", "0010_alter_usernotificationsetting_notification_type"), + ] + + operations = [ + migrations.AlterField( + model_name="usernotificationsetting", + name="notification_type", + field=models.CharField( + choices=[ + ("REGISTRATION", "Påmeldingsoppdateringer"), + ("UNREGISTRATION", "Avmeldingsoppdateringer"), + ("STRIKE", "Prikkoppdateringer"), + ("EVENT_SIGN_UP_START", "Arrangementer - påmeldingsstart"), + ("EVENT_SIGN_OFF_DEADLINE", "Arrangementer - avmeldingsfrist"), + ("EVENT_EVALUATION", "Arrangementer - evaluering"), + ("EVENT_INFO", "Arrangementer - info fra arrangør"), + ("FINE", "Grupper - bot"), + ("GROUP_MEMBERSHIP", "Grupper - medlemsskap"), + ("OTHER", "Andre"), + ("RESERVATION NEW", "Ny reservasjon"), + ("RESERVATION APPROVED", "Godkjent reservasjon"), + ("RESERVATION CANCELLED", "Avslått reservasjon"), + ("KONTRES", "Kontres"), + ("BLITZED", "Blitzed"), + ], + max_length=30, + ), + ), + ] diff --git a/app/communication/tests/test_send_email.py b/app/communication/tests/test_send_email.py new file mode 100644 index 00000000..eef51ca4 --- /dev/null +++ b/app/communication/tests/test_send_email.py @@ -0,0 +1,216 @@ +import os +from unittest.mock import patch + +from rest_framework import status + +import pytest + +from app.communication.enums import UserNotificationSettingType +from app.communication.notifier import Notify +from app.content.factories import UserFactory +from app.util.test_utils import get_api_client + +EMAIL_URL = "/send-email/" +EMAIL_API_KEY = os.environ.get("EMAIL_API_KEY") + + +def _get_email_url(): + return f"{EMAIL_URL}" + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_success(mock_send): + """ + Test that the send_email endpoint sends an email successfully. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_201_CREATED + mock_send.assert_called_once() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_field_missing(mock_send): + """ + Test that the send_email endpoint returns 400 when one of the fields is missing. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_wrong_api_key(mock_send): + """ + Test that the send_email endpoint returns 403 when the API key is invalid. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": "wrong_key"} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_user_id_invalid(mock_send): + """ + Test that the send_email endpoint returns 404 when the user id is invalid. + """ + test_user = UserFactory() + + data = { + "user_id_list": [999], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_404_NOT_FOUND + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +@pytest.mark.parametrize( + "notification_type", UserNotificationSettingType.get_kontres_and_blitzed() +) +def test_email_success_with_kontres_and_blitzed(mock_send, notification_type): + """ + Tests that the send_email endpoint works with both KONTRES and BLITZED notification types. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": notification_type, + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_201_CREATED + mock_send.assert_called_once() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_success_with_user_id_list(mock_send): + """ + Test that the send_email endpoint sends an email successfully to a list of users. + """ + test_user1 = UserFactory() + test_user2 = UserFactory() + test_user3 = UserFactory() + + data = { + "user_id_list": [ + test_user.user_id for test_user in [test_user1, test_user2, test_user3] + ], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user1) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_201_CREATED + mock_send.assert_called_once() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_user_id_list_is_empty(mock_send): + """ + Test that the send_email endpoint returns 400 when the user id list is empty. + """ + test_user = UserFactory() + + data = { + "user_id_list": [], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_user_paragraph_list_is_empty(mock_send): + """ + Test that the send_email endpoint returns 400 when the paragraph list is empty. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": [], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + mock_send.assert_not_called() diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index 2378c562..9150dc28 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -127,6 +127,7 @@ class Meta: "user_id", "first_name", "last_name", + "image", ) diff --git a/app/content/urls.py b/app/content/urls.py index c5a48239..26911fd6 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -20,6 +20,7 @@ accept_form, delete, register_with_feide, + send_email, upload, ) @@ -53,6 +54,7 @@ re_path(r"", include(router.urls)), path("accept-form/", accept_form), path("upload/", upload), + path("send-email/", send_email), path("delete-file///", delete), path("feide/", register_with_feide), re_path(r"users/(?P[^/.]+)/events.ics", UserCalendarEvents()), diff --git a/app/content/views/__init__.py b/app/content/views/__init__.py index 3392d438..5a9a5c3f 100644 --- a/app/content/views/__init__.py +++ b/app/content/views/__init__.py @@ -16,3 +16,4 @@ from app.content.views.logentry import LogEntryViewSet from app.content.views.minute import MinuteViewSet from app.content.views.feide import register_with_feide +from app.content.views.send_email import send_email diff --git a/app/content/views/registration.py b/app/content/views/registration.py index 2b5d680a..0aa13b3a 100644 --- a/app/content/views/registration.py +++ b/app/content/views/registration.py @@ -1,5 +1,6 @@ import uuid +from django.db.transaction import atomic from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, status @@ -48,6 +49,7 @@ def _is_own_registration(self): def _is_not_own_registration(self): return not self._is_own_registration() + @atomic def create(self, request, *args, **kwargs): """Register the current user for the given event.""" @@ -68,7 +70,7 @@ def create(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) event_id = self.kwargs.get("event_id", None) - event = Event.objects.get(pk=event_id) + event = Event.objects.select_for_update().get(pk=event_id) registration = super().perform_create( serializer, event=event, user=request.user diff --git a/app/content/views/send_email.py b/app/content/views/send_email.py new file mode 100644 index 00000000..2619632d --- /dev/null +++ b/app/content/views/send_email.py @@ -0,0 +1,84 @@ +import os + +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from app.communication.enums import UserNotificationSettingType +from app.communication.notifier import Notify +from app.content.models import User + + +@api_view(["POST"]) +def send_email(request): + """ + Endpoint for sending a notification and email to a user. + + Body should contain: + - 'user_id_list': A list of user ids to send the email to. + - 'notification_type': KONTRES or BLITZED. + - 'title': The title of the notification. + - 'paragraphs': A list of paragraphs to include in the notification. + + The header should contain: + - 'api_key': A key for validating access. + + """ + try: + EMAIL_API_KEY = os.environ.get("EMAIL_API_KEY") + api_key = request.META.get("api_key") + if api_key != EMAIL_API_KEY: + return Response( + {"detail": "Feil API nøkkel"}, + status=status.HTTP_403_FORBIDDEN, + ) + + user_id_list = request.data.get("user_id_list") + paragraphs = request.data.get("paragraphs") + title = request.data.get("title") + notification_type = request.data.get("notification_type") + + if not isinstance(user_id_list, list) or not user_id_list: + return Response( + {"detail": "En ikke-tom liste med bruker id-er må inkluderes"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not isinstance(paragraphs, list) or not paragraphs: + return Response( + {"detail": "En ikke-tom liste med paragrafer må inkluderes"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not notification_type or not title: + return Response( + { + "detail": "Notifikasjonstype (KONTRES/BLITZED) og tittel må være satt" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + users = list(User.objects.filter(user_id__in=user_id_list)) + if not users or len(users) != len(user_id_list): + return Response( + {"detail": "En eller flere brukere ble ikke funnet"}, + status=status.HTTP_404_NOT_FOUND, + ) + + email = Notify( + users, + f"{title}", + UserNotificationSettingType(notification_type), + ) + + for paragraph in paragraphs: + email.add_paragraph(paragraph) + + email.send() + return Response({"detail": "Emailen ble sendt"}, status=status.HTTP_201_CREATED) + except Exception as e: + print(e) + return Response( + {"detail": "Det oppsto en feil under sending av email"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/app/feedback/__init__.py b/app/feedback/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/feedback/admin.py b/app/feedback/admin.py new file mode 100644 index 00000000..3f450ab7 --- /dev/null +++ b/app/feedback/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from app.feedback import models + +admin.site.register(models.Bug) diff --git a/app/feedback/app.py b/app/feedback/app.py new file mode 100644 index 00000000..c310f0d3 --- /dev/null +++ b/app/feedback/app.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FeedbackConfig(AppConfig): + name = "app.feedback" diff --git a/app/feedback/enums.py b/app/feedback/enums.py new file mode 100644 index 00000000..5adae939 --- /dev/null +++ b/app/feedback/enums.py @@ -0,0 +1,8 @@ +from django.db import models + + +class Status(models.TextChoices): + OPEN = "OPEN", "Åpen" + CLOSED = "CLOSED", "Lukket" + IN_PROGRESS = "IN_PROGRESS", "Under arbeid" + REJECTED = "REJECTED", "Avvist" diff --git a/app/feedback/exceptions.py b/app/feedback/exceptions.py new file mode 100644 index 00000000..e69de29b diff --git a/app/feedback/factories/__init__.py b/app/feedback/factories/__init__.py new file mode 100644 index 00000000..6dba0161 --- /dev/null +++ b/app/feedback/factories/__init__.py @@ -0,0 +1,2 @@ +from app.feedback.factories.bug_factory import BugFactory +from app.feedback.factories.idea_factory import IdeaFactory diff --git a/app/feedback/factories/bug_factory.py b/app/feedback/factories/bug_factory.py new file mode 100644 index 00000000..4ff95a21 --- /dev/null +++ b/app/feedback/factories/bug_factory.py @@ -0,0 +1,13 @@ +import factory +from factory.django import DjangoModelFactory + +from app.content.factories import UserFactory +from app.feedback.models.bug import Bug + + +class BugFactory(DjangoModelFactory): + class Meta: + model = Bug + + title = factory.Sequence(lambda n: f"Bug{n}") + author = factory.SubFactory(UserFactory) diff --git a/app/feedback/factories/idea_factory.py b/app/feedback/factories/idea_factory.py new file mode 100644 index 00000000..c923c471 --- /dev/null +++ b/app/feedback/factories/idea_factory.py @@ -0,0 +1,13 @@ +import factory +from factory.django import DjangoModelFactory + +from app.content.factories import UserFactory +from app.feedback.models.idea import Idea + + +class IdeaFactory(DjangoModelFactory): + class Meta: + model = Idea + + title = factory.Sequence(lambda n: f"Idea{n}") + author = factory.SubFactory(UserFactory) diff --git a/app/feedback/filters/__init__.py b/app/feedback/filters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/feedback/migrations/0001_initial.py b/app/feedback/migrations/0001_initial.py new file mode 100644 index 00000000..ff17edca --- /dev/null +++ b/app/feedback/migrations/0001_initial.py @@ -0,0 +1,112 @@ +# Generated by Django 4.2.16 on 2024-10-11 15:33 + +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), + ("contenttypes", "0002_remove_content_type_name"), + ] + + operations = [ + migrations.CreateModel( + name="Feedback", + 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=100)), + ("description", models.TextField(blank=True, default="")), + ( + "status", + models.CharField( + default="OPEN", + max_length=20, + verbose_name=[ + ("OPEN", "Åpen"), + ("CLOSED", "Lukket"), + ("IN_PROGRESS", "Under arbeid"), + ("REJECTED", "Avvist"), + ], + ), + ), + ( + "author", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "polymorphic_ctype", + models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_%(app_label)s.%(class)s_set+", + to="contenttypes.contenttype", + ), + ), + ], + options={ + "ordering": ("created_at",), + }, + ), + migrations.CreateModel( + name="Bug", + fields=[ + ( + "feedback_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="feedback.feedback", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("feedback.feedback",), + ), + migrations.CreateModel( + name="Idea", + fields=[ + ( + "feedback_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="feedback.feedback", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("feedback.feedback",), + ), + ] diff --git a/app/feedback/migrations/__init__.py b/app/feedback/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/feedback/mixins.py b/app/feedback/mixins.py new file mode 100644 index 00000000..e69de29b diff --git a/app/feedback/models/__init__.py b/app/feedback/models/__init__.py new file mode 100644 index 00000000..604302af --- /dev/null +++ b/app/feedback/models/__init__.py @@ -0,0 +1,3 @@ +from app.feedback.models.bug import Bug +from app.feedback.models.feedback import Feedback +from app.feedback.models.idea import Idea diff --git a/app/feedback/models/bug.py b/app/feedback/models/bug.py new file mode 100644 index 00000000..39962659 --- /dev/null +++ b/app/feedback/models/bug.py @@ -0,0 +1,5 @@ +from app.feedback.models.feedback import Feedback + + +class Bug(Feedback): + pass diff --git a/app/feedback/models/feedback.py b/app/feedback/models/feedback.py new file mode 100644 index 00000000..13d698db --- /dev/null +++ b/app/feedback/models/feedback.py @@ -0,0 +1,76 @@ +from django.db import models + +from polymorphic.models import PolymorphicModel + +from app.common.enums import AdminGroup, Groups +from app.common.permissions import BasePermissionModel, check_has_access +from app.content.models.user import User +from app.feedback.enums import Status +from app.util.models import BaseModel + + +class Feedback(BaseModel, BasePermissionModel, PolymorphicModel): + + read_access = (Groups.TIHLDE,) + write_access = (Groups.TIHLDE,) + + title = models.CharField(max_length=100) + description = models.TextField(default="", blank=True) + + author = models.ForeignKey( + User, blank=True, null=True, default=None, on_delete=models.SET_NULL + ) + status = models.CharField(Status.choices, default=Status.OPEN, max_length=20) + + def __str__(self): + return f"{self.title} - {self.status}" + + class Meta: + ordering = ("created_at",) + + @classmethod + def has_read_permission(cls, request): + return super().has_read_permission(request) + + @classmethod + def has_write_permission(cls, request): + return super().has_write_permission(request) + + @classmethod + def has_retrieve_permission(cls, request): + return cls.has_read_permission(request) + + @classmethod + def has_create_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_update_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_destroy_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_list_permission(cls, request): + return cls.has_read_permission(request) + + def has_object_read_permission(self, request): + return self.has_read_permission(request) + + def has_object_write_permission(self, request): + return self.has_write_permission(request) + + def has_object_retrieve_permission(self, request): + return self.has_object_read_permission(request) + + def has_object_update_permission(self, request): + return ( + check_has_access([AdminGroup.INDEX], request) or self.author == request.user + ) + + def has_object_destroy_permission(self, request): + return ( + check_has_access([AdminGroup.INDEX], request) or self.author == request.user + ) diff --git a/app/feedback/models/idea.py b/app/feedback/models/idea.py new file mode 100644 index 00000000..42ecb651 --- /dev/null +++ b/app/feedback/models/idea.py @@ -0,0 +1,5 @@ +from app.feedback.models.feedback import Feedback + + +class Idea(Feedback): + pass diff --git a/app/feedback/serializers/__init__.py b/app/feedback/serializers/__init__.py new file mode 100644 index 00000000..25f74a27 --- /dev/null +++ b/app/feedback/serializers/__init__.py @@ -0,0 +1,3 @@ +from app.feedback.serializers.bug import BugListSerializer +from app.feedback.serializers.idea import IdeaListSerializer +from app.feedback.serializers.feedback import FeedbackListPolymorphicSerializer diff --git a/app/feedback/serializers/bug.py b/app/feedback/serializers/bug.py new file mode 100644 index 00000000..85046256 --- /dev/null +++ b/app/feedback/serializers/bug.py @@ -0,0 +1,17 @@ +from app.common.serializers import BaseModelSerializer +from app.content.serializers.user import SimpleUserSerializer +from app.feedback.models.bug import Bug + + +class BugListSerializer(BaseModelSerializer): + author = SimpleUserSerializer(read_only=True) + + class Meta: + model = Bug + fields = ( + "id", + "title", + "status", + "created_at", + "author", + ) diff --git a/app/feedback/serializers/feedback.py b/app/feedback/serializers/feedback.py new file mode 100644 index 00000000..588fc680 --- /dev/null +++ b/app/feedback/serializers/feedback.py @@ -0,0 +1,80 @@ +from rest_polymorphic.serializers import PolymorphicSerializer + +from app.common.serializers import BaseModelSerializer +from app.feedback.models import Bug, Feedback, Idea +from app.feedback.serializers import BugListSerializer, IdeaListSerializer + + +class FeedbackListPolymorphicSerializer(PolymorphicSerializer, BaseModelSerializer): + resource_type_field_name = "feedback_type" + + model_serializer_mapping = { + Bug: BugListSerializer, + Idea: IdeaListSerializer, + } + + class Meta: + model = Feedback + fields = ( + "id", + "title", + "status", + "created_at", + "author", + ) + + +class IdeaCreateSerializer(BaseModelSerializer): + class Meta: + model = Idea + fields = ( + "title", + "description", + ) + + def create(self, validated_data): + user = self.context["request"].user + validated_data["author"] = user + + return super().create(validated_data) + + +class BugCreateSerializer(BaseModelSerializer): + class Meta: + model = Bug + fields = ( + "title", + "description", + ) + + def create(self, validated_data): + user = self.context["request"].user + validated_data["author"] = user + + return super().create(validated_data) + + +class IdeaUpdateSerializer(BaseModelSerializer): + class Meta: + model = Feedback + fields = ( + "title", + "description", + "status", + ) + + def update(self, instance, validated_data): + return super().update(instance, validated_data) + + +class BugUpdateSerializer(BaseModelSerializer): + class Meta: + model = Feedback + fields = ( + "title", + "description", + "status", + ) + + def update(self, instance, validated_data): + return super().update(instance, validated_data) diff --git a/app/feedback/serializers/idea.py b/app/feedback/serializers/idea.py new file mode 100644 index 00000000..6b415eba --- /dev/null +++ b/app/feedback/serializers/idea.py @@ -0,0 +1,17 @@ +from app.common.serializers import BaseModelSerializer +from app.content.serializers.user import SimpleUserSerializer +from app.feedback.models.idea import Idea + + +class IdeaListSerializer(BaseModelSerializer): + author = SimpleUserSerializer(read_only=True) + + class Meta: + model = Idea + fields = ( + "id", + "title", + "status", + "created_at", + "author", + ) diff --git a/app/feedback/tasks/__init__.py b/app/feedback/tasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/feedback/tests/__init__.py b/app/feedback/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/feedback/urls.py b/app/feedback/urls.py new file mode 100644 index 00000000..c8922e8b --- /dev/null +++ b/app/feedback/urls.py @@ -0,0 +1,8 @@ +from django.urls import include, re_path +from rest_framework import routers + +from app.feedback.views.feedback import FeedbackViewSet + +router = routers.DefaultRouter() +router.register("feedbacks", FeedbackViewSet) +urlpatterns = [re_path(r"", include(router.urls))] diff --git a/app/feedback/util/__init__.py b/app/feedback/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/feedback/views/__init__.py b/app/feedback/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/feedback/views/feedback.py b/app/feedback/views/feedback.py new file mode 100644 index 00000000..0ccbc653 --- /dev/null +++ b/app/feedback/views/feedback.py @@ -0,0 +1,81 @@ +from rest_framework import status +from rest_framework.response import Response + +from app.common.pagination import BasePagination +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet +from app.feedback.models.feedback import Feedback +from app.feedback.serializers.feedback import ( + BugCreateSerializer, + BugUpdateSerializer, + FeedbackListPolymorphicSerializer, + IdeaCreateSerializer, + IdeaUpdateSerializer, +) + + +class FeedbackViewSet(BaseViewSet): + serializer_class = FeedbackListPolymorphicSerializer + queryset = Feedback.objects.select_related("author") + pagination_class = BasePagination + permission_classes = [BasicViewPermission] + + def create(self, request, *_args, **_kwargs): + data = request.data + + feedback_type = data.get("feedback_type") + + if feedback_type == "Idea": + serializer = IdeaCreateSerializer(data=data, context={"request": request}) + + elif feedback_type == "Bug": + serializer = BugCreateSerializer(data=data, context={"request": request}) + + else: + return Response( + {"detail": "Ugyldig type tilbakemelding"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if serializer.is_valid(): + feedback = self.perform_create(serializer) + data = FeedbackListPolymorphicSerializer(feedback).data + return Response( + data, + status=status.HTTP_201_CREATED, + ) + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + + def update(self, request, *_args, **_kwargs): + instance = self.get_object() + data = request.data + + feedback_type = instance.feedback_type + + if feedback_type == "Idea": + serializer = IdeaUpdateSerializer(instance, data=data) + + elif feedback_type == "Bug": + serializer = BugUpdateSerializer(instance, data=data) + + if serializer.is_valid(): + super().perform_update(serializer) + return Response( + serializer.data, + status=status.HTTP_200_OK, + ) + + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, *_args, **_kwargs): + super().destroy(request, *_args, **_kwargs) + return Response( + {"detail": "Tilbakemeldingen ble slettet"}, + status=status.HTTP_200_OK, + ) diff --git a/app/forms/migrations/0014_form_description.py b/app/forms/migrations/0014_form_description.py new file mode 100644 index 00000000..3cecfc4a --- /dev/null +++ b/app/forms/migrations/0014_form_description.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-01 06:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("forms", "0013_alter_field_type"), + ] + + operations = [ + migrations.AddField( + model_name="form", + name="description", + field=models.TextField(blank=True, default=""), + ), + ] diff --git a/app/forms/models/forms.py b/app/forms/models/forms.py index 08e2ff97..642654ec 100644 --- a/app/forms/models/forms.py +++ b/app/forms/models/forms.py @@ -24,6 +24,7 @@ class Form(PolymorphicModel, BasePermissionModel): write_access = (*AdminGroup.admin(), AdminGroup.NOK) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) title = models.CharField(max_length=400) + description = models.TextField(blank=True, default="") template = models.BooleanField(default=False) viewer_has_answered = None diff --git a/app/forms/serializers/forms.py b/app/forms/serializers/forms.py index 8d88a2fb..8944a112 100644 --- a/app/forms/serializers/forms.py +++ b/app/forms/serializers/forms.py @@ -51,6 +51,7 @@ class Meta: fields = ( "id", "title", + "description", "fields", "template", "resource_type", diff --git a/app/settings.py b/app/settings.py index d7a79e4d..26e8c1fe 100644 --- a/app/settings.py +++ b/app/settings.py @@ -106,6 +106,7 @@ "app.payment", "app.kontres", "app.emoji", + "app.feedback", "app.codex", ] diff --git a/app/tests/conftest.py b/app/tests/conftest.py index e2efd030..37961992 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -32,6 +32,7 @@ EventReactionFactory, NewsReactionFactory, ) +from app.feedback.factories import BugFactory, IdeaFactory from app.forms.tests.form_factories import FormFactory, SubmissionFactory from app.group.factories import GroupFactory, MembershipFactory from app.group.factories.fine_factory import FineFactory @@ -311,3 +312,13 @@ def codex_event(): @pytest.fixture() def codex_event_registration(): return CodexEventRegistrationFactory() + + +@pytest.fixture() +def feedback_bug(): + return BugFactory() + + +@pytest.fixture() +def feedback_idea(): + return IdeaFactory() diff --git a/app/tests/index/__init__.py b/app/tests/index/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/tests/index/test_feedback_integration.py b/app/tests/index/test_feedback_integration.py new file mode 100644 index 00000000..890c8ad4 --- /dev/null +++ b/app/tests/index/test_feedback_integration.py @@ -0,0 +1,174 @@ +from rest_framework import status + +import pytest + +from app.util.test_utils import get_api_client + +FEEDBACK_BASE_URL = "/feedbacks/" + + +def get_data(type): + return { + "feedback_type": type, + "title": "This is a type title", + "description": f"This is a {type} report", + } + + +@pytest.mark.django_db +def test_list_feedback_with_both_bug_and_idea_as_member( + member, feedback_bug, feedback_idea +): + """All members should be able to list all types of feedbacks.""" + + url = FEEDBACK_BASE_URL + client = get_api_client(member) + response = client.get(url) + + data = response.data + results = data["results"] + bug_type = list(filter(lambda x: "Bug" == x["feedback_type"], results)) + idea_type = list(filter(lambda x: "Idea" == x["feedback_type"], results)) + + assert response.status_code == status.HTTP_200_OK + assert data["count"] == 2 + assert bug_type + assert idea_type + + +@pytest.mark.django_db +def test_list_feedback_as_anonymous_user(default_client): + """Non TIHLDE users should not be able to list feedbacks""" + + url = FEEDBACK_BASE_URL + response = default_client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_create_feedback_with_both_bug_and_idea_as_member(member, type): + """All members should be able to create a bug and a idea feedback""" + + url = FEEDBACK_BASE_URL + client = get_api_client(member) + data = get_data(type) + response = client.post(url, data=data) + data = response.data + + assert response.status_code == status.HTTP_201_CREATED + assert data["feedback_type"] == type + + +@pytest.mark.django_db +def test_create_feedback_with_wrong_type_as_member(member): + """No members should be able to create feedback of another type than bug and idea""" + + url = FEEDBACK_BASE_URL + client = get_api_client(member) + data = get_data("wrong_type") + response = client.post(url, data=data) + data = response.data + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_create_feedback_as_anonymous_user(default_client, type): + """Non TIHLDE users should not be able to create feedbacks""" + + url = FEEDBACK_BASE_URL + data = get_data(type) + response = default_client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_update_feedback_with_both_bug_and_idea_as_member(member, type): + """All members should be able to update their own bug and idea feedback""" + + url = FEEDBACK_BASE_URL + data = get_data(type) + client = get_api_client(member) + response = client.post(url, data=data) + data = response.data + + assert response.status_code == status.HTTP_201_CREATED + assert data["feedback_type"] == type + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_destroy_feedback_as_index_user(index_member, type): + """An Index user should be able to delete other members feedback""" + + url = FEEDBACK_BASE_URL + data = get_data(type) + client = get_api_client(user=index_member) + + initial_response = client.post(url, data=data) + + feedback_id = initial_response.data["id"] + + url = f"{FEEDBACK_BASE_URL}{feedback_id}/" + + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_destroy_your_own_feedback_as_member(member, type): + """A user should be able to delete their own feedback as a member""" + + url = FEEDBACK_BASE_URL + data = get_data(type) + client = get_api_client(user=member) + + initial_response = client.post(url, data=data) + + print(initial_response.data) + print(f"Author: {initial_response.data['author']}") + + feedback_id = initial_response.data["id"] + + url = f"{FEEDBACK_BASE_URL}{feedback_id}/" + + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_destroy_feedback_as_anonymous_user(default_client, type): + """Non TIHLDE users should not be able to delete feedbacks""" + + url = FEEDBACK_BASE_URL + data = get_data(type) + response = default_client.delete(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/app/urls.py b/app/urls.py index d4fb286b..301f2342 100644 --- a/app/urls.py +++ b/app/urls.py @@ -55,4 +55,5 @@ path("kontres/", include("app.kontres.urls")), path("emojis/", include("app.emoji.urls")), path("codex/", include("app.codex.urls")), + path("", include("app.feedback.urls")), ] diff --git a/requirements.txt b/requirements.txt index f62db6e7..4f6210f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ wheel mysqlclient == 2.1.1 sentry-sdk == 2.8.0 celery == 5.4.0 -azure-storage-blob == 12.13.1 +azure-storage-blob == 12.23.1 python-dotenv ~= 1.0.1 gunicorn == 23.0.0 uvicorn == 0.30.6 @@ -16,7 +16,7 @@ pyjwt ~= 2.9.0 # Django # ------------------------------------------------------------------------------ -Django == 4.2.16 +Django == 5.1.1 django-enumchoicefield == 3.0.1 django-filter == 24.3 django-ordered-model ~= 3.7.4 @@ -39,7 +39,7 @@ django-mptt == 0.16.0 # Code quality # ------------------------------------------------------------------------------ pylint -black == 24.3.0 +black == 24.8.0 isort flake8 flake8-django diff --git a/scripts/app.py b/scripts/app.py index 0987e993..48532246 100644 --- a/scripts/app.py +++ b/scripts/app.py @@ -20,7 +20,6 @@ def create_app(): 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") @@ -31,8 +30,19 @@ def create_app(): init_dir(app_path, "views") init_dir(app_path, "serializers") + # create tests directory + TESTS_PATH = os.path.join(BASE_PATH, "tests") + + if app_name in os.listdir(TESTS_PATH): + print(f"App '{app_name}' already exists in 'tests'.") + else: + path = os.path.join(TESTS_PATH, app_name) + os.makedirs(os.path.join(TESTS_PATH, app_name), exist_ok=True) + init_app_file(path, "__init__.py") + # Create the app's files init_app_file(app_path, "__init__.py") + init_app_file(app_path, "admin.py") config_content = f"""from django.apps import AppConfig From 246ad2a1d589f4994171ad887b7fa67ec24bdbdf Mon Sep 17 00:00:00 2001 From: Emil Johnsen <111747340+EmilJohns1@users.noreply.github.com> Date: Mon, 4 Nov 2024 22:08:21 +0100 Subject: [PATCH 4/4] Upload files (#906) * Create files app to manage user uploaded files Signed-off-by: Tmpecho * created serializers for file and gallery * Move file upload endpoint from content/ to files/, create file and gallery views, add file and gallery model methods Signed-off-by: Tmpecho * updating progress, needed to rename 'Gallery' to 'UserGallery' as a Gallery model already exists * finished mvp for file project * updating progress * mvp for file uploading/deletion * forgot changelog, oopsie * fixed error in permissions from allowing non-admins to delete files, fixed 'security threat' * Minor fixes to file serializer Signed-off-by: Tmpecho * Fix whitespace Signed-off-by: Tmpecho * Add custom exeption with mixins for files class Signed-off-by: Tmpecho * Update custom file exceptions, set max gallery size as global constant, throw custom exceptions Signed-off-by: Tmpecho * create test for creating file when not having a gallery, format * removed duplicate method * small refactoring and removed 'url' from file model, changed it to 'OptionalFile' * fix lint --------- Signed-off-by: Tmpecho Co-authored-by: Tmpecho Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Co-authored-by: Johannes Aamot-Skeidsvoll <82368148+Tmpecho@users.noreply.github.com> --- CHANGELOG.md | 3 + app/common/serializers.py | 2 + app/communication/models/mail.py | 6 +- app/constants.py | 2 + app/content/urls.py | 4 - app/content/util/event_utils.py | 2 +- app/content/views/__init__.py | 1 - app/content/views/accept_form.py | 8 +- app/content/views/user_bio.py | 4 +- app/content/views/user_calendar_events.py | 2 +- app/files/__init__.py | 0 app/files/admin.py | 6 + app/files/app.py | 5 + app/files/enums.py | 0 app/files/exceptions.py | 22 ++ app/files/factories/__init__.py | 0 app/files/filters/__init__.py | 0 app/files/migrations/0001_initial.py | 68 +++++ .../0002_remove_file_url_file_file.py | 22 ++ app/files/migrations/__init__.py | 0 app/files/mixins.py | 17 ++ app/files/models/__init__.py | 2 + app/files/models/file.py | 68 +++++ app/files/models/user_gallery.py | 77 +++++ app/files/serializers/__init__.py | 6 + app/files/serializers/file.py | 68 +++++ app/files/serializers/user_gallery.py | 22 ++ app/files/tasks/__init__.py | 0 app/files/tests/__init__.py | 0 app/files/urls.py | 17 ++ app/files/util/__init__.py | 0 app/files/views/__init__.py | 2 + app/files/views/file.py | 56 ++++ app/{content => files}/views/upload.py | 0 app/files/views/user_gallery.py | 41 +++ app/group/views/group.py | 3 +- app/group/views/membership.py | 16 +- app/settings.py | 1 + app/tests/conftest.py | 13 + app/tests/files/__init__.py | 0 app/tests/files/test_file_integration.py | 272 ++++++++++++++++++ .../files/test_user_gallery_integration.py | 52 ++++ app/urls.py | 1 + app/util/models.py | 9 + app/util/test_utils.py | 33 +++ 45 files changed, 909 insertions(+), 24 deletions(-) create mode 100644 app/files/__init__.py create mode 100644 app/files/admin.py create mode 100644 app/files/app.py create mode 100644 app/files/enums.py create mode 100644 app/files/exceptions.py create mode 100644 app/files/factories/__init__.py create mode 100644 app/files/filters/__init__.py create mode 100644 app/files/migrations/0001_initial.py create mode 100644 app/files/migrations/0002_remove_file_url_file_file.py create mode 100644 app/files/migrations/__init__.py create mode 100644 app/files/mixins.py create mode 100644 app/files/models/__init__.py create mode 100644 app/files/models/file.py create mode 100644 app/files/models/user_gallery.py create mode 100644 app/files/serializers/__init__.py create mode 100644 app/files/serializers/file.py create mode 100644 app/files/serializers/user_gallery.py create mode 100644 app/files/tasks/__init__.py create mode 100644 app/files/tests/__init__.py create mode 100644 app/files/urls.py create mode 100644 app/files/util/__init__.py create mode 100644 app/files/views/__init__.py create mode 100644 app/files/views/file.py rename app/{content => files}/views/upload.py (100%) create mode 100644 app/files/views/user_gallery.py create mode 100644 app/tests/files/__init__.py create mode 100644 app/tests/files/test_file_integration.py create mode 100644 app/tests/files/test_user_gallery_integration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb3d57b..83fb1c9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ - 🎨**Overordnet**. Endret variabel og funksjonsnavn til å følge konvensjoner og andre små endringer. - ✨ **Filtrering**. Admin kan nå filtere deltakere av et arrangement på studie, studieår, om deltakere har allergier, (om deltakere godtar å bli tatt bilde av, om deltakere har ankommet), i tillegg til søk på fornavn og etternavn. +- ✨ **Filopplasting**. Det er nå mulig for admin brukere å laste opp- og slette filer. +- ✨ **Mail endepunkt**. Det er nå laget et endepunkt for å sende mailer. + ## Versjon 2024.10.11 - ✨ **Tilbakemelding-funksjon**. Man kan nå opprette tilbakemeldinger for bugs og idé. - 🦟 **Påmelding**. Det vil nå ikke være mulig med flere påmeldinger på et arrangement enn maksgrensen. diff --git a/app/common/serializers.py b/app/common/serializers.py index 8f8a386e..f91f26e6 100644 --- a/app/common/serializers.py +++ b/app/common/serializers.py @@ -7,4 +7,6 @@ class BaseModelSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): if hasattr(instance, "image") and "image" in validated_data: replace_file(instance.image, validated_data.get("image", None)) + if hasattr(instance, "file") and "file" in validated_data: + replace_file(instance.file, validated_data.get("file", None)) return super().update(instance, validated_data) diff --git a/app/communication/models/mail.py b/app/communication/models/mail.py index 692fdd00..6a4e13ea 100644 --- a/app/communication/models/mail.py +++ b/app/communication/models/mail.py @@ -40,5 +40,7 @@ def send(self, connection): return is_success def __str__(self): - return (f"\"{self.subject}\", to {self.users.all()[0] if self.users.count() == 1 else f'{self.users.count()} users'}, " - f"{'sent' if self.sent else 'eta'} {self.eta}") + return ( + f"\"{self.subject}\", to {self.users.all()[0] if self.users.count() == 1 else f'{self.users.count()} users'}, " + f"{'sent' if self.sent else 'eta'} {self.eta}" + ) diff --git a/app/constants.py b/app/constants.py index 638b4d67..773570e1 100644 --- a/app/constants.py +++ b/app/constants.py @@ -20,4 +20,6 @@ SLACK_BEDPRES_OG_KURS_CHANNEL_ID = "C01DCSJ8X2Q" SLACK_ARRANGEMENTER_CHANNEL_ID = "C01LFEFJFV3" +MAX_GALLERY_SIZE = 50 + # TODO: Create api-urls as constants which then can be used in for example tests and urls.py files diff --git a/app/content/urls.py b/app/content/urls.py index 26911fd6..536f8fb7 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -18,10 +18,8 @@ UserCalendarEvents, UserViewSet, accept_form, - delete, register_with_feide, send_email, - upload, ) router = routers.DefaultRouter() @@ -53,9 +51,7 @@ urlpatterns = [ re_path(r"", include(router.urls)), path("accept-form/", accept_form), - path("upload/", upload), path("send-email/", send_email), - path("delete-file///", delete), path("feide/", register_with_feide), re_path(r"users/(?P[^/.]+)/events.ics", UserCalendarEvents()), ] diff --git a/app/content/util/event_utils.py b/app/content/util/event_utils.py index 304431a7..ad423954 100644 --- a/app/content/util/event_utils.py +++ b/app/content/util/event_utils.py @@ -3,9 +3,9 @@ from app.content.exceptions import RefundFailedError from app.payment.tasks import check_if_has_paid from app.payment.util.payment_utils import ( + check_access_token, initiate_payment, refund_payment, - check_access_token ) diff --git a/app/content/views/__init__.py b/app/content/views/__init__.py index 5a9a5c3f..8cdc2bdf 100644 --- a/app/content/views/__init__.py +++ b/app/content/views/__init__.py @@ -8,7 +8,6 @@ from app.content.views.news import NewsViewSet from app.content.views.page import PageViewSet from app.content.views.short_link import ShortLinkViewSet -from app.content.views.upload import upload, delete from app.content.views.strike import StrikeViewSet from app.content.views.toddel import ToddelViewSet from app.content.views.qr_code import QRCodeViewSet diff --git a/app/content/views/accept_form.py b/app/content/views/accept_form.py index 44e4fe6f..545e1d35 100644 --- a/app/content/views/accept_form.py +++ b/app/content/views/accept_form.py @@ -23,9 +23,11 @@ def accept_form(request): if len(types) == 1 and types[0].lower() == "annonse" else MAIL_NOK_LEADER ) - title = (f"{body['info']['bedrift']} vil ha {', '.join(types[:-1])}" - f"{' og ' if len(types) > 1 else ''}{', '.join(types[-1:])}, " - f"{', '.join(times[:-1])}{' og ' if len(times) > 1 else ''}{', '.join(times[-1:])}") + title = ( + f"{body['info']['bedrift']} vil ha {', '.join(types[:-1])}" + f"{' og ' if len(types) > 1 else ''}{', '.join(types[-1:])}, " + f"{', '.join(times[:-1])}{' og ' if len(times) > 1 else ''}{', '.join(times[-1:])}" + ) is_success = send_html_email( to_mails=[to_mail], diff --git a/app/content/views/user_bio.py b/app/content/views/user_bio.py index 5eeb1081..b4c0902d 100644 --- a/app/content/views/user_bio.py +++ b/app/content/views/user_bio.py @@ -49,6 +49,4 @@ def update(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): super().destroy(request, *args, **kwargs) - return Response( - {"detail": "Brukerbio ble slettet"}, status=status.HTTP_200_OK - ) + return Response({"detail": "Brukerbio ble slettet"}, status=status.HTTP_200_OK) diff --git a/app/content/views/user_calendar_events.py b/app/content/views/user_calendar_events.py index 3c26c72f..59e5d499 100644 --- a/app/content/views/user_calendar_events.py +++ b/app/content/views/user_calendar_events.py @@ -23,7 +23,7 @@ def __call__(self, request, *args, **kwargs): return JsonResponse( { "detail": "Denne brukeren har skrudd av offentlig deling av påmeldinger til arrangementer. " - "Du kan derfor ikke hente ut brukerens arrangementer som .ics-fil" + "Du kan derfor ikke hente ut brukerens arrangementer som .ics-fil" }, status=status.HTTP_403_FORBIDDEN, ) diff --git a/app/files/__init__.py b/app/files/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/files/admin.py b/app/files/admin.py new file mode 100644 index 00000000..b4aaec23 --- /dev/null +++ b/app/files/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from app.files import models + +admin.site.register(models.UserGallery) +admin.site.register(models.File) diff --git a/app/files/app.py b/app/files/app.py new file mode 100644 index 00000000..2449379c --- /dev/null +++ b/app/files/app.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FilesConfig(AppConfig): + name = "app.files" diff --git a/app/files/enums.py b/app/files/enums.py new file mode 100644 index 00000000..e69de29b diff --git a/app/files/exceptions.py b/app/files/exceptions.py new file mode 100644 index 00000000..83490cc2 --- /dev/null +++ b/app/files/exceptions.py @@ -0,0 +1,22 @@ +from rest_framework import status +from rest_framework.exceptions import APIException, ValidationError + +from app.constants import MAX_GALLERY_SIZE + + +class APINoGalleryFoundForUser(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Ingen galleri ble funnet for brukeren." + + +class NoGalleryFoundForUser(ValidationError): + default_detail = "Ingen galleri ble funnet for brukeren." + + +class APIGalleryIsFull(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = f"Galleriet er fullt med {MAX_GALLERY_SIZE} filer." + + +class GalleryIsFull(ValidationError): + default_detail = f"Galleriet er fullt med {MAX_GALLERY_SIZE} filer." diff --git a/app/files/factories/__init__.py b/app/files/factories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/files/filters/__init__.py b/app/files/filters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/files/migrations/0001_initial.py b/app/files/migrations/0001_initial.py new file mode 100644 index 00000000..60e432dc --- /dev/null +++ b/app/files/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 4.2.16 on 2024-10-07 16:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserGallery", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "author", + models.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + related_name="user_galleries", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="File", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=80)), + ("url", models.URLField()), + ("description", models.TextField(blank=True)), + ( + "gallery", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="files", + to="files.usergallery", + ), + ), + ], + ), + ] diff --git a/app/files/migrations/0002_remove_file_url_file_file.py b/app/files/migrations/0002_remove_file_url_file_file.py new file mode 100644 index 00000000..5f5ac0f3 --- /dev/null +++ b/app/files/migrations/0002_remove_file_url_file_file.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.1 on 2024-10-31 15:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="file", + name="url", + ), + migrations.AddField( + model_name="file", + name="file", + field=models.URLField(blank=True, max_length=600, null=True), + ), + ] diff --git a/app/files/migrations/__init__.py b/app/files/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/files/mixins.py b/app/files/mixins.py new file mode 100644 index 00000000..1e36f0e4 --- /dev/null +++ b/app/files/mixins.py @@ -0,0 +1,17 @@ +from app.files.exceptions import ( + APIGalleryIsFull, + APINoGalleryFoundForUser, + GalleryIsFull, + NoGalleryFoundForUser, +) +from app.util.mixins import APIErrorsMixin + + +class FileErrorMixin(APIErrorsMixin): + @property + def expected_exceptions(self): + return { + **super().expected_exceptions, + NoGalleryFoundForUser: APINoGalleryFoundForUser, + GalleryIsFull: APIGalleryIsFull, + } diff --git a/app/files/models/__init__.py b/app/files/models/__init__.py new file mode 100644 index 00000000..8952713d --- /dev/null +++ b/app/files/models/__init__.py @@ -0,0 +1,2 @@ +from app.files.models.user_gallery import UserGallery +from app.files.models.file import File diff --git a/app/files/models/file.py b/app/files/models/file.py new file mode 100644 index 00000000..8f813bf2 --- /dev/null +++ b/app/files/models/file.py @@ -0,0 +1,68 @@ +from django.db import models +from django.db.models import PROTECT + +from app.common.enums import AdminGroup, Groups +from app.common.permissions import BasePermissionModel, check_has_access +from app.files.models.user_gallery import UserGallery +from app.util.models import BaseModel, OptionalFile + + +class File(BaseModel, BasePermissionModel, OptionalFile): + read_access = AdminGroup.admin() + write_access = AdminGroup.admin() + + title = models.CharField(max_length=80) + + description = models.TextField(blank=True) + gallery = models.ForeignKey( + UserGallery, on_delete=PROTECT, related_name="files", blank=False + ) + + class Meta: + pass + + def __str__(self): + return self.title + + @classmethod + def has_read_permission(cls, request): + return super().has_read_permission(request) + + @classmethod + def has_write_permission(cls, request): + return check_has_access(Groups.TIHLDE, request) + + @classmethod + def has_retrieve_permission(cls, request): + return cls.has_read_permission(request) + + @classmethod + def has_create_permission(cls, request): + return check_has_access(cls.write_access, request) + + @classmethod + def has_update_permission(cls, request): + return check_has_access(cls.write_access, request) + + @classmethod + def has_destroy_permission(cls, request): + return check_has_access(Groups.TIHLDE, request) + + @classmethod + def has_list_permission(cls, request): + return cls.has_read_permission(request) + + def has_object_read_permission(self, request): + return self.has_read_permission(request) + + def has_object_write_permission(self, request): + return self.has_write_permission(request) + + def has_object_retrieve_permission(self, request): + return self.has_object_read_permission(request) + + def has_object_update_permission(self, request): + return self.gallery.author == request.user + + def has_object_destroy_permission(self, request): + return self.gallery.author == request.user diff --git a/app/files/models/user_gallery.py b/app/files/models/user_gallery.py new file mode 100644 index 00000000..238cc4c7 --- /dev/null +++ b/app/files/models/user_gallery.py @@ -0,0 +1,77 @@ +from django.db import models +from django.db.models import PROTECT + +from app.common.enums import AdminGroup +from app.common.permissions import BasePermissionModel +from app.content.models.user import User +from app.util.models import BaseModel + + +class UserGallery(BaseModel, BasePermissionModel): + read_access = AdminGroup.admin() + write_access = AdminGroup.admin() + + author = models.OneToOneField( + User, on_delete=PROTECT, related_name="user_galleries" + ) + + class Meta: + pass + + def __str__(self): + return f"Gallery by {self.author.first_name} {self.author.last_name}" + + @classmethod + def has_read_permission(cls, request): + return super().has_read_permission(request) + + @classmethod + def has_write_permission(cls, request): + return super().has_write_permission(request) + + @classmethod + def has_retrieve_permission(cls, request): + return cls.has_read_permission(request) + + @classmethod + def has_create_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_update_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_destroy_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_list_permission(cls, request): + return cls.has_read_permission(request) + + def has_object_read_permission(self, request): + return self.has_read_permission(request) + + def has_object_write_permission(self, request): + return self.has_write_permission(request) + + def has_object_retrieve_permission(self, request): + return self.has_object_read_permission(request) + + def has_object_update_permission(self, request): + return self.author == request.user + + def has_object_destroy_permission(self, request): + return self.author == request.user + + @classmethod + def get_all_files(cls, user): + return cls.objects.get(author=user).files.all() + + @classmethod + def has_gallery(cls, user): + return cls.objects.filter(author=user).exists() + + @classmethod + def create_gallery(cls, user): + return cls.objects.create(author=user) diff --git a/app/files/serializers/__init__.py b/app/files/serializers/__init__.py new file mode 100644 index 00000000..e44eb26c --- /dev/null +++ b/app/files/serializers/__init__.py @@ -0,0 +1,6 @@ +from app.files.serializers.user_gallery import UserGallerySerializer +from app.files.serializers.file import ( + FileSerializer, + CreateFileSerializer, + DeleteFileSerializer, +) diff --git a/app/files/serializers/file.py b/app/files/serializers/file.py new file mode 100644 index 00000000..d12dc675 --- /dev/null +++ b/app/files/serializers/file.py @@ -0,0 +1,68 @@ +from app.common.azure_file_handler import AzureFileHandler +from app.common.serializers import BaseModelSerializer +from app.constants import MAX_GALLERY_SIZE +from app.files.exceptions import GalleryIsFull, NoGalleryFoundForUser +from app.files.models import File +from app.files.models.user_gallery import UserGallery + + +class FileSerializer(BaseModelSerializer): + class Meta: + model = File + fields = ( + "id", + "title", + "description", + "file", + "created_at", + "updated_at", + ) + + +class CreateFileSerializer(BaseModelSerializer): + class Meta: + model = File + fields = ( + "title", + "description", + "file", + ) + + def create(self, validated_data): + user = self.context["request"].user + + gallery = UserGallery.objects.filter(author=user).first() + + if not gallery: + raise NoGalleryFoundForUser() + + if gallery.files.count() >= MAX_GALLERY_SIZE: + raise GalleryIsFull() + + validated_data["gallery"] = gallery + + file_instance = super().create(validated_data) + + return file_instance + + +class UpdateFileSerializer(BaseModelSerializer): + + class Meta: + model = File + fields = ( + "title", + "description", + "file", + ) + + +class DeleteFileSerializer(BaseModelSerializer): + class Meta: + model = File + + def delete(self, instance): + azure_handler = AzureFileHandler(url=instance.url) + azure_handler.deleteBlob() + + return super().delete(instance) diff --git a/app/files/serializers/user_gallery.py b/app/files/serializers/user_gallery.py new file mode 100644 index 00000000..8dd6f64a --- /dev/null +++ b/app/files/serializers/user_gallery.py @@ -0,0 +1,22 @@ +from app.common.serializers import BaseModelSerializer +from app.content.serializers.user import DefaultUserSerializer +from app.files.models.user_gallery import UserGallery + + +class UserGallerySerializer(BaseModelSerializer): + author = DefaultUserSerializer(read_only=True) + + class Meta: + model = UserGallery + fields = ( + "id", + "created_at", + "updated_at", + "author", + ) + + def create(self, validated_data): + user = self.context["request"].user + validated_data["author"] = user + + return super().create(validated_data) diff --git a/app/files/tasks/__init__.py b/app/files/tasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/files/tests/__init__.py b/app/files/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/files/urls.py b/app/files/urls.py new file mode 100644 index 00000000..02655adc --- /dev/null +++ b/app/files/urls.py @@ -0,0 +1,17 @@ +from django.urls import include, path +from rest_framework import routers + +from app.files.views.file import FileViewSet +from app.files.views.upload import delete, upload +from app.files.views.user_gallery import UserGalleryViewSet + +router = routers.DefaultRouter() + +router.register("file", FileViewSet, basename="file") +router.register("user_gallery", UserGalleryViewSet, basename="user_gallery") + +urlpatterns = [ + path("upload/", upload), + path("delete-file///", delete), + path("files/", include(router.urls)), +] diff --git a/app/files/util/__init__.py b/app/files/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/files/views/__init__.py b/app/files/views/__init__.py new file mode 100644 index 00000000..3a2ba8b4 --- /dev/null +++ b/app/files/views/__init__.py @@ -0,0 +1,2 @@ +from app.files.views.user_gallery import UserGalleryViewSet +from app.files.views.file import FileViewSet diff --git a/app/files/views/file.py b/app/files/views/file.py new file mode 100644 index 00000000..d99070bf --- /dev/null +++ b/app/files/views/file.py @@ -0,0 +1,56 @@ +from rest_framework import status +from rest_framework.response import Response + +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet +from app.files.mixins import FileErrorMixin +from app.files.models.file import File +from app.files.serializers.file import ( + CreateFileSerializer, + FileSerializer, + UpdateFileSerializer, +) + + +class FileViewSet(FileErrorMixin, BaseViewSet): + serializer_class = FileSerializer + permission_classes = [BasicViewPermission] + queryset = File.objects.select_related("gallery").all() + + def retrieve(self, request, *_args, **_kwargs): + """Retrieves a specific file by id""" + file = self.get_object() + serializer = FileSerializer(file, context={"request": request}, many=False) + return Response(data=serializer.data, status=status.HTTP_200_OK) + + def update(self, request, *_args, **_kwargs): + """Updates a specific file by id""" + file = self.get_object() + + serializer = UpdateFileSerializer( + file, data=request.data, partial=True, context={"request": request} + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def create(self, request, *args, **kwargs): + """Creates a file""" + serializer = CreateFileSerializer( + data=request.data, context={"request": request} + ) + if serializer.is_valid(): + file = super().perform_create(serializer) + return_serializer = CreateFileSerializer(file) + return Response(data=return_serializer.data, status=status.HTTP_201_CREATED) + return Response( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def delete(self, request, *_args, **_kwargs): + """Deletes a specific file by id""" + file = self.get_object() + file.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/app/content/views/upload.py b/app/files/views/upload.py similarity index 100% rename from app/content/views/upload.py rename to app/files/views/upload.py diff --git a/app/files/views/user_gallery.py b/app/files/views/user_gallery.py new file mode 100644 index 00000000..c362dcc8 --- /dev/null +++ b/app/files/views/user_gallery.py @@ -0,0 +1,41 @@ +from rest_framework import status +from rest_framework.response import Response + +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet +from app.files.models.user_gallery import UserGallery +from app.files.serializers.user_gallery import UserGallerySerializer + + +class UserGalleryViewSet(BaseViewSet): + serializer_class = UserGallerySerializer + permission_classes = [BasicViewPermission] + queryset = UserGallery.objects.all() + + def retrieve(self, request, *_args, **_kwargs): + """Retrieve all files in gallery""" + try: + files = UserGallery.get_all_files(request.user) + serializer = UserGallerySerializer(files, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except UserGallery.DoesNotExist: + return Response( + {"detail": "Galleriet finnes ikke"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def create(self, request, *args, **kwargs): + """Create a gallery for the current user""" + serializer = UserGallerySerializer( + data=request.data, context={"request": request} + ) + + if serializer.is_valid(): + user_gallery = super().perform_create(serializer) + return_serializer = UserGallerySerializer(user_gallery) + return Response(return_serializer.data, status=status.HTTP_201_CREATED) + + return Response( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/app/group/views/group.py b/app/group/views/group.py index a96f19ce..3533e33c 100644 --- a/app/group/views/group.py +++ b/app/group/views/group.py @@ -2,7 +2,6 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.status import HTTP_201_CREATED from app.common.mixins import ActionMixin from app.common.permissions import BasicViewPermission @@ -73,7 +72,7 @@ def create(self, request, *args, **kwargs): if serializer.is_valid(): group = super().perform_create(serializer) return_serializer = SimpleGroupSerializer(group) - return Response(data=return_serializer.data, status=HTTP_201_CREATED) + return Response(data=return_serializer.data, status=status.HTTP_201_CREATED) return Response( {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST, diff --git a/app/group/views/membership.py b/app/group/views/membership.py index 1e4356a8..f98db0ab 100644 --- a/app/group/views/membership.py +++ b/app/group/views/membership.py @@ -58,13 +58,13 @@ def update(self, request, *args, **kwargs): UserNotificationSettingType.GROUP_MEMBERSHIP, ).add_paragraph(f"Hei, {membership.user.first_name}!").add_paragraph( f'Du har blitt gjort til leder i gruppen "{membership.group.name}". Som leder får du tilgang til ' - f'diverse funksjonalitet på nettsiden. Du kan finne administrasjonspanelene du har tilgang til ' + f"diverse funksjonalitet på nettsiden. Du kan finne administrasjonspanelene du har tilgang til " f'under "Admin" i din profil.' ).add_paragraph( f'Som leder har du også fått administratorrettigheter i "{membership.group.name}". Det innebærer ' - f'at du kan legge til og fjerne medlemmer, endre tidligere medlemskap og administrere gruppens ' - f'spørreskjemaer. I gruppens innstillinger kan du endre gruppens beskrivelse og logo, samt ' - f'aktivere botsystemet og velge en botsjef.' + f"at du kan legge til og fjerne medlemmer, endre tidligere medlemskap og administrere gruppens " + f"spørreskjemaer. I gruppens innstillinger kan du endre gruppens beskrivelse og logo, samt " + f"aktivere botsystemet og velge en botsjef." ).add_paragraph( "Gratulerer så mye og lykke til med ledervervet!" ).add_link( @@ -92,9 +92,11 @@ def create(self, request, *args, **kwargs): admin_text = " " if group.type in [GroupType.BOARD, GroupType.SUBGROUP]: - admin_text = (f'Som medlem av "{group.name}" har du også fått tilgang til diverse funksjonalitet på ' - f'nettsiden. Du kan finne administrasjonspanelene du har tilgang til under "Admin" ' - f'i din profil. ') + admin_text = ( + f'Som medlem av "{group.name}" har du også fått tilgang til diverse funksjonalitet på ' + f'nettsiden. Du kan finne administrasjonspanelene du har tilgang til under "Admin" ' + f"i din profil. " + ) Notify( [membership.user], diff --git a/app/settings.py b/app/settings.py index 26e8c1fe..f9b3bcf6 100644 --- a/app/settings.py +++ b/app/settings.py @@ -108,6 +108,7 @@ "app.emoji", "app.feedback", "app.codex", + "app.files", ] # Django rest framework diff --git a/app/tests/conftest.py b/app/tests/conftest.py index fbdccddf..eb48220e 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -33,6 +33,7 @@ NewsReactionFactory, ) from app.feedback.factories import BugFactory, IdeaFactory +from app.files.models.user_gallery import UserGallery from app.forms.tests.form_factories import FormFactory, SubmissionFactory from app.group.factories import GroupFactory, MembershipFactory from app.group.factories.fine_factory import FineFactory @@ -314,6 +315,18 @@ def codex_event_registration(): return CodexEventRegistrationFactory() +@pytest.fixture +def user_gallery(member): + """Creates a gallery for the member.""" + return UserGallery.objects.create(author=member) + + +@pytest.fixture +def admin_gallery(admin_user): + """Creates a gallery for the admin user.""" + return UserGallery.objects.create(author=admin_user) + + @pytest.fixture() def new_admin_user(): admin = UserFactory() diff --git a/app/tests/files/__init__.py b/app/tests/files/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/tests/files/test_file_integration.py b/app/tests/files/test_file_integration.py new file mode 100644 index 00000000..20660c7b --- /dev/null +++ b/app/tests/files/test_file_integration.py @@ -0,0 +1,272 @@ +from rest_framework import status + +import pytest + +from app.common.enums import AdminGroup, Groups +from app.constants import MAX_GALLERY_SIZE +from app.files.models.file import File +from app.files.models.user_gallery import UserGallery +from app.util.test_utils import ( + add_user_to_group_with_name, + get_api_client, + remove_user_from_group_with_name, +) + +FILE_URL = "/files/file/" + + +def _get_file_url(file=None): + return f"{FILE_URL}{file.id}/" if file else f"{FILE_URL}" + + +def _get_file_post_data(): + return { + "title": "Sample File", + "url": "https://example.com/file.pdf", + "description": "This is a sample file.", + } + + +def _get_file_put_data(file): + return {"title": file.title, "description": "Updated description."} + + +def _create_file(user, gallery=None): + """Helper function to create a file in the database.""" + if gallery is None: + gallery, _ = UserGallery.objects.get_or_create(author=user) + + return File.objects.create( + title="Sample File", + description="This is a sample file.", + gallery=gallery, + ) + + +@pytest.mark.django_db +def test_list_files_as_anonymous_user(default_client): + """Tests if an anonymous user cannot list files""" + url = _get_file_url() + response = default_client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_list_files_as_member(member): + """Tests if a member can list files""" + client = get_api_client(user=member) + url = _get_file_url() + response = client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_list_files_as_admin(admin_user): + """Tests if an admin user can list files""" + client = get_api_client(user=admin_user) + url = _get_file_url() + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_create_file_as_member(member): + """Tests if a member can create a file""" + client = get_api_client(user=member) + url = _get_file_url() + data = _get_file_post_data() + + response = client.post(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_create_file_as_admin(admin_user, admin_gallery): + """Tests if an admin can create a file""" + client = get_api_client(user=admin_user) + url = _get_file_url() + data = _get_file_post_data() + + response = client.post(url, data) + + assert response.status_code == status.HTTP_201_CREATED + assert File.objects.filter(title=data["title"]).exists() + file = File.objects.get(title=data["title"]) + assert file.gallery == admin_gallery + + +@pytest.mark.django_db +def test_create_file_as_admin_no_gallery(admin_user): + """Tests if an admin can create a file without a gallery""" + client = get_api_client(user=admin_user) + url = _get_file_url() + data = _get_file_post_data() + + response = client.post(url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_create_file_gallery_full(admin_user, admin_gallery): + """Tests if the admin cannot create a file when gallery is full (>=50 files)""" + for _ in range(MAX_GALLERY_SIZE): + _create_file(admin_user, admin_gallery) + + client = get_api_client(user=admin_user) + url = _get_file_url() + data = _get_file_post_data() + + response = client.post(url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +# noinspection PyTypeChecker +@pytest.mark.django_db +def test_retrieve_file_does_not_exist(admin_user): + """Tests retrieving a non-existent file""" + client = get_api_client(user=admin_user) + url = _get_file_url(file=None) + "999/" + + response = client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_delete_file_as_admin(admin_user, admin_gallery): + """Tests if an admin user can delete their file""" + file = _create_file(admin_user, admin_gallery) + + client = get_api_client(user=admin_user) + url = _get_file_url(file) + + response = client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not File.objects.filter(id=file.id).exists() + + +@pytest.mark.django_db +def test_delete_file_as_non_author(member, new_admin_user, admin_gallery): + """Tests if a non-author cannot delete another user's file""" + add_user_to_group_with_name(member, AdminGroup.HS) + + admin_gallery = UserGallery.objects.create(author=new_admin_user) + + file = _create_file(new_admin_user, admin_gallery) + + client = get_api_client(user=member) + url = _get_file_url(file) + + response = client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert File.objects.filter(id=file.id).exists() + + +@pytest.mark.django_db +def test_update_file_as_admin(admin_user, admin_gallery): + """Tests if an admin can update a file without providing a new file.""" + file = _create_file(admin_user, admin_gallery) + client = get_api_client(user=admin_user) + url = _get_file_url(file) + + data = { + "title": "Updated Sample File", + "description": "This is an updated sample file.", + } + + response = client.put(url, data) + + assert response.status_code == status.HTTP_200_OK + file.refresh_from_db() + assert file.title == "Updated Sample File" + assert file.description == "This is an updated sample file." + + +@pytest.mark.django_db +def test_update_file_as_member(member, user_gallery): + """Tests if a member cannot update a file.""" + file = _create_file(member, user_gallery) + client = get_api_client(user=member) + url = _get_file_url(file) + + data = { + "title": "Attempted Update by Member", + } + + response = client.put(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_update_file_invalid_data(admin_user, admin_gallery): + """Tests if an admin cannot update a file with invalid data.""" + file = _create_file(admin_user, admin_gallery) + client = get_api_client(user=admin_user) + url = _get_file_url(file) + + data = { + "title": "", + } + + response = client.put(url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "title" in response.data + + +@pytest.mark.django_db +def test_update_non_existent_file(admin_user): + """Tests if an admin cannot update a non-existent file.""" + client = get_api_client(user=admin_user) + url = _get_file_url() + "999/" + + data = { + "title": "Non-Existent File", + } + + response = client.put(url, data) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_partial_update_file(admin_user, admin_gallery): + """Tests if an admin can partially update a file.""" + file = _create_file(admin_user, admin_gallery) + client = get_api_client(user=admin_user) + url = _get_file_url(file) + + data = { + "description": "This is a partially updated description.", + } + + response = client.patch(url, data) + + assert response.status_code == status.HTTP_200_OK + file.refresh_from_db() + assert file.description == "This is a partially updated description." + + +@pytest.mark.django_db +def test_create_file_as_admin_and_delete_as_tihlde_user(admin_user, admin_gallery): + """Tests if an admin can create a file and delete it after being removed as an admin.""" + file = _create_file(admin_user, admin_gallery) + url = _get_file_url(file) + + remove_user_from_group_with_name(admin_user, AdminGroup.HS) + add_user_to_group_with_name(admin_user, Groups.TIHLDE) + + client = get_api_client(user=admin_user) + + delete_response = client.delete(url) + + assert delete_response.status_code == status.HTTP_204_NO_CONTENT diff --git a/app/tests/files/test_user_gallery_integration.py b/app/tests/files/test_user_gallery_integration.py new file mode 100644 index 00000000..5b9c1bdf --- /dev/null +++ b/app/tests/files/test_user_gallery_integration.py @@ -0,0 +1,52 @@ +from rest_framework import status + +import pytest + +from app.files.models.user_gallery import UserGallery +from app.util.test_utils import get_api_client + + +def _get_user_gallery_url(user_gallery=None): + return ( + f"/files/user_gallery/{user_gallery.id}/" + if user_gallery + else "/files/user_gallery/" + ) + + +@pytest.mark.django_db +def test_create_user_gallery(admin_user): + """Tests if an admin can create a gallery""" + client = get_api_client(user=admin_user) + url = _get_user_gallery_url() + + assert not UserGallery.has_gallery(admin_user) + + response = client.post(url) + + assert response.status_code == status.HTTP_201_CREATED + assert UserGallery.objects.filter(author=admin_user).exists() + + user_gallery = UserGallery.objects.get(author=admin_user) + assert user_gallery.author == admin_user + + +@pytest.mark.django_db +def test_delete_admin_gallery(admin_user): + """Tests if an admin can delete their gallery""" + client = get_api_client(user=admin_user) + + post_url = _get_user_gallery_url() + response = client.post(post_url) + + assert response.status_code == status.HTTP_201_CREATED + + user_gallery_id = response.data["id"] + user_gallery = UserGallery.objects.get(id=user_gallery_id) + + delete_url = _get_user_gallery_url(user_gallery) + + response = client.delete(delete_url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not UserGallery.objects.filter(id=user_gallery.id).exists() diff --git a/app/urls.py b/app/urls.py index 301f2342..74acacb4 100644 --- a/app/urls.py +++ b/app/urls.py @@ -47,6 +47,7 @@ path("", include("app.content.urls")), path("", include("app.group.urls")), path("", include("app.payment.urls")), + path("", include("app.files.urls")), path("auth/", include("app.authentication.urls")), path("badges/", include("app.badge.urls")), path("forms/", include("app.forms.urls")), diff --git a/app/util/models.py b/app/util/models.py index 65d4589a..4d303477 100644 --- a/app/util/models.py +++ b/app/util/models.py @@ -17,3 +17,12 @@ class OptionalImage(models.Model): class Meta: abstract = True + + +class OptionalFile(models.Model): + """Abstract model for models containing a file""" + + file = models.URLField(max_length=600, null=True, blank=True) + + class Meta: + abstract = True diff --git a/app/util/test_utils.py b/app/util/test_utils.py index 78682ec2..2ab0818f 100644 --- a/app/util/test_utils.py +++ b/app/util/test_utils.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ObjectDoesNotExist from django.db.transaction import atomic from rest_framework.authtoken.models import Token from rest_framework.test import APIClient @@ -24,6 +25,9 @@ def get_api_client(user=None, group_name=None): def add_user_to_group_with_name( user, group_name, group_type=None, membership_type=MembershipType.MEMBER ): + """ + Adds a user to a group with the given name. + """ if not group_type: group_type = get_group_type_from_group_name(group_name) group = Group.objects.get_or_create(name=group_name, type=group_type)[0] @@ -33,6 +37,35 @@ def add_user_to_group_with_name( return group +@atomic +def remove_user_from_group_with_name(user, group_name, group_type=None): + """ + Removes a user from a group with the given name. + """ + if not group_type: + group_type = get_group_type_from_group_name(group_name) + + try: + group = Group.objects.get(name=group_name, type=group_type) + except Group.DoesNotExist: + raise ObjectDoesNotExist( + f"Group with name '{group_name}' and type '{group_type}' does not exist." + ) + + try: + membership = Membership.objects.get(group=group, user=user) + membership.delete() + except Membership.DoesNotExist: + raise ObjectDoesNotExist( + f"User '{user}' is not a member of the group '{group_name}'." + ) + + if not Membership.objects.filter(group=group).exists(): + group.delete() + + return group + + def get_group_type_from_group_name(group_name): if group_name == AdminGroup.HS: return GroupType.BOARD