diff --git a/.env.dev b/.env.dev index 18622074d..8f0fa8b34 100644 --- a/.env.dev +++ b/.env.dev @@ -16,3 +16,10 @@ DATABASE_PASSWORD="postgres" SECRET_KEY='secret' DEBUG='1' + +ACTIVIST_EMAIL="noreply@activist.org" +EMAIL_HOST="smtp.activist.org" +EMAIl_PORT="587" +EMAIL_HOST_USER="activst@activst.org" +EMAIL_HOST_PASSWORD="activist123!?" +EMAIL_USE_TLS="True" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index da603050d..392a46140 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - - name: Community discussion channels + - name: 👋 Community discussion channels url: https://matrix.to/#/#activist_community:matrix.org about: Join us in our public Matrix chat rooms! diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6cfa89f76..f45cb9eb5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,12 @@ repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.3.0 hooks: @@ -15,11 +23,3 @@ repos: # additional_dependencies: # - prettier # - prettier-plugin-tailwindcss - - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index b8028c5ce..c3e22cf47 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -266,7 +266,7 @@ Please define all routes for images and icons in the respective [url registry ut activist uses [nuxt-icon](https://github.com/nuxt-modules/icon) for all icons. Icons are defined via `` components, with [Icônes](https://icones.js.org/) being a good place to look for [Iconify](https://iconify.design/) based files to import. The `` component also has a `size` argument that `em` based arguments can be passed to. There's also a `color` argument, but colors are handled with Tailwind CSS via the `text-COLOR` class argument. -Custom icons for activist can further be found in the [Icon directory of the frontend components](https://github.com/activist-org/activist/tree/main/frontend/components/Icon). These icons can also be referenced via the `` component via their file name (ex: `` for the grasped hands we use). For Tailwind coloration note that we need to use `fill-COLOR` for the custom activist icons rather than `text-COLOR`. +Custom icons for activist can further be found in the [Icon directory of the frontend components](https://github.com/activist-org/activist/tree/main/frontend/components/icon). These icons can also be referenced via the `` component via their file name (ex: `` for the grasped hands we use). For Tailwind coloration note that we need to use `fill-COLOR` for the custom activist icons rather than `text-COLOR`. diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py index a157b8588..50d616ff4 100644 --- a/backend/authentication/admin.py +++ b/backend/authentication/admin.py @@ -16,6 +16,21 @@ UserTopic, ) +# MARK: Main Tables + +# Remove default Group. +admin.site.unregister(Group) +admin.site.register(Support) + +# MARK: Bridge Tables + +admin.site.register(UserResource) +admin.site.register(UserTask) +admin.site.register(UserTopic) +admin.site.register(SupportEntityType) + +# MARK: Methods + class UserCreationForm(forms.ModelForm[UserModel]): """ @@ -81,10 +96,9 @@ class UserAdmin(BaseUserAdmin): "fields": [ "username", "description", - "private", - "high_risk", - "user_icon", - "social_accounts", + "is_private", + "is_high_risk", + "verfied", ] }, ), @@ -115,12 +129,5 @@ class UserAdmin(BaseUserAdmin): filter_horizontal = [] -# Remove default Group -admin.site.unregister(Group) -admin.site.register(UserResource) -admin.site.register(UserTask) -admin.site.register(UserTopic) -admin.site.register(SupportEntityType) -admin.site.register(Support) # Now register the new UserAdmin... admin.site.register(UserModel, UserAdmin) diff --git a/backend/authentication/enums.py b/backend/authentication/enums.py new file mode 100644 index 000000000..0d0f46f1a --- /dev/null +++ b/backend/authentication/enums.py @@ -0,0 +1,20 @@ +""" +Enums for the authentication app. +More details about Enums can be found in schema section on Figma. +""" + +from enum import Enum + + +class StatusTypes(Enum): + PENDING = 1 + ACTIVE = 2 + SUSPENDED = 3 + BANNED = 4 + + +class SupportEntityTypes(Enum): + ORGANIZATION = 1 + GROUP = 2 + EVENT = 3 + USER = 4 diff --git a/backend/authentication/factories.py b/backend/authentication/factories.py index 8c81b83d9..90fb83739 100644 --- a/backend/authentication/factories.py +++ b/backend/authentication/factories.py @@ -11,6 +11,8 @@ UserTopic, ) +# MARK: Main Tables + class SupportEntityTypeFactory(factory.django.DjangoModelFactory): class Meta: @@ -39,12 +41,12 @@ class Meta: description = factory.Faker("text", max_nb_chars=500) verified = factory.Faker("boolean") verification_method = factory.Faker("word") + verifictaion_code = factory.Faker("uuid4") email = factory.Faker("email") social_links = factory.List([factory.Faker("user_name") for _ in range(3)]) is_private = factory.Faker("boolean") is_high_risk = factory.Faker("boolean") creation_date = factory.Faker("date_time_this_decade", before_now=True) - deletion_date = factory.Faker("date_time_this_decade", before_now=False) plaintext_password = factory.PostGenerationMethodCall("set_password", "password") # Workaround for the build method @@ -61,6 +63,9 @@ def verification_partner( pass +# MARK: Bridge Tables + + class UserResourceFactory(factory.django.DjangoModelFactory): class Meta: model = UserResource diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 0847d3f30..b18df56cb 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -2,6 +2,8 @@ Models for the authentication app. """ +from __future__ import annotations + from typing import Any from uuid import uuid4 @@ -9,40 +11,14 @@ AbstractUser, BaseUserManager, PermissionsMixin, - User, ) from django.contrib.postgres.fields import ArrayField from django.db import models - -class SupportEntityType(models.Model): - id = models.IntegerField(primary_key=True) - name = models.CharField(max_length=255) - - def __str__(self) -> str: - return self.name +# MARK: Main Tables -class Support(models.Model): - supporter_type = models.ForeignKey( - "SupportEntityType", on_delete=models.CASCADE, related_name="supporter" - ) - supporter_entity = models.ForeignKey( - "entities.Organization", on_delete=models.CASCADE, related_name="supporter" - ) - supported_type = models.ForeignKey( - "SupportEntityType", on_delete=models.CASCADE, related_name="supported" - ) - supported_entity = models.ForeignKey( - "entities.Organization", on_delete=models.CASCADE, related_name="supported" - ) - creation_date = models.DateTimeField(auto_now_add=True) - - def __str__(self) -> str: - return f"{self.id}" - - -class CustomAccountManager(BaseUserManager[User]): +class CustomAccountManager(BaseUserManager["UserModel"]): def create_superuser( self, email: str, @@ -59,25 +35,53 @@ def create_superuser( if other_fields.get("is_superuser") is not True: raise ValueError("Superuser must be assigned to is_superuser=True.") - return self.create_user(email, username, password, **other_fields) + return self.create_user( + email=email, username=username, password=password, **other_fields + ) def create_user( self, email: str, username: str, password: str, - **other_fields: bool, - ) -> User: - if not email: - raise ValueError(("You must provide an email address")) + **other_fields: Any, + ) -> UserModel: + if email != "": + email = self.normalize_email(email) - email = self.normalize_email(email) - user: User = self.model(email=email, username=username, **other_fields) + user = self.model(email=email, username=username, **other_fields) user.set_password(password) user.save() return user +class SupportEntityType(models.Model): + id = models.IntegerField(primary_key=True) + name = models.CharField(max_length=255) + + def __str__(self) -> str: + return self.name + + +class Support(models.Model): + supporter_type = models.ForeignKey( + "SupportEntityType", on_delete=models.CASCADE, related_name="supporter" + ) + supporter_entity = models.ForeignKey( + "entities.Organization", on_delete=models.CASCADE, related_name="supporter" + ) + supported_type = models.ForeignKey( + "SupportEntityType", on_delete=models.CASCADE, related_name="supported" + ) + supported_entity = models.ForeignKey( + "entities.Organization", on_delete=models.CASCADE, related_name="supported" + ) + creation_date = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"{self.id}" + + class UserModel(AbstractUser, PermissionsMixin): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) username = models.CharField(max_length=255, unique=True) @@ -92,7 +96,9 @@ class UserModel(AbstractUser, PermissionsMixin): icon_url = models.ForeignKey( "content.Image", on_delete=models.SET_NULL, blank=True, null=True ) - email = models.EmailField(unique=True) + verification_code = models.UUIDField(blank=True, null=True) + email = models.EmailField(blank=True) + is_confirmed = models.BooleanField(default=False) social_links = ArrayField(models.CharField(max_length=255), blank=True, null=True) is_private = models.BooleanField(default=False) is_high_risk = models.BooleanField(default=False) @@ -102,15 +108,17 @@ class UserModel(AbstractUser, PermissionsMixin): is_active = models.BooleanField(default=True) is_admin = models.BooleanField(default=False) - objects = CustomAccountManager() # type: ignore + objects: CustomAccountManager = CustomAccountManager() # type: ignore USERNAME_FIELD = "username" - REQUIRED_FIELDS = ["email"] def __str__(self) -> str: return self.username +# MARK: Bridge Tables + + class UserResource(models.Model): user_id = models.ForeignKey(UserModel, on_delete=models.CASCADE) resource_id = models.ForeignKey("content.Resource", on_delete=models.CASCADE) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 16d4c9ad0..7f9b731f3 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -6,7 +6,6 @@ from typing import Any, Dict, Union from django.contrib.auth import authenticate, get_user_model -from django.contrib.auth.models import User from django.utils.translation import gettext as _ from rest_framework import serializers from rest_framework.authtoken.models import Token @@ -27,18 +26,7 @@ USER = get_user_model() -class SupportEntityTypeSerializer(serializers.ModelSerializer[SupportEntityType]): - class Meta: - model = SupportEntityType - fields = "__all__" - - def validate(self, data: Dict[str, Union[str, Any]]) -> Dict[str, Union[str, Any]]: - if len(data["name"]) < 3: - raise serializers.ValidationError( - _("The field name must be at least 3 characters long."), - code="invalid_name", - ) - return data +# MARK: Main Tables class SupportSerializer(serializers.ModelSerializer[Support]): @@ -58,6 +46,20 @@ def validate(self, data: Dict[str, Union[str, int]]) -> Dict[str, Union[str, int return data +class SupportEntityTypeSerializer(serializers.ModelSerializer[SupportEntityType]): + class Meta: + model = SupportEntityType + fields = "__all__" + + def validate(self, data: Dict[str, Union[str, Any]]) -> Dict[str, Union[str, Any]]: + if len(data["name"]) < 3: + raise serializers.ValidationError( + _("The field name must be at least 3 characters long."), + code="invalid_name", + ) + return data + + class UserSerializer(serializers.ModelSerializer[UserModel]): class Meta: model = UserModel @@ -80,6 +82,9 @@ def validate(self, data: Dict[str, Union[str, Any]]) -> Dict[str, Union[str, Any return data +# MARK: Bridge Tables + + class UserResourceSerializer(serializers.ModelSerializer[UserResource]): class Meta: model = UserResource @@ -98,15 +103,16 @@ class Meta: fields = "__all__" -class SignupSerializer(serializers.ModelSerializer[User]): +# MARK: Methods + + +class SignupSerializer(serializers.ModelSerializer[UserModel]): password_confirmed = serializers.CharField(write_only=True) class Meta: model = USER fields = ("username", "password", "password_confirmed", "email") - extra_kwargs = { - "password": {"write_only": True}, - } + extra_kwargs = {"password": {"write_only": True}, "email": {"required": False}} def validate(self, data: Dict[str, Union[str, Any]]) -> Dict[str, Union[str, Any]]: pattern = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*()_+{}\[\]:;<>,.?~\\-]).{12,}$" @@ -127,44 +133,73 @@ def validate(self, data: Dict[str, Union[str, Any]]) -> Dict[str, Union[str, Any return data - def create(self, validated_data: Dict[str, Union[str, Any]]) -> User: + def create(self, validated_data: Dict[str, Union[str, Any]]) -> UserModel: validated_data.pop("password_confirmed") - user = UserModel.objects.create_user( - username=validated_data["username"], - password=validated_data["password"], - email=validated_data["email"], - ) + user: UserModel = UserModel.objects.create_user(**validated_data) user.save() return user class LoginSerializer(serializers.Serializer[UserModel]): - username = serializers.CharField() + email = serializers.EmailField(required=False) + username = serializers.CharField(required=False) password = serializers.CharField(write_only=True) def validate(self, data: Dict[str, Union[str, Any]]) -> Dict[str, Union[str, Any]]: - username = UserModel.objects.filter(username=data.get("username")).first() + if not data.get("email"): + user = UserModel.objects.filter(username=data.get("username")).first() + else: + user = UserModel.objects.filter(email=data.get("email")).first() - if username is None: + if user is None: raise serializers.ValidationError( ("Invalid credentials. Please try again."), code="invalid_credentials", ) - user = authenticate( - username=username, + authenticated_user: UserModel = authenticate( + username=user, password=data.get("password"), - ) + ) # type: ignore - if user is None: + if authenticated_user is None: raise serializers.ValidationError( ("Invalid credentials. Please try again."), code="invalid_credentials", ) + if authenticated_user.email != "" and authenticated_user.is_confirmed is False: + raise serializers.ValidationError( + ("Please confirm your email address."), + code="email_not_confirmed", + ) + + data["user"] = authenticated_user + token, _ = Token.objects.get_or_create(user=user) data["token"] = token.key data["user"] = user + return data + + +class PasswordResetSerializer(serializers.Serializer[UserModel]): + email = serializers.EmailField(required=False) + password = serializers.CharField(write_only=True) + code = serializers.UUIDField(required=False) + + def validate(self, data: Dict[str, Union[str, Any]]) -> UserModel: + if data.get("code") is not None: + user = UserModel.objects.filter(verifictaion_code=data.get("code")).first() + else: + user = UserModel.objects.filter(email=data.get("email")).first() + + if user is None: + raise serializers.ValidationError( + _("Invalid email address. Please try again."), + code="invalid_email", + ) + + return user diff --git a/backend/authentication/templates/pwreset_email.html b/backend/authentication/templates/pwreset_email.html new file mode 100644 index 000000000..407173b6c --- /dev/null +++ b/backend/authentication/templates/pwreset_email.html @@ -0,0 +1,15 @@ + + + + + Password Reset + + +

Hi {{username}},

+

We have received a request to reset your password. To proceed with the password reset, please click on the link below:

+

Reset Password

+

If you did not request a password reset, then it's safe to ignore this email.

+

Best regards,

+

Your activist team

+ + diff --git a/backend/authentication/templates/signup_email.html b/backend/authentication/templates/signup_email.html new file mode 100644 index 000000000..f82084df3 --- /dev/null +++ b/backend/authentication/templates/signup_email.html @@ -0,0 +1,17 @@ + + + + Signup Confirmation + + +

Hi {{username}},

+ +

Thank you for signing up!

+ +

Please click the button below to confirm your email address:

+ Confirm Email + +

Best regards,

+

Your activist team

+ + diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index 8261e6aab..83d997b44 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -2,6 +2,7 @@ Testing for the authentication app. """ +# mypy: ignore-errors import pytest from .factories import ( SupportEntityTypeFactory, @@ -14,10 +15,13 @@ from .models import UserModel from django.test import Client +from django.core import mail from faker import Faker from .models import UserModel from django.test import Client +from uuid import UUID +import uuid @pytest.mark.django_db @@ -45,12 +49,12 @@ def test_signup(client: Client) -> None: Scenarios: 1. Password strength fails 2. Password confirmation fails - 3. User is created successfully + 3. User is created successfully with an email - Check response status code - Check if user exists in the DB - Check if user password is hashed - 4. User already exists / Username already exists - 5. Different User with the same email already exists + 4. User already exists + 5. User is created without an email """ # Setup fake = Faker() @@ -105,11 +109,16 @@ def test_signup(client: Client) -> None: "email": email, }, ) - + user = UserModel.objects.filter(username=username).first() assert response.status_code == 201 - assert UserModel.objects.filter(username=username).exists() + assert UserModel.objects.filter(username=username) + # code for Email confirmation is generated and is a UUID + assert isinstance(user.verification_code, UUID) + assert user.is_confirmed is False + # Confirmation Email was sent + assert len(mail.outbox) == 1 # Assert that the password within the dashboard is hashed and not the original string. - assert UserModel.objects.get(username=username).password != strong_password + assert user.password != strong_password # 4. User already exists response = client.post( @@ -125,50 +134,120 @@ def test_signup(client: Client) -> None: assert response.status_code == 400 assert UserModel.objects.filter(username=username).count() == 1 - # 5. Different User with the same email already exists + # 5. User is created without an email response = client.post( path="/v1/auth/signup/", data={ "username": second_username, "password": strong_password, "password_confirmed": strong_password, - "email": email, }, ) - assert response.status_code == 400 - assert not UserModel.objects.filter(username=second_username).exists() + + user = UserModel.objects.filter(username=second_username).first() + assert response.status_code == 201 + assert UserModel.objects.filter(username=second_username).exists() + assert user.email == "" + assert user.is_confirmed is False + assert user.verification_code is None +@pytest.mark.django_db def test_login(client: Client) -> None: """ Test login view. Scenarios: - 1. User is logged in successfully - 2. User exists but password is incorrect - 3. User does not exists and tries to login + 1. User that signed up with email, that has not confirmed their email + 2. User that signed up with email, confimred email address. Is logged in successfully + 3. User exists but password is incorrect + 4. User does not exists and tries to login """ # Setup plaintext_password = "Activist@123!?" user = UserFactory(plaintext_password=plaintext_password) - # 1. User is logged in successfully + # 1. User that signed up with email, that has not confirmed their email + response = client.post( + path="/v1/auth/login/", + data={"username": user.username, "password": plaintext_password}, + ) + assert response.status_code == 400 + + # 2. User that signed up with email, confimred email address. Is logged in successfully + user.is_confirmed = True + user.save() response = client.post( path="/v1/auth/login/", data={"email": user.email, "password": plaintext_password}, ) assert response.status_code == 200 + # login via username + response = client.post( + path="/v1/auth/login/", + data={"username": user.username, "password": plaintext_password}, + ) + assert response.status_code == 200 - # 2. User exists but password is incorrect + # 3. User exists but password is incorrect response = client.post( path="/v1/auth/login/", data={"email": user.email, "password": "Strong_But_Incorrect?!123"}, ) assert response.status_code == 400 - # 2. User does not exists and tries to login + # 4. User does not exists and tries to login response = client.post( path="/v1/auth/login/", data={"email": "unknown_user@example.com", "password": "Password@123!?"}, ) assert response.status_code == 400 + + +@pytest.mark.django_db +def test_pwreset(client: Client) -> None: + """ + Test password reset view. + + Scenarios: + 1. Password reset email is sent successfully + 2. Password reset with invalid email + 3. Password reset is performed successfully + 4. Password reset with invalid verification code + """ + # Setup + old_password = "password123!?" + new_password = "Activist@123!?" + + # 1. User exists and password reset is successful + user = UserFactory(plaintext_password=old_password) + response = client.get( + path="/v1/auth/pwreset/", + data={"email": user.email}, + ) + assert response.status_code == 200 + assert len(mail.outbox) == 1 + + # 2. Password reset with invalid email + response = client.get( + path="/v1/auth/pwreset/", data={"email": "invalid_email@example.com"} + ) + assert response.status_code == 404 + + # 3. Password reset is performed successfully + user.verifictaion_code = uuid.uuid4() + user.save() + response = client.post( + path=f"/v1/auth/pwreset/?code={user.verifictaion_code}", + data={"password": new_password}, + ) + assert response.status_code == 200 + user.refresh_from_db() + assert user.check_password(new_password) + + # 4. Password reset with invalid verification code + response = client.post( + path="/v1/auth/pwreset/invalid_code/", + data={"password": new_password}, + ) + assert response.status_code == 404 diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 183f7c6ec..d5699749b 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -7,17 +7,22 @@ router = DefaultRouter() -router.register(r"support_entity_types", views.SupportEntityTypeViewSet) +# MARK: Main Tables + router.register(r"supports", views.SupportViewSet) router.register(r"users", views.UserViewSet) + +# MARK: Bridge Tables + +router.register(r"support_entity_types", views.SupportEntityTypeViewSet) router.register(r"user_resources", views.UserResourceViewSet) router.register(r"user_tasks", views.UserTaskViewSet) router.register(r"user_topics", views.UserTopicViewSet) - urlpatterns = [ path("", include(router.urls)), path("signup/", views.SignupView.as_view(), name="signup"), path("delete/", views.DeleteUserView.as_view(), name="delete"), path("login/", views.LoginView.as_view(), name="login"), + path("pwreset/", views.PasswordResetView.as_view(), name="pwreset"), ] diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 876d58ceb..85f1b0ca6 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,9 +1,14 @@ +import os +import uuid from uuid import UUID -from django.contrib.auth import get_user_model, login -from django.contrib.auth.models import User +import dotenv +from django.contrib.auth import login +from django.core.mail import send_mail +from django.template.loader import render_to_string from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status, viewsets from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request @@ -22,6 +27,7 @@ ) from .serializers import ( LoginSerializer, + PasswordResetSerializer, SignupSerializer, SupportEntityTypeSerializer, SupportSerializer, @@ -31,13 +37,13 @@ UserTopicSerializer, ) -USER = get_user_model() +dotenv.load_dotenv() +FRONTEND_BASE_URL = os.getenv("VITE_FRONTEND_URL") +ACTIVIST_EMAIL = os.getenv("ACTIVIST_EMAIL") -class SupportEntityTypeViewSet(viewsets.ModelViewSet[SupportEntityType]): - queryset = SupportEntityType.objects.all() - pagination_class = CustomPagination - serializer_class = SupportEntityTypeSerializer + +# MARK: Main Tables class SupportViewSet(viewsets.ModelViewSet[Support]): @@ -46,12 +52,21 @@ class SupportViewSet(viewsets.ModelViewSet[Support]): serializer_class = SupportSerializer -class UserViewSet(viewsets.ModelViewSet[User]): - queryset = USER.objects.all() +class UserViewSet(viewsets.ModelViewSet[UserModel]): + queryset = UserModel.objects.all() pagination_class = CustomPagination serializer_class = UserSerializer +# MARK: Bridge Tables + + +class SupportEntityTypeViewSet(viewsets.ModelViewSet[SupportEntityType]): + queryset = SupportEntityType.objects.all() + pagination_class = CustomPagination + serializer_class = SupportEntityTypeSerializer + + class UserResourceViewSet(viewsets.ModelViewSet[UserResource]): queryset = UserResource.objects.all() pagination_class = CustomPagination @@ -70,21 +85,72 @@ class UserTopicViewSet(viewsets.ModelViewSet[UserTopic]): serializer_class = UserTopicSerializer +# MARK: Methods + + class SignupView(APIView): queryset = UserModel.objects.all() permission_classes = (AllowAny,) serializer_class = SignupSerializer def post(self, request: Request) -> Response: + """Create a new user.""" serializer = SignupSerializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save() + user: UserModel = serializer.save() + + if user.email != "": + user.verification_code = uuid.uuid4() + + confirmation_link = f"{FRONTEND_BASE_URL}/confirm/{user.verification_code}" + message = f"Welcome to activist.org, {user.username}!, Please confirm your email address by clicking the link: {confirmation_link}" + html_message = render_to_string( + template_name="signup_email.html", + context={ + "username": user.username, + confirmation_link: confirmation_link, + }, + ) + + send_mail( + subject="Welcome to activist.org", + message=message, + from_email=ACTIVIST_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + + user.save() return Response( {"message": "User was created successfully"}, status=status.HTTP_201_CREATED, ) + @extend_schema( + parameters=[OpenApiParameter(name="verification_code", type=str, required=True)] + ) + def get(self, request: Request) -> Response: + """Confirm a user's email address.""" + verification_code = request.GET.get("verification_code") + user = UserModel.objects.filter(verification_code=verification_code).first() + + if user is None: + return Response( + {"message": "User does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + + user.is_confirmed = True + user.verification_code = "" + user.save() + + return Response( + {"message": "Email is confirmed. You can now log in."}, + status=status.HTTP_201_CREATED, + ) + @method_decorator(csrf_exempt, name="dispatch") class LoginView(APIView): @@ -92,6 +158,10 @@ class LoginView(APIView): permission_classes = (AllowAny,) def post(self, request: Request) -> Response: + """Log in a user. + + Login is possible with either email or username + """ serializer = LoginSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -106,6 +176,66 @@ def post(self, request: Request) -> Response: ) +class PasswordResetView(APIView): + serializer_class = PasswordResetSerializer + permission_classes = (AllowAny,) + queryset = UserModel.objects.all() + + @extend_schema(parameters=[OpenApiParameter(name="email", type=str, required=True)]) + def get(self, request: Request) -> Response: + email = request.query_params.get("email") + user = UserModel.objects.filter(email=email).first() + + if user is None: + return Response( + {"message": "User does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + + user.verification_code = uuid.uuid4() + + pwreset_link = f"{FRONTEND_BASE_URL}/pwreset/{user.verification_code}" + message = "Reset your password at activist.org" + html_message = render_to_string( + template_name="pwreset_email.html", + context={"username": user.username, pwreset_link: pwreset_link}, + ) + + send_mail( + subject="Reset your password at activist.org", + message=message, + from_email=ACTIVIST_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + + user.save() + + return Response( + {"message": "Password reset email was sent successfully"}, + status=status.HTTP_200_OK, + ) + + def post(self, request: Request) -> Response: + data = { + "password": request.data.get("password"), + "code": request.query_params.get("code"), + } + serializer = PasswordResetSerializer(data=data) + serializer.is_valid(raise_exception=True) + + user: UserModel = serializer.validated_data + + user.set_password(request.data.get("password")) + user.save() + + return Response( + {"message": "Password was reset successfully"}, + status=status.HTTP_200_OK, + ) + + class DeleteUserView(APIView): queryset = UserModel.objects.all() permission_classes = (IsAuthenticated,) diff --git a/backend/backend/exception_handler.py b/backend/backend/exception_handler.py new file mode 100644 index 000000000..aeaca091b --- /dev/null +++ b/backend/backend/exception_handler.py @@ -0,0 +1,21 @@ +import logging +from typing import Any + +from rest_framework.response import Response +from rest_framework.views import exception_handler + +logger = logging.getLogger(__name__) + + +def bad_request_logger(exception: Any, context: dict[str, Any]) -> Response | None: + # Get the DRF exception handler standard error response + response = exception_handler(exception, context) + + if response is not None: + logger.warning( + f"Bad request: {context['request'].path} -" + f"Status Code: {response.status_code} -" + f"Data: {response.data} -" + ) + + return response diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 09052963b..1d9f8f501 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -157,6 +157,17 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +# Email Settings +# https://docs.djangoproject.com/en/5.0/topics/email/ + +EMAIL_HOST = os.getenv("EMAIL_HOST") +EMAIL_PORT = os.getenv("EMAIL_PORT") +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") +EMAIL_USE_TLS = bool(os.getenv("EMAIL_USE_TLS") == "True") +# DEVELOPMENT ONLY +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + REST_FRAMEWORK = { "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", @@ -170,6 +181,7 @@ "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.TokenAuthentication", ], + "EXCEPTION_HANDLER": "backend.exception_handler.bad_request_logger", } SPECTACULAR_SETTINGS = { diff --git a/backend/content/admin.py b/backend/content/admin.py index 4a18334df..b1d867411 100644 --- a/backend/content/admin.py +++ b/backend/content/admin.py @@ -11,11 +11,16 @@ TopicFormat, ) +# MARK: Main Tables + admin.site.register(Faq) admin.site.register(Image) +admin.site.register(IsoCodeMap) admin.site.register(Resource) -admin.site.register(ResourceTopic) admin.site.register(Task) admin.site.register(Topic) + +# MARK: Main Tables + +admin.site.register(ResourceTopic) admin.site.register(TopicFormat) -admin.site.register(IsoCodeMap) diff --git a/backend/content/factories.py b/backend/content/factories.py index 77128f414..7b6885af4 100644 --- a/backend/content/factories.py +++ b/backend/content/factories.py @@ -4,6 +4,8 @@ from .models import Faq, Resource, ResourceTopic, Task, Topic, TopicFormat +# MARK: Main Tables + class FaqFactory(factory.django.DjangoModelFactory): class Meta: @@ -19,7 +21,6 @@ class Meta: name = factory.Faker("name") description = factory.Faker("text") - topics = factory.List([factory.Faker("word") for _ in range(5)]) url = factory.Faker("url") is_private = factory.Faker("boolean") created_by = factory.SubFactory("authentication.factories.UserFactory") @@ -49,6 +50,9 @@ class Meta: deprecation_date = factory.Faker("date") +# MARK: Bridge Tables + + class ResourceTopicFactory(factory.django.DjangoModelFactory): class Meta: model = ResourceTopic diff --git a/backend/content/models.py b/backend/content/models.py index 55c7b4885..dd9452701 100644 --- a/backend/content/models.py +++ b/backend/content/models.py @@ -10,6 +10,8 @@ from backend.mixins.models import CreationDeletionMixin +# MARK: Main Tables + class Discussion(models.Model): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) @@ -25,18 +27,6 @@ def __str__(self) -> str: return f"{self.id}" -class DiscussionEntry(models.Model): - id = models.UUIDField(primary_key=True, default=uuid4, editable=False) - discussion_id = models.ForeignKey(Discussion, on_delete=models.CASCADE) - created_by = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) - text = models.CharField(max_length=255, blank=True) - creation_date = models.DateTimeField(auto_now_add=True) - deletion_date = models.DateTimeField(null=True, blank=True) - - def __str__(self) -> str: - return f"{self.id}" - - class Faq(models.Model): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) org_id = models.ForeignKey("entities.Organization", on_delete=models.CASCADE) @@ -48,6 +38,21 @@ def __str__(self) -> str: return self.question +class Image(models.Model): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + image_location = models.ImageField( + upload_to="images/", validators=[validate_image_file_extension] + ) + creation_date = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"{self.id}" + + +class IsoCodeMap(models.Model): + code = models.CharField(max_length=2) + + class Resource(models.Model): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) name = models.CharField(max_length=255) @@ -64,6 +69,15 @@ def __str__(self) -> str: return self.name +class Tag(models.Model): + id = models.IntegerField(primary_key=True) + text = models.CharField(max_length=255) + creation_date = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"{self.id}" + + class Task(CreationDeletionMixin): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) name = models.CharField(max_length=255) @@ -87,18 +101,24 @@ def __str__(self) -> str: return self.name -class Tag(models.Model): - id = models.IntegerField(primary_key=True) - text = models.CharField(max_length=255) +# MARK: Bridge Tables + + +class DiscussionEntry(models.Model): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + discussion_id = models.ForeignKey(Discussion, on_delete=models.CASCADE) + created_by = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) + text = models.CharField(max_length=255, blank=True) creation_date = models.DateTimeField(auto_now_add=True) + deletion_date = models.DateTimeField(null=True, blank=True) def __str__(self) -> str: return f"{self.id}" -class ResourceTopic(models.Model): - resource_id = models.ForeignKey(Resource, on_delete=models.CASCADE) - topic_id = models.ForeignKey(Topic, on_delete=models.CASCADE) +class DiscussionTag(models.Model): + discussion_id = models.ForeignKey(Discussion, on_delete=models.CASCADE) + tag_id = models.ForeignKey(Tag, on_delete=models.CASCADE) def __str__(self) -> str: return f"{self.id}" @@ -112,32 +132,17 @@ def __str__(self) -> str: return f"{self.id}" -class TopicFormat(models.Model): +class ResourceTopic(models.Model): + resource_id = models.ForeignKey(Resource, on_delete=models.CASCADE) topic_id = models.ForeignKey(Topic, on_delete=models.CASCADE) - format_id = models.ForeignKey("events.Format", on_delete=models.CASCADE) def __str__(self) -> str: return f"{self.id}" -class DiscussionTag(models.Model): - discussion_id = models.ForeignKey(Discussion, on_delete=models.CASCADE) - tag_id = models.ForeignKey(Tag, on_delete=models.CASCADE) - - def __str__(self) -> str: - return f"{self.id}" - - -class Image(models.Model): - id = models.UUIDField(primary_key=True, default=uuid4, editable=False) - image_location = models.ImageField( - upload_to="images/", validators=[validate_image_file_extension] - ) - creation_date = models.DateTimeField(auto_now_add=True) +class TopicFormat(models.Model): + topic_id = models.ForeignKey(Topic, on_delete=models.CASCADE) + format_id = models.ForeignKey("events.Format", on_delete=models.CASCADE) def __str__(self) -> str: return f"{self.id}" - - -class IsoCodeMap(models.Model): - code = models.CharField(max_length=2) diff --git a/backend/content/serializers.py b/backend/content/serializers.py index c3a0aa20b..8c972ee19 100644 --- a/backend/content/serializers.py +++ b/backend/content/serializers.py @@ -29,6 +29,8 @@ TopicFormat, ) +# MARK: Main Tables + class DiscussionSerializer(serializers.ModelSerializer[Discussion]): class Meta: @@ -44,25 +46,46 @@ class Meta: ] -class DiscussionEntrySerializer(serializers.ModelSerializer[DiscussionEntry]): - class Meta: - model = DiscussionEntry - fields = [ - "id", - "discussion_id", - "created_by", - "text", - "creation_date", - "deletion_date", - ] - - class FaqSerializer(serializers.ModelSerializer[Faq]): class Meta: model = Faq fields = ["id", "question", "org_id", "answer", "last_updated"] +class ImageSerializer(serializers.ModelSerializer[Image]): + class Meta: + model = Image + fields = ["id", "image_location", "creation_date"] + read_only_fields = ["id", "creation_date"] + + def validate(self, data: Dict[str, Union[str, int]]) -> Dict[str, Union[str, int]]: + image_extensions = [".jpg", ".jpeg", ".png"] + img_format = "" + + try: + with PilImage.open(data["image_location"]) as img: + img.verify() + img_format = img.format.lower() + except Exception: + raise serializers.ValidationError( + _("The image is not valid."), code="corrupted_file" + ) + + if img_format not in image_extensions: + raise serializers.ValidationError( + _("The image must be in jpg, jpeg or png format."), + code="invalid_extension", + ) + + return data + + +class IsoCodeMapSerializer(serializers.ModelSerializer[IsoCodeMap]): + class Meta: + model = IsoCodeMap + fields = "__all__" + + class ResourceSerializer(serializers.ModelSerializer[Resource]): class Meta: model = Resource @@ -78,6 +101,12 @@ class Meta: ] +class TagSerializer(serializers.ModelSerializer[Tag]): + class Meta: + model = Tag + fields = "__all__" + + class TaskSerializer(serializers.ModelSerializer[Task]): class Meta: model = Task @@ -112,65 +141,41 @@ def validate(self, data: Dict[str, Union[str, int]]) -> Dict[str, Union[str, int return data -class TagSerializer(serializers.ModelSerializer[Tag]): - class Meta: - model = Tag - fields = "__all__" +# MARK: Bridge Tables -class ResourceTopicSerializer(serializers.ModelSerializer[ResourceTopic]): +class DiscussionEntrySerializer(serializers.ModelSerializer[DiscussionEntry]): class Meta: - model = ResourceTopic - fields = "__all__" + model = DiscussionEntry + fields = [ + "id", + "discussion_id", + "created_by", + "text", + "creation_date", + "deletion_date", + ] -class ResourceTagSerializer(serializers.ModelSerializer[ResourceTag]): +class DiscussionTagSerializer(serializers.ModelSerializer[DiscussionTag]): class Meta: - model = ResourceTag + model = DiscussionTag fields = "__all__" -class TopicFormatSerializer(serializers.ModelSerializer[TopicFormat]): +class ResourceTagSerializer(serializers.ModelSerializer[ResourceTag]): class Meta: - model = TopicFormat + model = ResourceTag fields = "__all__" -class DiscussionTagSerializer(serializers.ModelSerializer[DiscussionTag]): +class ResourceTopicSerializer(serializers.ModelSerializer[ResourceTopic]): class Meta: - model = DiscussionTag + model = ResourceTopic fields = "__all__" -class ImageSerializer(serializers.ModelSerializer[Image]): - class Meta: - model = Image - fields = ["id", "image_location", "creation_date"] - read_only_fields = ["id", "creation_date"] - - def validate(self, data: Dict[str, Union[str, int]]) -> Dict[str, Union[str, int]]: - image_extensions = [".jpg", ".jpeg", ".png"] - img_format = "" - - try: - with PilImage.open(data["image_location"]) as img: - img.verify() - img_format = img.format.lower() - except Exception: - raise serializers.ValidationError( - _("The image is not valid."), code="corrupted_file" - ) - - if img_format not in image_extensions: - raise serializers.ValidationError( - _("The image must be in jpg, jpeg or png format."), - code="invalid_extension", - ) - - return data - - -class IsoCodeMapSerializer(serializers.ModelSerializer[IsoCodeMap]): +class TopicFormatSerializer(serializers.ModelSerializer[TopicFormat]): class Meta: - model = IsoCodeMap + model = TopicFormat fields = "__all__" diff --git a/backend/content/tests.py b/backend/content/tests.py index 42bcf741f..b9fd3e1c3 100644 --- a/backend/content/tests.py +++ b/backend/content/tests.py @@ -2,6 +2,7 @@ Testing for the content app. """ +# mypy: ignore-errors from .factories import ResourceFactory, TaskFactory, TopicFactory, ResourceTopicFactory from tests.throttle import BaseTestThrottle from django.urls import reverse diff --git a/backend/content/urls.py b/backend/content/urls.py index be9aeca5b..a05c67c7e 100644 --- a/backend/content/urls.py +++ b/backend/content/urls.py @@ -6,18 +6,23 @@ app_name = "content" router = DefaultRouter() + +# MARK: Main Tables + router.register(r"discussion", views.DiscussionViewSet) -router.register(r"discussion_entry", views.DiscussionEntryViewSet) +router.register(r"faq", views.FaqViewSet) router.register(r"images", views.ImageViewSet) router.register(r"resources", views.ResourceViewSet) router.register(r"tasks", views.TaskViewSet) router.register(r"topics", views.TopicViewSet) + +# MARK: Bridge Tables + +router.register(r"discussion_entry", views.DiscussionEntryViewSet) router.register(r"resource_topics", views.ResourceTopicViewSet) router.register(r"topic_formats", views.TopicFormatViewSet) -router.register(r"faq", views.FaqViewSet) - urlpatterns = [ path("", include(router.urls)), - path("iso_codes/", views.IsoCodeMapListAPIView.as_view(), name="iso_codes"), + path("iso_code_map/", views.IsoCodeMapListAPIView.as_view(), name="iso_code_map"), ] diff --git a/backend/content/views.py b/backend/content/views.py index 2ece0d467..bab12a56b 100644 --- a/backend/content/views.py +++ b/backend/content/views.py @@ -34,6 +34,8 @@ TopicSerializer, ) +# MARK: Main Tables + class DiscussionViewSet(viewsets.ModelViewSet[Discussion]): queryset = Discussion.objects.all() @@ -115,32 +117,57 @@ def destroy(self, request: Request, pk: str | None = None) -> Response: return Response(status=status.HTTP_204_NO_CONTENT) -class DiscussionEntryViewSet(viewsets.ModelViewSet[DiscussionEntry]): - queryset = DiscussionEntry.objects.all() - serializer_class = DiscussionEntrySerializer +class FaqViewSet(viewsets.ModelViewSet[Faq]): + queryset = Faq.objects.all() + serializer_class = FaqSerializer + pagination_class = CustomPagination + + +class ImageViewSet(viewsets.ModelViewSet[Image]): + queryset = Image.objects.all() + serializer_class = ImageSerializer + pagination_class = CustomPagination + + +class IsoCodeMapListAPIView(ListAPIView[IsoCodeMap]): + queryset = IsoCodeMap.objects.all() + serializer_class = IsoCodeMapSerializer + + def get(self, request: Request) -> Response: + queryset = self.get_queryset() + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class ResourceViewSet(viewsets.ModelViewSet[Resource]): + queryset = Resource.objects.all() + serializer_class = ResourceSerializer pagination_class = CustomPagination throttle_classes = [AnonRateThrottle, UserRateThrottle] permission_classes = [IsAuthenticatedOrReadOnly] def create(self, request: Request) -> Response: if request.user.is_authenticated: - request.data["created_by"] = request.user serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save() + serializer.save(created_by=request.user) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response( - {"error": "You are not allowed to create a discussion entry."}, + {"error": "You are not allowed to create a resource."}, status=status.HTTP_403_FORBIDDEN, ) def retrieve(self, request: Request, pk: str | None = None) -> Response: - queryset = self.get_queryset() - item = queryset.filter(id=pk).first() + if request.user.is_authenticated: + query = self.queryset.filter( + Q(is_private=False) | Q(is_private=True, created_by=request.user), id=pk + ) + else: + query = self.queryset.filter(Q(is_private=False), id=pk) - serializer = self.get_serializer(item) - return Response(serializer.data, status=status.HTTP_200_OK) + serializer = self.get_serializer(query) + return Response(serializer.data) def list(self, request: Request) -> Response: if request.user.is_authenticated: @@ -148,7 +175,7 @@ def list(self, request: Request) -> Response: Q(is_private=False) | Q(is_private=True, created_by=request.user) ) else: - query = self.queryset.filter() + query = self.queryset.filter(is_private=False) serializer = self.get_serializer(query, many=True) return self.get_paginated_response(self.paginate_queryset(serializer.data)) @@ -157,7 +184,7 @@ def update(self, request: Request, pk: str | None = None) -> Response: item = self.get_object() if item.created_by != request.user: return Response( - {"error": "You are not allowed to update this discussion entry."}, + {"error": "You are not allowed to update this resource."}, status=status.HTTP_403_FORBIDDEN, ) serializer = self.get_serializer(item, data=request.data) @@ -169,7 +196,7 @@ def partial_update(self, request: Request, pk: str | None = None) -> Response: item = self.get_object() if item.created_by != request.user: return Response( - {"error": "You are not allowed to update this discussion entry."}, + {"error": "You are not allowed to update this resource."}, status=status.HTTP_403_FORBIDDEN, ) serializer = self.get_serializer(item, data=request.data, partial=True) @@ -181,7 +208,7 @@ def destroy(self, request: Request, pk: str | None = None) -> Response: item = self.get_object() if item.created_by != request.user: return Response( - {"error": "You are not allowed to delete this discussion entry."}, + {"error": "You are not allowed to delete this resource."}, status=status.HTTP_403_FORBIDDEN, ) @@ -189,47 +216,48 @@ def destroy(self, request: Request, pk: str | None = None) -> Response: return Response(status=status.HTTP_204_NO_CONTENT) -class FaqViewSet(viewsets.ModelViewSet[Faq]): - queryset = Faq.objects.all() - serializer_class = FaqSerializer +class TaskViewSet(viewsets.ModelViewSet[Task]): + queryset = Task.objects.all() + serializer_class = TaskSerializer pagination_class = CustomPagination -class ImageViewSet(viewsets.ModelViewSet[Image]): - queryset = Image.objects.all() - serializer_class = ImageSerializer +class TopicViewSet(viewsets.ModelViewSet[Topic]): + queryset = Topic.objects.all() + serializer_class = TopicSerializer + pagination_class = CustomPagination -class ResourceViewSet(viewsets.ModelViewSet[Resource]): - queryset = Resource.objects.all() - serializer_class = ResourceSerializer +# MARK: Bridge Tables + + +class DiscussionEntryViewSet(viewsets.ModelViewSet[DiscussionEntry]): + queryset = DiscussionEntry.objects.all() + serializer_class = DiscussionEntrySerializer pagination_class = CustomPagination throttle_classes = [AnonRateThrottle, UserRateThrottle] permission_classes = [IsAuthenticatedOrReadOnly] def create(self, request: Request) -> Response: if request.user.is_authenticated: + request.data["created_by"] = request.user serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save(created_by=request.user) + serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) return Response( - {"error": "You are not allowed to create a resource."}, + {"error": "You are not allowed to create a discussion entry."}, status=status.HTTP_403_FORBIDDEN, ) def retrieve(self, request: Request, pk: str | None = None) -> Response: - if request.user.is_authenticated: - query = self.queryset.filter( - Q(is_private=False) | Q(is_private=True, created_by=request.user), id=pk - ) - else: - query = self.queryset.filter(Q(is_private=False), id=pk) + queryset = self.get_queryset() + item = queryset.filter(id=pk).first() - serializer = self.get_serializer(query) - return Response(serializer.data) + serializer = self.get_serializer(item) + return Response(serializer.data, status=status.HTTP_200_OK) def list(self, request: Request) -> Response: if request.user.is_authenticated: @@ -237,7 +265,7 @@ def list(self, request: Request) -> Response: Q(is_private=False) | Q(is_private=True, created_by=request.user) ) else: - query = self.queryset.filter(is_private=False) + query = self.queryset.filter() serializer = self.get_serializer(query, many=True) return self.get_paginated_response(self.paginate_queryset(serializer.data)) @@ -246,7 +274,7 @@ def update(self, request: Request, pk: str | None = None) -> Response: item = self.get_object() if item.created_by != request.user: return Response( - {"error": "You are not allowed to update this resource."}, + {"error": "You are not allowed to update this discussion entry."}, status=status.HTTP_403_FORBIDDEN, ) serializer = self.get_serializer(item, data=request.data) @@ -258,7 +286,7 @@ def partial_update(self, request: Request, pk: str | None = None) -> Response: item = self.get_object() if item.created_by != request.user: return Response( - {"error": "You are not allowed to update this resource."}, + {"error": "You are not allowed to update this discussion entry."}, status=status.HTTP_403_FORBIDDEN, ) serializer = self.get_serializer(item, data=request.data, partial=True) @@ -270,7 +298,7 @@ def destroy(self, request: Request, pk: str | None = None) -> Response: item = self.get_object() if item.created_by != request.user: return Response( - {"error": "You are not allowed to delete this resource."}, + {"error": "You are not allowed to delete this discussion entry."}, status=status.HTTP_403_FORBIDDEN, ) @@ -278,19 +306,6 @@ def destroy(self, request: Request, pk: str | None = None) -> Response: return Response(status=status.HTTP_204_NO_CONTENT) -class TaskViewSet(viewsets.ModelViewSet[Task]): - queryset = Task.objects.all() - serializer_class = TaskSerializer - pagination_class = CustomPagination - - -class TopicViewSet(viewsets.ModelViewSet[Topic]): - queryset = Topic.objects.all() - serializer_class = TopicSerializer - - pagination_class = CustomPagination - - class ResourceTopicViewSet(viewsets.ModelViewSet[ResourceTopic]): queryset = ResourceTopic.objects.all() serializer_class = ResourceTopicSerializer @@ -301,13 +316,3 @@ class TopicFormatViewSet(viewsets.ModelViewSet[TopicFormat]): queryset = TopicFormat.objects.all() serializer_class = TopicFormatSerializer pagination_class = CustomPagination - - -class IsoCodeMapListAPIView(ListAPIView[IsoCodeMap]): - queryset = IsoCodeMap.objects.all() - serializer_class = IsoCodeMapSerializer - - def get(self, request: Request) -> Response: - queryset = self.get_queryset() - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/entities/admin.py b/backend/entities/admin.py index 47e4003e5..9bec21206 100644 --- a/backend/entities/admin.py +++ b/backend/entities/admin.py @@ -6,6 +6,7 @@ GroupImage, GroupMember, GroupResource, + GroupText, GroupTopic, Organization, OrganizationApplication, @@ -14,22 +15,32 @@ OrganizationMember, OrganizationResource, OrganizationTask, + OrganizationText, OrganizationTopic, StatusType, ) +# MARK: Main Tables + admin.site.register(Group) +admin.site.register(Organization) + +# MARK: Bridge Tables + admin.site.register(GroupEvent) admin.site.register(GroupImage) admin.site.register(GroupMember) admin.site.register(GroupResource) +admin.site.register(GroupText) admin.site.register(GroupTopic) -admin.site.register(Organization) + admin.site.register(OrganizationApplication) admin.site.register(OrganizationEvent) admin.site.register(OrganizationImage) admin.site.register(OrganizationMember) admin.site.register(OrganizationResource) admin.site.register(OrganizationTask) +admin.site.register(OrganizationText) admin.site.register(OrganizationTopic) + admin.site.register(StatusType) diff --git a/backend/entities/factories.py b/backend/entities/factories.py index 56f5ef441..b7bb3692f 100644 --- a/backend/entities/factories.py +++ b/backend/entities/factories.py @@ -8,6 +8,7 @@ GroupImage, GroupMember, GroupResource, + GroupText, GroupTopic, Organization, OrganizationApplication, @@ -17,9 +18,12 @@ OrganizationMember, OrganizationResource, OrganizationTask, + OrganizationText, OrganizationTopic, ) +# MARK: Main Tables + class OrganizationFactory(factory.django.DjangoModelFactory): class Meta: @@ -33,6 +37,76 @@ class Meta: is_high_risk = factory.Faker("boolean") +class GroupFactory(factory.django.DjangoModelFactory): + class Meta: + model = Group + + org_id = factory.SubFactory(OrganizationFactory) + name = factory.Faker("word") + tagline = factory.Faker("word") + description = factory.Faker("text") + social_links = factory.List([factory.Faker("word") for _ in range(10)]) + created_by = factory.SubFactory("authentication.factories.UserFactory") + creation_date = factory.LazyFunction(datetime.datetime.now) + deletion_date = factory.LazyFunction(datetime.datetime.now) + + +# MARK: Bridge Tables + + +class GroupEventFactory(factory.django.DjangoModelFactory): + class Meta: + model = GroupEvent + + group_id = factory.SubFactory(GroupFactory) + event_id = factory.SubFactory("events.factories.EventFactory") + + +class GroupImageFactory(factory.django.DjangoModelFactory): + class Meta: + model = GroupImage + + group_id = factory.SubFactory(GroupFactory) + image_id = factory.SubFactory("content.factories.ImageFactory") + + +class GroupMemberFactory(factory.django.DjangoModelFactory): + class Meta: + model = GroupMember + + group_id = factory.SubFactory(GroupFactory) + user_id = factory.SubFactory("authentication.factories.UserFactory") + is_admin = factory.Faker("boolean") + + +class GroupResourceFactory(factory.django.DjangoModelFactory): + class Meta: + model = GroupResource + + group_id = factory.SubFactory(GroupFactory) + resource_id = factory.SubFactory("content.factories.ResourceFactory") + + +class GroupTextFactory(factory.django.DjangoModelFactory): + class Meta: + model = GroupText + + group_id = factory.SubFactory(GroupFactory) + iso = factory.Faker("word") + primary = factory.Fakeer("boolean") + description = factory.Faker("text") + get_involved = factory.Faker("text") + donate_prompt = factory.Faker("text") + + +class GroupTopicFactory(factory.django.DjangoModelFactory): + class Meta: + model = GroupTopic + + group_id = factory.SubFactory(GroupFactory) + topic_id = factory.SubFactory("content.factories.TopicFactory") + + class OrganizationApplicationStatusFactory(factory.django.DjangoModelFactory): class Meta: model = OrganizationApplicationStatus @@ -87,20 +161,6 @@ class Meta: resource_id = factory.SubFactory("content.factories.ResourceFactory") -class GroupFactory(factory.django.DjangoModelFactory): - class Meta: - model = Group - - org_id = factory.SubFactory(OrganizationFactory) - name = factory.Faker("word") - tagline = factory.Faker("word") - description = factory.Faker("text") - social_links = factory.List([factory.Faker("word") for _ in range(10)]) - created_by = factory.SubFactory("authentication.factories.UserFactory") - creation_date = factory.LazyFunction(datetime.datetime.now) - deletion_date = factory.LazyFunction(datetime.datetime.now) - - class OrganizationTaskFactory(factory.django.DjangoModelFactory): class Meta: model = OrganizationTask @@ -110,50 +170,21 @@ class Meta: group_id = factory.SubFactory(GroupFactory) -class OrganizationTopicFactory(factory.django.DjangoModelFactory): +class OrganizationTextFactory(factory.django.DjangoModelFactory): class Meta: - model = OrganizationTopic + model = OrganizationText org_id = factory.SubFactory(OrganizationFactory) - topic_id = factory.SubFactory("content.factories.TopicFactory") - - -class GroupEventFactory(factory.django.DjangoModelFactory): - class Meta: - model = GroupEvent - - group_id = factory.SubFactory(GroupFactory) - event_id = factory.SubFactory("events.factories.EventFactory") - - -class GroupImageFactory(factory.django.DjangoModelFactory): - class Meta: - model = GroupImage - - group_id = factory.SubFactory(GroupFactory) - image_id = factory.SubFactory("content.factories.ImageFactory") - - -class GroupMemberFactory(factory.django.DjangoModelFactory): - class Meta: - model = GroupMember - - group_id = factory.SubFactory(GroupFactory) - user_id = factory.SubFactory("authentication.factories.UserFactory") - is_admin = factory.Faker("boolean") - - -class GroupResourceFactory(factory.django.DjangoModelFactory): - class Meta: - model = GroupResource - - group_id = factory.SubFactory(GroupFactory) - resource_id = factory.SubFactory("content.factories.ResourceFactory") + iso = factory.Faker("word") + primary = factory.Fakeer("boolean") + description = factory.Faker("text") + get_involved = factory.Faker("text") + donate_prompt = factory.Faker("text") -class GroupTopicFactory(factory.django.DjangoModelFactory): +class OrganizationTopicFactory(factory.django.DjangoModelFactory): class Meta: - model = GroupTopic + model = OrganizationTopic - group_id = factory.SubFactory(GroupFactory) + org_id = factory.SubFactory(OrganizationFactory) topic_id = factory.SubFactory("content.factories.TopicFactory") diff --git a/backend/entities/models.py b/backend/entities/models.py index 2257b0297..5f72bc985 100644 --- a/backend/entities/models.py +++ b/backend/entities/models.py @@ -7,27 +7,38 @@ from django.contrib.postgres.fields import ArrayField from django.db import models +from authentication import enums + +# MARK: Main Tables + class Organization(models.Model): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) - name = models.CharField(max_length=255, unique=True) + name = models.CharField(max_length=255) tagline = models.CharField(max_length=255, blank=True) icon_url = models.OneToOneField( "content.Image", on_delete=models.CASCADE, null=True, blank=True ) + location = models.CharField(max_length=255) + # location_id = models.OneToOneField( + # "content.Location", on_delete=models.CASCADE, null=False, blank=False + # ) created_by = models.ForeignKey( "authentication.UserModel", related_name="created_orgs", on_delete=models.CASCADE, ) - description = models.TextField(max_length=500) social_links = ArrayField( models.CharField(max_length=255), default=list, blank=True ) get_involved_url = models.URLField(blank=True) is_high_risk = models.BooleanField(default=False) status = models.ForeignKey( - "StatusType", on_delete=models.CASCADE, default=1, blank=True, null=True + "StatusType", + on_delete=models.CASCADE, + default=enums.StatusTypes.PENDING.value, + blank=True, + null=True, ) status_updated = models.DateTimeField(auto_now=True, null=True) acceptance_date = models.DateTimeField(null=True, blank=True) @@ -37,39 +48,54 @@ def __str__(self) -> str: return self.name -class OrganizationApplicationStatus(models.Model): - id = models.IntegerField(primary_key=True) - status_name = models.CharField(max_length=255) +class Group(models.Model): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + name = models.CharField(max_length=255) + tagline = models.CharField(max_length=255, blank=True) + org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) + location = models.CharField(max_length=255) + # location_id = models.OneToOneField( + # "content.Location", on_delete=models.CASCADE, null=False, blank=False + # ) + about_images = models.ManyToManyField( + "content.Image", related_name="about_img", blank=True + ) + created_by = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) + get_involved_url = models.URLField(blank=True) + social_links = ArrayField( + models.CharField(max_length=255), default=list, blank=True + ) + category = models.CharField(max_length=255) + creation_date = models.DateTimeField(auto_now_add=True) def __str__(self) -> str: - return self.status_name + return self.name -class OrganizationApplication(models.Model): - org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) - status = models.ForeignKey("StatusType", on_delete=models.CASCADE, default=1) - orgs_in_favor = models.ManyToManyField( - "entities.Organization", related_name="in_favor", blank=True - ) - orgs_against = models.ManyToManyField( - "entities.Organization", related_name="against", blank=True +class Status(models.Model): + status_type = models.ForeignKey("StatusType", on_delete=models.CASCADE) + org_id = models.ForeignKey( + Organization, on_delete=models.CASCADE, related_name="org_status" ) - creation_date = models.DateTimeField(auto_now_add=True) + user_id = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) def __str__(self) -> str: - return f"{self.creation_date}" + return f"{self.org_id.name} - {self.status_type}" -class OrganizationEvent(models.Model): - org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) +# MARK: Bridge Tables + + +class GroupEvent(models.Model): + group_id = models.ForeignKey(Group, on_delete=models.CASCADE) event_id = models.ForeignKey("events.Event", on_delete=models.CASCADE) def __str__(self) -> str: return f"{self.id}" -class OrganizationImage(models.Model): - org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) +class GroupImage(models.Model): + group_id = models.ForeignKey(Group, on_delete=models.CASCADE) image_id = models.ForeignKey("content.Image", on_delete=models.CASCADE) sequence_index = models.IntegerField() @@ -77,8 +103,8 @@ def __str__(self) -> str: return f"{self.id}" -class OrganizationMember(models.Model): - org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) +class GroupMember(models.Model): + group_id = models.ForeignKey(Group, on_delete=models.CASCADE) user_id = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) is_owner = models.BooleanField(default=False) is_admin = models.BooleanField(default=False) @@ -88,73 +114,64 @@ def __str__(self) -> str: return f"{self.id}" -class OrganizationResource(models.Model): - org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) +class GroupResource(models.Model): + group_id = models.ForeignKey(Group, on_delete=models.CASCADE) resource_id = models.ForeignKey("content.Resource", on_delete=models.CASCADE) def __str__(self) -> str: return f"{self.id}" -class Group(models.Model): - id = models.UUIDField(primary_key=True, default=uuid4, editable=False) - org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) - name = models.CharField(max_length=255) - tagline = models.CharField(max_length=255, blank=True) - about_images = models.ManyToManyField( - "content.Image", related_name="about_img", blank=True - ) - created_by = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) - get_involved_url = models.URLField(blank=True) +class GroupText(models.Model): + group_id = models.ForeignKey(Group, on_delete=models.CASCADE) + iso = models.ForeignKey("content.IsoCodeMap", on_delete=models.CASCADE) + primary = models.BooleanField(default=False) description = models.TextField(max_length=500) - social_links = ArrayField( - models.CharField(max_length=255), default=list, blank=True - ) - category = models.CharField(max_length=255) - creation_date = models.DateTimeField(auto_now_add=True) + get_involved = models.TextField(max_length=500, blank=True) + donate_prompt = models.TextField(max_length=500, blank=True) + + +class GroupTopic(models.Model): + group_id = models.ForeignKey(Group, on_delete=models.CASCADE) + topic_id = models.ForeignKey("content.Topic", on_delete=models.CASCADE) def __str__(self) -> str: - return self.name + return f"{self.id}" -class OrganizationTask(models.Model): +class OrganizationApplication(models.Model): org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) - task_id = models.ForeignKey("content.Task", on_delete=models.CASCADE) - group_id = models.ForeignKey( - "Group", on_delete=models.CASCADE, null=True, blank=True + status = models.ForeignKey("StatusType", on_delete=models.CASCADE, default=1) + orgs_in_favor = models.ManyToManyField( + "entities.Organization", related_name="in_favor", blank=True + ) + orgs_against = models.ManyToManyField( + "entities.Organization", related_name="against", blank=True ) + creation_date = models.DateTimeField(auto_now_add=True) def __str__(self) -> str: - return f"{self.id}" + return f"{self.creation_date}" -class OrganizationTopic(models.Model): - org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) - topic_id = models.ForeignKey("content.Topic", on_delete=models.CASCADE) +class OrganizationApplicationStatus(models.Model): + id = models.IntegerField(primary_key=True) + status_name = models.CharField(max_length=255) def __str__(self) -> str: - return f"{self.id}" + return self.status_name -class OrganizationText(models.Model): +class OrganizationEvent(models.Model): org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) - iso = models.ForeignKey("content.IsoCodeMap", on_delete=models.CASCADE) - primary = models.BooleanField(default=False) - description = models.TextField(max_length=500) - get_involved = models.TextField(max_length=500) - donate_prompt = models.TextField(max_length=500) - - -class GroupEvent(models.Model): - group_id = models.ForeignKey(Group, on_delete=models.CASCADE) event_id = models.ForeignKey("events.Event", on_delete=models.CASCADE) def __str__(self) -> str: return f"{self.id}" -class GroupImage(models.Model): - group_id = models.ForeignKey(Group, on_delete=models.CASCADE) +class OrganizationImage(models.Model): + org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) image_id = models.ForeignKey("content.Image", on_delete=models.CASCADE) sequence_index = models.IntegerField() @@ -162,8 +179,8 @@ def __str__(self) -> str: return f"{self.id}" -class GroupMember(models.Model): - group_id = models.ForeignKey(Group, on_delete=models.CASCADE) +class OrganizationMember(models.Model): + org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) user_id = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) is_owner = models.BooleanField(default=False) is_admin = models.BooleanField(default=False) @@ -173,40 +190,40 @@ def __str__(self) -> str: return f"{self.id}" -class GroupResource(models.Model): - group_id = models.ForeignKey(Group, on_delete=models.CASCADE) +class OrganizationResource(models.Model): + org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) resource_id = models.ForeignKey("content.Resource", on_delete=models.CASCADE) def __str__(self) -> str: return f"{self.id}" -class GroupTopic(models.Model): - group_id = models.ForeignKey(Group, on_delete=models.CASCADE) - topic_id = models.ForeignKey("content.Topic", on_delete=models.CASCADE) +class OrganizationTask(models.Model): + org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) + task_id = models.ForeignKey("content.Task", on_delete=models.CASCADE) + group_id = models.ForeignKey( + "Group", on_delete=models.CASCADE, null=True, blank=True + ) def __str__(self) -> str: return f"{self.id}" -class GroupText(models.Model): - group_id = models.ForeignKey(Group, on_delete=models.CASCADE) +class OrganizationText(models.Model): + org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) iso = models.ForeignKey("content.IsoCodeMap", on_delete=models.CASCADE) primary = models.BooleanField(default=False) - description = models.TextField(max_length=500) - get_involved = models.TextField(max_length=500) - donate_prompt = models.TextField(max_length=500) + description = models.TextField(max_length=2500) + get_involved = models.TextField(max_length=500, blank=True) + donate_prompt = models.TextField(max_length=500, blank=True) -class Status(models.Model): - status_type = models.ForeignKey("StatusType", on_delete=models.CASCADE) - org_id = models.ForeignKey( - Organization, on_delete=models.CASCADE, related_name="org_status" - ) - user_id = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) +class OrganizationTopic(models.Model): + org_id = models.ForeignKey(Organization, on_delete=models.CASCADE) + topic_id = models.ForeignKey("content.Topic", on_delete=models.CASCADE) def __str__(self) -> str: - return f"{self.org_id.name} - {self.status_type}" + return f"{self.id}" class StatusType(models.Model): diff --git a/backend/entities/serializers.py b/backend/entities/serializers.py index a01f3c9b7..8b316d141 100644 --- a/backend/entities/serializers.py +++ b/backend/entities/serializers.py @@ -10,6 +10,7 @@ GroupImage, GroupMember, GroupResource, + GroupText, GroupTopic, Organization, OrganizationApplication, @@ -18,13 +19,30 @@ OrganizationMember, OrganizationResource, OrganizationTask, + OrganizationText, OrganizationTopic, Status, StatusType, ) +# MARK: Main Tables + + +class GroupSerializer(serializers.ModelSerializer[Group]): + class Meta: + model = Group + fields = "__all__" + + +class OrganizationTextSerializer(serializers.ModelSerializer[OrganizationText]): + class Meta: + model = OrganizationText + fields = "__all__" + class OrganizationSerializer(serializers.ModelSerializer[Organization]): + organization_text = OrganizationTextSerializer(read_only=True) + class Meta: model = Organization extra_kwargs = { @@ -38,99 +56,103 @@ class Meta: "name", "tagline", "icon_url", + "location", "created_by", - "description", "social_links", "is_high_risk", "status", "status_updated", "acceptance_date", + "organization_text", ] -class OrganizationApplicationSerializer( - serializers.ModelSerializer[OrganizationApplication] -): +class StatusSerializer(serializers.ModelSerializer[Status]): class Meta: - model = OrganizationApplication + model = Status fields = "__all__" -class OrganizationEventSerializer(serializers.ModelSerializer[OrganizationEvent]): +# MARK: Bridge Tables + + +class GroupEventSerializer(serializers.ModelSerializer[GroupEvent]): class Meta: - model = OrganizationEvent + model = GroupEvent fields = "__all__" -class OrganizationMemberSerializer(serializers.ModelSerializer[OrganizationMember]): +class GroupImageSerializer(serializers.ModelSerializer[GroupImage]): class Meta: - model = OrganizationMember + model = GroupImage fields = "__all__" -class OrganizationImageSerializer(serializers.ModelSerializer[OrganizationImage]): +class GroupMemberSerializer(serializers.ModelSerializer[GroupMember]): class Meta: - model = OrganizationImage + model = GroupMember fields = "__all__" -class OrganizationResourceSerializer(serializers.ModelSerializer[OrganizationResource]): +class GroupResourceSerializer(serializers.ModelSerializer[GroupResource]): class Meta: - model = OrganizationResource + model = GroupResource fields = "__all__" -class GroupSerializer(serializers.ModelSerializer[Group]): +class GroupTextSerializer(serializers.ModelSerializer[GroupText]): class Meta: - model = Group + model = GroupText fields = "__all__" -class GroupImageSerializer(serializers.ModelSerializer[GroupImage]): +class GroupTopicSerializer(serializers.ModelSerializer[GroupTopic]): class Meta: - model = GroupImage + model = GroupTopic fields = "__all__" -class OrganizationTaskSerializer(serializers.ModelSerializer[OrganizationTask]): +class OrganizationApplicationSerializer( + serializers.ModelSerializer[OrganizationApplication] +): class Meta: - model = OrganizationTask + model = OrganizationApplication fields = "__all__" -class OrganizationTopicSerializer(serializers.ModelSerializer[OrganizationTopic]): +class OrganizationEventSerializer(serializers.ModelSerializer[OrganizationEvent]): class Meta: - model = OrganizationTopic + model = OrganizationEvent fields = "__all__" -class GroupEventSerializer(serializers.ModelSerializer[GroupEvent]): +class OrganizationMemberSerializer(serializers.ModelSerializer[OrganizationMember]): class Meta: - model = GroupEvent + model = OrganizationMember fields = "__all__" -class GroupMemberSerializer(serializers.ModelSerializer[GroupMember]): +class OrganizationImageSerializer(serializers.ModelSerializer[OrganizationImage]): class Meta: - model = GroupMember + model = OrganizationImage fields = "__all__" -class GroupResourceSerializer(serializers.ModelSerializer[GroupResource]): +class OrganizationResourceSerializer(serializers.ModelSerializer[OrganizationResource]): class Meta: - model = GroupResource + model = OrganizationResource fields = "__all__" -class GroupTopicSerializer(serializers.ModelSerializer[GroupTopic]): +class OrganizationTaskSerializer(serializers.ModelSerializer[OrganizationTask]): class Meta: - model = GroupTopic + model = OrganizationTask fields = "__all__" -class StatusSerializer(serializers.ModelSerializer[Status]): +class OrganizationTopicSerializer(serializers.ModelSerializer[OrganizationTopic]): class Meta: - model = Status + model = OrganizationTopic fields = "__all__" diff --git a/backend/entities/tests.py b/backend/entities/tests.py index 29fcfc4bc..f65ac809c 100644 --- a/backend/entities/tests.py +++ b/backend/entities/tests.py @@ -2,6 +2,7 @@ Testing for the entities app. """ +# mypy: ignore-errors from django.urls import reverse from tests.throttle import BaseTestThrottle diff --git a/backend/entities/urls.py b/backend/entities/urls.py index d73d03c47..0ac217c37 100644 --- a/backend/entities/urls.py +++ b/backend/entities/urls.py @@ -7,21 +7,30 @@ router = DefaultRouter() +# MARK: Main Tables + +router.register(r"groups", views.GroupViewSet) router.register(r"organizations", views.OrganizationViewSet, basename="organization") +router.register(r"status", views.StatusViewSet) + +# MARK: Bridge Tables + +router.register(r"group_events", views.GroupEventViewSet) +router.register(r"group_images", views.GroupImageViewSet) +router.register(r"group_members", views.GroupMemberViewSet) +router.register(r"group_resources", views.GroupResourceViewSet) +router.register(r"group_texts", views.GroupTextViewSet) +router.register(r"group_topics", views.GroupTopicViewSet) + router.register(r"organization_applications", views.OrganizationApplicationViewSet) router.register(r"organization_events", views.OrganizationEventViewSet) router.register(r"organization_images", views.OrganizationImageViewSet) router.register(r"organization_members", views.OrganizationMemberViewSet) router.register(r"organization_resources", views.OrganizationResourceViewSet) -router.register(r"groups", views.GroupViewSet) router.register(r"organization_tasks", views.OrganizationTaskViewSet) +router.register(r"organization_texts", views.OrganizationTextViewSet) router.register(r"organization_topics", views.OrganizationTopicViewSet) -router.register(r"group_events", views.GroupEventViewSet) -router.register(r"group_images", views.GroupImageViewSet) -router.register(r"group_members", views.GroupMemberViewSet) -router.register(r"group_resources", views.GroupResourceViewSet) -router.register(r"group_topics", views.GroupTopicViewSet) -router.register(r"status", views.StatusViewSet) + router.register(r"status_type", views.StatusTypeViewSet) urlpatterns = [ diff --git a/backend/entities/views.py b/backend/entities/views.py index 3c9ae3f61..972b0d5f0 100644 --- a/backend/entities/views.py +++ b/backend/entities/views.py @@ -15,6 +15,7 @@ GroupImage, GroupMember, GroupResource, + GroupText, GroupTopic, Organization, OrganizationApplication, @@ -23,6 +24,7 @@ OrganizationMember, OrganizationResource, OrganizationTask, + OrganizationText, OrganizationTopic, Status, StatusType, @@ -33,6 +35,7 @@ GroupMemberSerializer, GroupResourceSerializer, GroupSerializer, + GroupTextSerializer, GroupTopicSerializer, OrganizationApplicationSerializer, OrganizationEventSerializer, @@ -41,11 +44,72 @@ OrganizationResourceSerializer, OrganizationSerializer, OrganizationTaskSerializer, + OrganizationTextSerializer, OrganizationTopicSerializer, StatusSerializer, StatusTypeSerializer, ) +# MARK: Main Tables + + +class GroupViewSet(viewsets.ModelViewSet[Group]): + queryset = Group.objects.all() + serializer_class = GroupSerializer + pagination_class = CustomPagination + + def list(self, request: Request, *args: str, **kwargs: int) -> Response: + serializer = self.get_serializer(self.queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def create(self, request: Request) -> Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(created_by=request.user) + data = {"message": f"New Group created: {serializer.data}"} + return Response(data, status=status.HTTP_201_CREATED) + + def retrieve(self, request: Request, *args: str, **kwargs: int) -> Response: + group = self.queryset.get(id=kwargs["pk"]) + serializer = self.get_serializer(group) + + return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request: Request, *args: str, **kwargs: int) -> Response: + group = self.queryset.filter(id=kwargs["pk"]).first() + + if group is None: + return Response( + {"error": "Group not found"}, status=status.HTTP_404_NOT_FOUND + ) + if request.user != group.created_by: + return Response( + {"error": "You are not authorized to update this group"}, + status.HTTP_401_UNAUTHORIZED, + ) + + serializer = self.get_serializer(group, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request: Request, *args: str, **kwargs: int) -> Response: + group = self.queryset.filter(id=kwargs["pk"]).first() + + if group is None: + return Response( + {"error": "Group not found"}, status=status.HTTP_404_NOT_FOUND + ) + if request.user != group.created_by: + return Response( + {"error": "You are not authorized to delete this group"}, + status.HTTP_401_UNAUTHORIZED, + ) + group.delete() + return Response( + {"message": "Group deleted successfully"}, status=status.HTTP_200_OK + ) + class OrganizationViewSet(viewsets.ModelViewSet[Organization]): queryset = Organization.objects.all() @@ -67,8 +131,7 @@ def create(self, request: Request) -> Response: return Response(serializer.data, status=status.HTTP_201_CREATED) def retrieve(self, request: Request, pk: str | None = None) -> Response: - org = self.queryset.filter(id=pk).first() - if org: + if org := self.queryset.filter(id=pk).first(): serializer = self.get_serializer(org) return Response(serializer.data, status=status.HTTP_200_OK) @@ -116,7 +179,6 @@ def partial_update(self, request: Request, pk: str | None = None) -> Response: def destroy(self, request: Request, pk: str | None = None) -> Response: org = self.queryset.filter(id=pk).first() - print(pk, org) if org is None: return Response( {"error": "Organization not found"}, status.HTTP_404_NOT_FOUND @@ -133,7 +195,6 @@ def destroy(self, request: Request, pk: str | None = None) -> Response: org.is_high_risk = False org.status_updated = None org.tagline = "" - org.description = "" org.social_links = [] org.save() @@ -142,32 +203,18 @@ def destroy(self, request: Request, pk: str | None = None) -> Response: ) -class OrganizationApplicationViewSet(viewsets.ModelViewSet[OrganizationApplication]): - queryset = OrganizationApplication.objects.all() - serializer_class = OrganizationApplicationSerializer - pagination_class = CustomPagination - - -class OrganizationEventViewSet(viewsets.ModelViewSet[OrganizationEvent]): - queryset = OrganizationEvent.objects.all() - serializer_class = OrganizationEventSerializer - pagination_class = CustomPagination - - -class OrganizationMemberViewSet(viewsets.ModelViewSet[OrganizationMember]): - queryset = OrganizationMember.objects.all() - serializer_class = OrganizationMemberSerializer +class StatusViewSet(viewsets.ModelViewSet[Status]): + queryset = Status.objects.all() + serializer_class = StatusSerializer pagination_class = CustomPagination -class OrganizationImageViewSet(viewsets.ModelViewSet[OrganizationImage]): - queryset = OrganizationImage.objects.all() - serializer_class = OrganizationImageSerializer +# MARK: Bridge Tables -class OrganizationResourceViewSet(viewsets.ModelViewSet[OrganizationResource]): - queryset = OrganizationResource.objects.all() - serializer_class = OrganizationResourceSerializer +class GroupEventViewSet(viewsets.ModelViewSet[GroupEvent]): + queryset = GroupEvent.objects.all() + serializer_class = GroupEventSerializer pagination_class = CustomPagination @@ -177,103 +224,74 @@ class GroupImageViewSet(viewsets.ModelViewSet[GroupImage]): pagination_class = CustomPagination -class GroupViewSet(viewsets.ModelViewSet[Group]): - queryset = Group.objects.all() - serializer_class = GroupSerializer +class GroupMemberViewSet(viewsets.ModelViewSet[GroupMember]): + queryset = GroupMember.objects.all() + serializer_class = GroupMemberSerializer pagination_class = CustomPagination - def list(self, request: Request, *args: str, **kwargs: int) -> Response: - serializer = self.get_serializer(self.queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def create(self, request: Request) -> Response: - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save(created_by=request.user) - data = {"message": f"New Group created: {serializer.data}"} - return Response(data, status=status.HTTP_201_CREATED) - def retrieve(self, request: Request, *args: str, **kwargs: int) -> Response: - group = self.queryset.get(id=kwargs["pk"]) - serializer = self.get_serializer(group) +class GroupResourceViewSet(viewsets.ModelViewSet[GroupResource]): + queryset = GroupResource.objects.all() + serializer_class = GroupResourceSerializer + pagination_class = CustomPagination - return Response(serializer.data, status=status.HTTP_200_OK) - def partial_update(self, request: Request, *args: str, **kwargs: int) -> Response: - group = self.queryset.filter(id=kwargs["pk"]).first() +class GroupTextViewSet(viewsets.ModelViewSet[GroupText]): + queryset = GroupText.objects.all() + serializer_class = GroupTextSerializer + pagination_class = CustomPagination - if group is None: - return Response( - {"error": "Group not found"}, status=status.HTTP_404_NOT_FOUND - ) - if request.user != group.created_by: - return Response( - {"error": "You are not authorized to update this group"}, - status.HTTP_401_UNAUTHORIZED, - ) - serializer = self.get_serializer(group, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) +class GroupTopicViewSet(viewsets.ModelViewSet[GroupTopic]): + queryset = GroupTopic.objects.all() + serializer_class = GroupTopicSerializer + pagination_class = CustomPagination - def destroy(self, request: Request, *args: str, **kwargs: int) -> Response: - group = self.queryset.filter(id=kwargs["pk"]).first() - if group is None: - return Response( - {"error": "Group not found"}, status=status.HTTP_404_NOT_FOUND - ) - if request.user != group.created_by: - return Response( - {"error": "You are not authorized to delete this group"}, - status.HTTP_401_UNAUTHORIZED, - ) - group.delete() - return Response( - {"message": "Group deleted successfully"}, status=status.HTTP_200_OK - ) +class OrganizationApplicationViewSet(viewsets.ModelViewSet[OrganizationApplication]): + queryset = OrganizationApplication.objects.all() + serializer_class = OrganizationApplicationSerializer + pagination_class = CustomPagination -class OrganizationTaskViewSet(viewsets.ModelViewSet[OrganizationTask]): - queryset = OrganizationTask.objects.all() - serializer_class = OrganizationTaskSerializer +class OrganizationEventViewSet(viewsets.ModelViewSet[OrganizationEvent]): + queryset = OrganizationEvent.objects.all() + serializer_class = OrganizationEventSerializer pagination_class = CustomPagination -class OrganizationTopicViewSet(viewsets.ModelViewSet[OrganizationTopic]): - queryset = OrganizationTopic.objects.all() - serializer_class = OrganizationTopicSerializer +class OrganizationMemberViewSet(viewsets.ModelViewSet[OrganizationMember]): + queryset = OrganizationMember.objects.all() + serializer_class = OrganizationMemberSerializer pagination_class = CustomPagination -class GroupEventViewSet(viewsets.ModelViewSet[GroupEvent]): - queryset = GroupEvent.objects.all() - serializer_class = GroupEventSerializer - pagination_class = CustomPagination +class OrganizationImageViewSet(viewsets.ModelViewSet[OrganizationImage]): + queryset = OrganizationImage.objects.all() + serializer_class = OrganizationImageSerializer -class GroupMemberViewSet(viewsets.ModelViewSet[GroupMember]): - queryset = GroupMember.objects.all() - serializer_class = GroupMemberSerializer +class OrganizationResourceViewSet(viewsets.ModelViewSet[OrganizationResource]): + queryset = OrganizationResource.objects.all() + serializer_class = OrganizationResourceSerializer pagination_class = CustomPagination -class GroupResourceViewSet(viewsets.ModelViewSet[GroupResource]): - queryset = GroupResource.objects.all() - serializer_class = GroupResourceSerializer +class OrganizationTaskViewSet(viewsets.ModelViewSet[OrganizationTask]): + queryset = OrganizationTask.objects.all() + serializer_class = OrganizationTaskSerializer pagination_class = CustomPagination -class GroupTopicViewSet(viewsets.ModelViewSet[GroupTopic]): - queryset = GroupTopic.objects.all() - serializer_class = GroupTopicSerializer +class OrganizationTextViewSet(viewsets.ModelViewSet[OrganizationText]): + queryset = OrganizationText.objects.all() + serializer_class = OrganizationTextSerializer pagination_class = CustomPagination -class StatusViewSet(viewsets.ModelViewSet[Status]): - queryset = Status.objects.all() - serializer_class = StatusSerializer +class OrganizationTopicViewSet(viewsets.ModelViewSet[OrganizationTopic]): + queryset = OrganizationTopic.objects.all() + serializer_class = OrganizationTopicSerializer pagination_class = CustomPagination diff --git a/backend/events/admin.py b/backend/events/admin.py index 78472e867..276e7e2c3 100644 --- a/backend/events/admin.py +++ b/backend/events/admin.py @@ -14,7 +14,14 @@ Role, ) +# MARK: Main Tables + admin.site.register(Event) +admin.site.register(Format) +admin.site.register(Role) + +# MARK: Bridge Tables + admin.site.register(EventAttendee) admin.site.register(EventAttendeeStatus) admin.site.register(EventResource) @@ -23,5 +30,3 @@ admin.site.register(EventText) admin.site.register(EventTopic) admin.site.register(EventFormat) -admin.site.register(Format) -admin.site.register(Role) diff --git a/backend/events/factories.py b/backend/events/factories.py index 8200a8afc..80ab35ca5 100644 --- a/backend/events/factories.py +++ b/backend/events/factories.py @@ -15,6 +15,8 @@ Role, ) +# MARK: Main Tables + class EventFactory(factory.django.DjangoModelFactory): class Meta: @@ -58,6 +60,9 @@ class Meta: deprecation_date = factory.Faker("future_date", end_date="+30d") +# MARK: Bridge Tables + + class EventAttendeeFactory(factory.django.DjangoModelFactory): class Meta: model = EventAttendee @@ -68,19 +73,19 @@ class Meta: attendee_status = factory.SubFactory("events.factories.EventAttendeeStatusFactory") -class EventFormatFactory(factory.django.DjangoModelFactory): +class EventAttendeeStatusFactory(factory.django.DjangoModelFactory): class Meta: - model = EventFormat + model = EventAttendeeStatus - event_id = factory.SubFactory(EventFactory) - format_id = factory.SubFactory(FormatFactory) + status_name = factory.Faker("word") -class EventAttendeeStatusFactory(factory.django.DjangoModelFactory): +class EventFormatFactory(factory.django.DjangoModelFactory): class Meta: - model = EventAttendeeStatus + model = EventFormat - status_name = factory.Faker("word") + event_id = factory.SubFactory(EventFactory) + format_id = factory.SubFactory(FormatFactory) class EventResourceFactory(factory.django.DjangoModelFactory): diff --git a/backend/events/models.py b/backend/events/models.py index a36e9f883..8317857a3 100644 --- a/backend/events/models.py +++ b/backend/events/models.py @@ -9,6 +9,8 @@ from backend.mixins.models import CreationDeletionMixin +# MARK: Main Tables + class Event(CreationDeletionMixin): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) @@ -23,8 +25,6 @@ class Event(CreationDeletionMixin): "content.Image", on_delete=models.CASCADE, blank=True, null=True ) type = models.CharField(max_length=255) - description = models.TextField(max_length=500) - get_involved_text = models.TextField(max_length=500) online_location_link = models.CharField(max_length=255, blank=True) offline_location_lat = models.FloatField(null=True, blank=True) offline_location_long = models.FloatField(null=True, blank=True) @@ -65,6 +65,9 @@ def __str__(self) -> str: return self.name +# MARK: Bridge Tables + + class EventAttendee(models.Model): event_id = models.ForeignKey(Event, on_delete=models.CASCADE) user_id = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) @@ -77,14 +80,6 @@ def __str__(self) -> str: return f"{self.user_id} - {self.event_id}" -class EventFormat(models.Model): - event_id = models.ForeignKey(Event, on_delete=models.CASCADE) - format_id = models.ForeignKey("Format", on_delete=models.CASCADE) - - def __str__(self) -> str: - return f"{self.id}" - - class EventAttendeeStatus(models.Model): id = models.IntegerField(primary_key=True) status_name = models.CharField(max_length=255) @@ -93,6 +88,14 @@ def __str__(self) -> str: return self.status_name +class EventFormat(models.Model): + event_id = models.ForeignKey(Event, on_delete=models.CASCADE) + format_id = models.ForeignKey("Format", on_delete=models.CASCADE) + + def __str__(self) -> str: + return f"{self.id}" + + class EventResource(models.Model): event_id = models.ForeignKey(Event, on_delete=models.CASCADE) resource_id = models.ForeignKey("content.Resource", on_delete=models.CASCADE) @@ -109,6 +112,14 @@ def __str__(self) -> str: return f"{self.id}" +class EventTag(models.Model): + event_id = models.ForeignKey(Event, on_delete=models.CASCADE) + tag_id = models.ForeignKey("content.Tag", on_delete=models.CASCADE) + + def __str__(self) -> str: + return f"{self.id}" + + class EventTask(models.Model): event_id = models.ForeignKey(Event, on_delete=models.CASCADE) task_id = models.ForeignKey("content.Task", on_delete=models.CASCADE) @@ -124,7 +135,7 @@ class EventText(models.Model): ) primary = models.BooleanField() description = models.TextField(max_length=500) - get_involved = models.TextField(max_length=500) + get_involved = models.TextField(max_length=500, blank=True) def __str__(self) -> str: return f"{self.id}" @@ -136,11 +147,3 @@ class EventTopic(models.Model): def __str__(self) -> str: return f"{self.id}" - - -class EventTag(models.Model): - event_id = models.ForeignKey(Event, on_delete=models.CASCADE) - tag_id = models.ForeignKey("content.Tag", on_delete=models.CASCADE) - - def __str__(self) -> str: - return f"{self.id}" diff --git a/backend/events/serializers.py b/backend/events/serializers.py index 5668edcf1..144c1f59a 100644 --- a/backend/events/serializers.py +++ b/backend/events/serializers.py @@ -28,6 +28,8 @@ Role, ) +# MARK: Main Tables + class EventSerializer(serializers.ModelSerializer[Event]): class Meta: @@ -68,21 +70,24 @@ def validate(self, data: Dict[str, Union[str, int]]) -> Dict[str, Union[str, int return data +# MARK: Bridge Tables + + class EventAttendeeSerializer(serializers.ModelSerializer[EventAttendee]): class Meta: model = EventAttendee fields = "__all__" -class EventFormatSerializer(serializers.ModelSerializer[EventFormat]): +class EventAttendeeStatusSerializer(serializers.ModelSerializer[EventAttendeeStatus]): class Meta: - model = EventFormat + model = EventAttendeeStatus fields = "__all__" -class EventAttendeeStatusSerializer(serializers.ModelSerializer[EventAttendeeStatus]): +class EventFormatSerializer(serializers.ModelSerializer[EventFormat]): class Meta: - model = EventAttendeeStatus + model = EventFormat fields = "__all__" @@ -98,6 +103,12 @@ class Meta: fields = "__all__" +class EventTagSerializer(serializers.ModelSerializer[EventTag]): + class Meta: + model = EventTag + fields = "__all__" + + class EventTaskSerializer(serializers.ModelSerializer[EventTask]): class Meta: model = EventTask @@ -114,9 +125,3 @@ class EventTopicSerializer(serializers.ModelSerializer[EventTopic]): class Meta: model = EventTopic fields = "__all__" - - -class EventTagSerializer(serializers.ModelSerializer[EventTag]): - class Meta: - model = EventTag - fields = "__all__" diff --git a/backend/events/tests.py b/backend/events/tests.py index 9858bf657..d5d96883e 100644 --- a/backend/events/tests.py +++ b/backend/events/tests.py @@ -2,6 +2,7 @@ Testing for the events app. """ +# mypy: ignore-errors from django.urls import reverse from tests.throttle import BaseTestThrottle diff --git a/backend/events/urls.py b/backend/events/urls.py index bd38a731e..59ca90b80 100644 --- a/backend/events/urls.py +++ b/backend/events/urls.py @@ -6,15 +6,22 @@ app_name = "events" router = DefaultRouter() + +# MARK: Main Tables + router.register(r"events", views.EventViewSet) router.register(r"formats", views.FormatViewSet) router.register(r"roles", views.RoleViewSet) + +# MARK: Bridge Tables + router.register(r"event_attendees", views.EventAttendeeViewSet) router.register(r"event_formats", views.EventFormatViewSet) router.register(r"event_attendee_statuses", views.EventAttendeeStatusViewSet) router.register(r"event_resources", views.EventResourceViewSet) router.register(r"event_roles", views.EventRoleViewSet) router.register(r"event_tasks", views.EventTaskViewSet) +router.register(r"event_texts", views.EventTextViewSet) router.register(r"event_topics", views.EventTopicViewSet) urlpatterns = [ diff --git a/backend/events/views.py b/backend/events/views.py index e87273fc9..d1de501d4 100644 --- a/backend/events/views.py +++ b/backend/events/views.py @@ -33,6 +33,8 @@ RoleSerializer, ) +# MARK: Main Tables + class EventViewSet(viewsets.ModelViewSet[Event]): queryset = Event.objects.all() @@ -53,24 +55,27 @@ class RoleViewSet(viewsets.ModelViewSet[Role]): pagination_class = CustomPagination +# MARK: Bridge Tables + + class EventAttendeeViewSet(viewsets.ModelViewSet[EventAttendee]): queryset = EventAttendee.objects.all() serializer_class = EventAttendeeSerializer pagination_class = CustomPagination -class EventFormatViewSet(viewsets.ModelViewSet[EventFormat]): - queryset = EventFormat.objects.all() - serializer_class = EventFormatSerializer - pagination_class = CustomPagination - - class EventAttendeeStatusViewSet(viewsets.ModelViewSet[EventAttendeeStatus]): queryset = EventAttendeeStatus.objects.all() serializer_class = EventAttendeeStatusSerializer pagination_class = CustomPagination +class EventFormatViewSet(viewsets.ModelViewSet[EventFormat]): + queryset = EventFormat.objects.all() + serializer_class = EventFormatSerializer + pagination_class = CustomPagination + + class EventResourceViewSet(viewsets.ModelViewSet[EventResource]): queryset = EventResource.objects.all() serializer_class = EventResourceSerializer diff --git a/backend/fixtures/iso_code_map.json b/backend/fixtures/iso_code_map.json new file mode 100644 index 000000000..9e221b84f --- /dev/null +++ b/backend/fixtures/iso_code_map.json @@ -0,0 +1,9 @@ +[ + { + "model": "content.isocodemap", + "pk": 1, + "fields": { + "code": "en" + } + } +] diff --git a/backend/fixtures/superuser.json b/backend/fixtures/superuser.json index 082b54cc7..1b22a93a1 100644 --- a/backend/fixtures/superuser.json +++ b/backend/fixtures/superuser.json @@ -8,7 +8,8 @@ "first_name": "", "last_name": "", "is_staff": true, - "date_joined": "2024-06-01T16:03:10.439Z", + "date_joined": "2024-04-29T17:30:42.975Z", + "creation_date": "2024-04-29T17:30:43.127Z", "username": "admin", "name": "", "password": "pbkdf2_sha256$600000$V4ADJJtADmbfpWmhbysc0v$9LBg11KagabWLdZ7W2c/GUfnrrJwf/xS4221vGt5T/Q=", @@ -16,14 +17,11 @@ "verified": false, "verification_method": "", "verification_partner": null, - "icon_url": null, - "email": "admin@admin.admin", - "social_links": null, - "is_private": false, + "email": "admin@activist.org", "is_high_risk": false, - "creation_date": "2024-06-01T16:03:10.804Z", "is_active": true, "is_admin": false, + "is_confirmed": true, "groups": [], "user_permissions": [] } diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 24b25b62f..6082d027f 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -51,7 +51,7 @@ django-stubs-ext==4.2.5 # via # -r requirements.txt # django-stubs -djangorestframework==3.15.1 +djangorestframework==3.15.2 # via # -r requirements.txt # drf-spectacular @@ -183,7 +183,7 @@ uritemplate==4.1.1 # via # -r requirements.txt # drf-spectacular -urllib3==2.0.7 +urllib3==2.2.2 # via # -r requirements.txt # requests diff --git a/backend/requirements.txt b/backend/requirements.txt index 0a40235fb..23db7769c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -32,7 +32,7 @@ django-stubs-ext==4.2.5 # via # -r requirements.in # django-stubs -djangorestframework==3.15.1 +djangorestframework==3.15.2 # via # -r requirements.in # drf-spectacular @@ -92,7 +92,7 @@ typing-extensions==4.8.0 # mypy uritemplate==4.1.1 # via drf-spectacular -urllib3==2.0.7 +urllib3==2.2.2 # via # requests # types-requests diff --git a/backend/tests/throttle.py b/backend/tests/throttle.py index 874861bfd..651e0239f 100644 --- a/backend/tests/throttle.py +++ b/backend/tests/throttle.py @@ -24,8 +24,8 @@ class BaseTestThrottle: __test__ = False url = "" client = APIClient() - anon_throttle = 7 - user_throttle = 10 + anon_throttle = 20 + user_throttle = 30 @pytest.mark.django_db @override_settings( diff --git a/docker-compose.yml b/docker-compose.yml index 5023e1f91..604737b52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,12 @@ services: build: context: ./backend container_name: django_backend + restart: unless-stopped command: sh -c "python manage.py makemigrations && python manage.py migrate && + python manage.py loaddata fixtures/superuser.json && + python manage.py loaddata fixtures/status_types.json && + python manage.py loaddata fixtures/iso_code_map.json && python manage.py runserver 0.0.0.0:${BACKEND_PORT}" ports: - "${BACKEND_PORT}:${BACKEND_PORT}" @@ -21,6 +25,7 @@ services: - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} - DEBUG=${DEBUG} - SECRET_KEY=${SECRET_KEY} + - VITE_FRONTEND_URL=${VITE_FRONTEND_URL} depends_on: - db healthcheck: diff --git a/frontend/components/btn/action/BtnActionDropdown.vue b/frontend/components/btn/action/BtnActionDropdown.vue index c87aeb601..56356de6a 100644 --- a/frontend/components/btn/action/BtnActionDropdown.vue +++ b/frontend/components/btn/action/BtnActionDropdown.vue @@ -29,7 +29,7 @@ v-for="o in dropdownOptions" :key="o" v-slot="{ active }" - class="block cursor-pointer px-4 py-2 text-sm" + class="block w-full cursor-pointer px-4 py-2 text-sm" :class="{ 'rounded-t-md': o === dropdownOptions[0], 'rounded-b-md': o === dropdownOptions[dropdownOptions.length - 1], diff --git a/frontend/components/card/CardAbout.vue b/frontend/components/card/CardAbout.vue deleted file mode 100644 index d14df78c6..000000000 --- a/frontend/components/card/CardAbout.vue +++ /dev/null @@ -1,256 +0,0 @@ - - - diff --git a/frontend/components/card/CardConnect.vue b/frontend/components/card/CardConnect.vue index e958ca1f5..b790cb187 100644 --- a/frontend/components/card/CardConnect.vue +++ b/frontend/components/card/CardConnect.vue @@ -8,13 +8,13 @@ class="cursor-pointer break-all rounded-lg p-1 text-light-text transition-all hover:text-light-distinct-text dark:text-dark-text dark:hover:text-dark-distinct-text" > import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue"; +import type { Organization } from "~/types/entities/organization"; import { IconMap } from "~/types/icon-map"; const props = defineProps<{ - socialLinks?: string[]; - userIsAdmin?: boolean; + pageType: "organization" | "group" | "event" | "other"; }>(); +const { userIsSignedIn } = useUser(); +const paramsID = useRoute().params.id; +const paramsIDGroup = useRoute().params.groupID; + +const id = typeof paramsID === "string" ? paramsID : undefined; +const idGroup = typeof paramsIDGroup === "string" ? paramsIDGroup : undefined; + +const organizationStore = useOrganizationStore(); +let organization: Organization; +const group = useGroupStore(); +const event = useEventStore(); + +if (props.pageType == "organization") { + await organizationStore.fetchByID(id); + organization = organizationStore.organization; +} else if (props.pageType == "group") { + await group.fetchByID(idGroup); +} else if (props.pageType == "event") { + await event.fetchByID(id); +} + const editModeEnabled = ref(false); -const socialLinksRef = computed(() => props.socialLinks); +const socialLinksRef = computed(() => { + if (props.pageType == "organization") { + return organization.socialLinks; + } else if (props.pageType == "group") { + return group.socialLinks; + } else if (props.pageType == "event") { + return event.socialLinks; + } else { + return [""]; + } +}); const toggleEditMode = () => { editModeEnabled.value = !editModeEnabled.value; diff --git a/frontend/components/card/CardDetails.vue b/frontend/components/card/CardDetails.vue index d0784750d..935b032f2 100644 --- a/frontend/components/card/CardDetails.vue +++ b/frontend/components/card/CardDetails.vue @@ -8,15 +8,15 @@ {{ $t("components.card-details.header") }} - @@ -44,21 +44,12 @@ diff --git a/frontend/components/card/CardOrgApplicationVote.vue b/frontend/components/card/CardOrgApplicationVote.vue index f26d29082..634931469 100644 --- a/frontend/components/card/CardOrgApplicationVote.vue +++ b/frontend/components/card/CardOrgApplicationVote.vue @@ -55,8 +55,8 @@ diff --git a/frontend/components/card/about/CardAboutGroup.vue b/frontend/components/card/about/CardAboutGroup.vue new file mode 100644 index 000000000..d6804c5c4 --- /dev/null +++ b/frontend/components/card/about/CardAboutGroup.vue @@ -0,0 +1,131 @@ + + + diff --git a/frontend/components/card/about/CardAboutOrganization.vue b/frontend/components/card/about/CardAboutOrganization.vue new file mode 100644 index 000000000..3b5d368d0 --- /dev/null +++ b/frontend/components/card/about/CardAboutOrganization.vue @@ -0,0 +1,130 @@ + + + diff --git a/frontend/components/card/discussion/CardDiscussion.vue b/frontend/components/card/discussion/CardDiscussion.vue index 04d14947d..7e1f1fdde 100644 --- a/frontend/components/card/discussion/CardDiscussion.vue +++ b/frontend/components/card/discussion/CardDiscussion.vue @@ -78,7 +78,7 @@ diff --git a/frontend/components/card/get-involved/CardGetInvolvedGroup.vue b/frontend/components/card/get-involved/CardGetInvolvedGroup.vue new file mode 100644 index 000000000..197e6f538 --- /dev/null +++ b/frontend/components/card/get-involved/CardGetInvolvedGroup.vue @@ -0,0 +1,63 @@ + + + diff --git a/frontend/components/card/get-involved/CardGetInvolvedOrganization.vue b/frontend/components/card/get-involved/CardGetInvolvedOrganization.vue new file mode 100644 index 000000000..e6b8baa3b --- /dev/null +++ b/frontend/components/card/get-involved/CardGetInvolvedOrganization.vue @@ -0,0 +1,84 @@ + + + diff --git a/frontend/components/card/search-result/CardSearchResult.vue b/frontend/components/card/search-result/CardSearchResult.vue index d1e5ba09e..1b989cd14 100644 --- a/frontend/components/card/search-result/CardSearchResult.vue +++ b/frontend/components/card/search-result/CardSearchResult.vue @@ -166,7 +166,7 @@ :link="onlineLocation" label="components.meta-tag-video.view-video" /> - +
@@ -177,7 +177,7 @@ :link="onlineLocation" label="components.meta-tag-video.view-video" /> - +
import { useLinkURL } from "~/composables/useLinkURL"; -import type { Event } from "~/types/event"; -import type { Group } from "~/types/group"; +import type { User } from "~/types/auth/user"; +import type { Resource } from "~/types/content/resource"; +import type { Group } from "~/types/entities/group"; +import type { Organization } from "~/types/entities/organization"; +import type { Event } from "~/types/events/event"; import { IconMap } from "~/types/icon-map"; -import type { Organization } from "~/types/organization"; -import type { Resource } from "~/types/resource"; -import type { User } from "~/types/user"; const props = defineProps<{ organization?: Organization; diff --git a/frontend/components/card/search-result/CardSearchResultEvent.vue b/frontend/components/card/search-result/CardSearchResultEvent.vue index 7fd33e7c4..d140b9105 100644 --- a/frontend/components/card/search-result/CardSearchResultEvent.vue +++ b/frontend/components/card/search-result/CardSearchResultEvent.vue @@ -7,7 +7,7 @@ diff --git a/frontend/components/form/FormTextField.vue b/frontend/components/form/text/FormTextInput.vue similarity index 100% rename from frontend/components/form/FormTextField.vue rename to frontend/components/form/text/FormTextInput.vue diff --git a/frontend/components/header/HeaderAppPage.vue b/frontend/components/header/HeaderAppPage.vue index 8d6233003..e10d58893 100644 --- a/frontend/components/header/HeaderAppPage.vue +++ b/frontend/components/header/HeaderAppPage.vue @@ -1,10 +1,5 @@ diff --git a/frontend/components/modal/command-palette/ModalCommandPaletteItem.vue b/frontend/components/modal/command-palette/ModalCommandPaletteItem.vue new file mode 100644 index 000000000..d9cd594a5 --- /dev/null +++ b/frontend/components/modal/command-palette/ModalCommandPaletteItem.vue @@ -0,0 +1,94 @@ + + + + diff --git a/frontend/components/modal/ModalEditPageText.vue b/frontend/components/modal/edit/ModalEditFaqEntry.vue similarity index 96% rename from frontend/components/modal/ModalEditPageText.vue rename to frontend/components/modal/edit/ModalEditFaqEntry.vue index ffba96c19..c7afac1de 100644 --- a/frontend/components/modal/ModalEditPageText.vue +++ b/frontend/components/modal/edit/ModalEditFaqEntry.vue @@ -43,8 +43,11 @@ diff --git a/frontend/components/modal/edit/about/ModalEditAboutGroup.vue b/frontend/components/modal/edit/about/ModalEditAboutGroup.vue new file mode 100644 index 000000000..c95debec1 --- /dev/null +++ b/frontend/components/modal/edit/about/ModalEditAboutGroup.vue @@ -0,0 +1,90 @@ +