diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py index 9615d9211..fd8bed9da 100644 --- a/backend/authentication/admin.py +++ b/backend/authentication/admin.py @@ -15,7 +15,7 @@ from django.forms import ModelForm from django.http import HttpRequest -from authentication.models import Support, SupportEntityType, UserFlag, UserModel +from authentication.models import UserFlag, UserModel logger = logging.getLogger(__name__) @@ -23,8 +23,6 @@ # Remove default Group. admin.site.unregister(Group) -admin.site.register(Support) -admin.site.register(SupportEntityType) # MARK: User Creation diff --git a/backend/authentication/enums.py b/backend/authentication/enums.py index 4da987c5f..ea0dca661 100644 --- a/backend/authentication/enums.py +++ b/backend/authentication/enums.py @@ -1,28 +1,17 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -""" -Enums for the authentication app. -""" - -from enum import Enum - - -class StatusTypes(Enum): - """ - Represents the possible statuses of a user. - """ - - PENDING = 1 - ACTIVE = 2 - SUSPENDED = 3 - BANNED = 4 - - -class SupportEntityTypes(Enum): - """ - Defines the types of entities that can support users. - """ - - ORGANIZATION = 1 - GROUP = 2 - EVENT = 3 - USER = 4 +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Enums for the authentication app. +""" + +from enum import Enum + + +class StatusTypes(Enum): + """ + Represents the possible statuses of a user. + """ + + PENDING = 1 + ACTIVE = 2 + SUSPENDED = 3 + BANNED = 4 diff --git a/backend/authentication/factories.py b/backend/authentication/factories.py index 7caff2417..4d2abd971 100644 --- a/backend/authentication/factories.py +++ b/backend/authentication/factories.py @@ -11,49 +11,10 @@ from authentication.models import ( SessionModel, - Support, - SupportEntityType, UserFlag, UserModel, ) -# MARK: Support - - -class SupportEntityTypeFactory(factory.django.DjangoModelFactory): - """ - Factory for creating SupportEntityType model instances. - """ - - class Meta: - model = SupportEntityType - - name = factory.Faker("word") - - -class SupportFactory(factory.django.DjangoModelFactory): - """ - Factory for creating Support model instances. - - Notes - ----- - This class generates mock `Support` instances, which associate supporters with supported entities. - It uses other factories like `SupportEntityTypeFactory` to generate related data. - """ - - class Meta: - model = Support - - supporter_type = factory.SubFactory(SupportEntityTypeFactory) - supporter_entity = factory.SubFactory( - "communities.organizations.factories.OrganizationFactory" - ) - supported_type = factory.SubFactory(SupportEntityTypeFactory) - supported_entity = factory.SubFactory( - "communities.organizations.factories.OrganizationFactory" - ) - - # MARK: Session diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 6ba3b55d1..b813aff74 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -135,59 +135,6 @@ def create_user( logger.exception(f"Failed to create user {username}: {str(e)}") raise - -# MARK: Support - - -class SupportEntityType(models.Model): - """ - Represents a type of support entity, such as organization, group, event, or user. - - Notes - ----- - This model is used in the `Support` relationship to define the type of entity - involved in the support system. - """ - - id = models.UUIDField(primary_key=True, default=uuid4, editable=False) - name = models.CharField(max_length=255) - - def __str__(self) -> str: - return self.name - - -class Support(models.Model): - """ - Represents a support relationship between two entities. - - Notes - ----- - A `Support` connects a supporter entity (like an organization) to a supported entity. - Both entities are represented by their type and specific instances. - """ - - supporter_type = models.ForeignKey( - "SupportEntityType", on_delete=models.CASCADE, related_name="supporter" - ) - supporter_entity = models.ForeignKey( - "communities.Organization", - on_delete=models.CASCADE, - related_name="supporter", - ) - supported_type = models.ForeignKey( - "SupportEntityType", on_delete=models.CASCADE, related_name="supported" - ) - supported_entity = models.ForeignKey( - "communities.Organization", - on_delete=models.CASCADE, - related_name="supported", - ) - creation_date = models.DateTimeField(auto_now_add=True) - - def __str__(self) -> str: - return str(self.id) - - # MARK: Session @@ -258,6 +205,7 @@ class UserModel(AbstractUser, PermissionsMixin): through="authentication.UserFlag", ) + def __str__(self) -> str: return self.username @@ -275,3 +223,4 @@ class UserFlag(models.Model): ) created_by = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) creation_date = models.DateTimeField(auto_now=True) + diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index a905250dd..f9d1cfcb7 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -13,6 +13,11 @@ from rest_framework_simplejwt.tokens import RefreshToken from authentication.models import SessionModel, UserFlag, UserModel +from communities.organizations.models import Organization +from communities.organizations.serializers import OrganizationTextSerializer +from content.models import Topic +from events.models import Event +from events.serializers import EventTextSerializer logger = logging.getLogger(__name__) USER = get_user_model() @@ -197,11 +202,89 @@ def validate(self, data: dict[str, str | Any]) -> dict[str, str | Any]: return data +class UserSupportedOrganizationSerializer(serializers.ModelSerializer["Organization"]): + """ + Lightweight serializer for a user's supported organizations. + """ + + texts = OrganizationTextSerializer(many=True, read_only=True) + topics = serializers.SlugRelatedField( + queryset=Topic.objects.filter(active=True), + many=True, + slug_field="type", + required=False, + allow_null=True, + ) + + class Meta: + model = Organization # resolved at module level; see import note below + fields = [ + "id", + "name", + "tagline", + "texts", + "topics", + ] + + +class UserSupportedEventSerializer(serializers.ModelSerializer["Event"]): + """ + Lightweight event serializer for a user's supported events. + """ + + texts = EventTextSerializer(many=True, read_only=True) + topics = serializers.SlugRelatedField( + queryset=Topic.objects.filter(active=True), + many=True, + slug_field="type", + required=False, + allow_null=True, + ) + + class Meta: + model = Event # resolved at module level; see import note below + fields = [ + "id", + "name", + "tagline", + "type", + "location_type", + "creation_date", + "texts", + "topics", + ] + + class UserSerializer(serializers.ModelSerializer[UserModel]): """ Serializer for the user model. """ + supported_events = UserSupportedEventSerializer(many=True, read_only=True) + supported_organizations = UserSupportedOrganizationSerializer( + many=True, read_only=True + ) + + class Meta: + model = UserModel + fields = [ + "id", + "username", + "email", + "is_admin", + "is_active", + "is_staff", + "is_superuser", + "supported_events", + "supported_organizations", + ] + + +class UserSessionSerializer(serializers.ModelSerializer[UserModel]): + """ + Lightweight serializer for a user's session. + """ + class Meta: model = UserModel fields = [ @@ -220,7 +303,7 @@ class SessionSerializer(serializers.ModelSerializer[SessionModel]): Serializer for the session model. """ - user = UserSerializer(read_only=True) + user = UserSessionSerializer(read_only=True) class Meta: model = SessionModel diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 064beeba6..484644c08 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -35,4 +35,5 @@ views.VerifyAccountResetPassword.as_view(), name="verify_email_password", ), + path(route="users/", view=views.UserDetailAPIView.as_view(), name="user"), ] diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 891352cbe..8d31f2219 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -534,3 +534,26 @@ def delete(self, request: Request, id: str | uuid.UUID) -> Response: return Response( {"message": "Flag deleted successfully."}, status=status.HTTP_204_NO_CONTENT ) + +class UserDetailAPIView(GenericAPIView[UserModel]): + queryset = UserModel.objects.all() + serializer_class = UserSerializer + permission_classes = [IsAdminStaffCreatorOrReadOnly] + def get(self, request: Request, id: str | uuid.UUID) -> Response: + logger.info( + f"User detail requested: ID {id} by user {request.user.username}" + ) + try: + user = UserModel.objects.get(id=id) + + except UserModel.DoesNotExist: + logger.warning(f"User not found: ID {id}") + return Response( + {"detail": "Failed to retrieve the user."}, + status=status.HTTP_404_NOT_FOUND, + ) + + self.check_object_permissions(request, user) + + serializer = UserSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/communities/organizations/models.py b/backend/communities/organizations/models.py index 4ddca224f..756ee67c0 100644 --- a/backend/communities/organizations/models.py +++ b/backend/communities/organizations/models.py @@ -1,235 +1,310 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -""" -Models for the communities app. -""" - -from typing import Any -from uuid import uuid4 - -from django.db import models - -from authentication import enums -from content.models import Faq, Resource, SocialLink, Text - -# MARK: Organization - - -class Organization(models.Model): - """ - General organization class with all base parameters. - """ - - id = models.UUIDField(primary_key=True, default=uuid4, editable=False) - created_by = models.ForeignKey( - "authentication.UserModel", - related_name="created_org", - on_delete=models.CASCADE, - ) - description = models.CharField(max_length=2500, blank=True, default="") - name = models.CharField(max_length=255) - tagline = models.CharField(max_length=255, blank=True) - icon_url = models.ForeignKey( - "content.Image", on_delete=models.CASCADE, blank=True, null=True - ) - location = models.OneToOneField( - "content.Location", on_delete=models.CASCADE, null=False, blank=False - ) - terms_checked = models.BooleanField(default=False) - is_high_risk = models.BooleanField(default=False) - status = models.ForeignKey( - "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(blank=True, null=True) - deletion_date = models.DateTimeField(blank=True, null=True) - - topics = models.ManyToManyField("content.Topic", blank=True) - - discussions = models.ManyToManyField("content.Discussion", blank=True) - - # Explicit type annotation required for mypy compatibility with django-stubs. - flags: Any = models.ManyToManyField( - "authentication.UserModel", - through="OrganizationFlag", - ) - - def __str__(self) -> str: - return self.name - - -# MARK: Application - - -class OrganizationApplication(models.Model): - """ - Class covering the application of an organization to join the platform. - """ - - org = models.ForeignKey( - Organization, on_delete=models.CASCADE, related_name="application" - ) - status = models.ForeignKey( - "StatusType", on_delete=models.CASCADE, blank=True, null=True - ) - orgs_in_favor = models.ManyToManyField( - "communities.Organization", related_name="in_favor", blank=True - ) - orgs_against = models.ManyToManyField( - "communities.Organization", related_name="against", blank=True - ) - creation_date = models.DateTimeField(auto_now_add=True) - - def __str__(self) -> str: - return str(self.creation_date) - - -class OrganizationApplicationStatus(models.Model): - """ - Class handling the status of an organization application. - """ - - id = models.UUIDField(primary_key=True, default=uuid4, editable=False) - status_name = models.CharField(max_length=255) - - def __str__(self) -> str: - return self.status_name - - -# MARK: FAQ - - -class OrganizationFaq(Faq): - """ - Organization Frequently Asked Questions model. - """ - - org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="faqs") - - def __str__(self) -> str: - return self.question - - class Meta: - ordering = ["order"] - - -# MARK: Flag - - -class OrganizationFlag(models.Model): - """ - Model for flagged organizations. - """ - - id = models.UUIDField(primary_key=True, default=uuid4, editable=False) - org = models.ForeignKey("communities.Organization", on_delete=models.CASCADE) - created_by = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) - creation_date = models.DateTimeField(auto_now=True) - - -# MARK: Image - - -class OrganizationImage(models.Model): - """ - Class for adding image parameters to organizations. - """ - - org = models.ForeignKey(Organization, on_delete=models.CASCADE) - image = models.ForeignKey("content.Image", on_delete=models.CASCADE) - sequence_index = models.IntegerField() - - def __str__(self) -> str: - return str(self.id) - - -# MARK: Member - - -class OrganizationMember(models.Model): - """ - Class for adding user membership parameters to organizations. - """ - - org = models.ForeignKey(Organization, on_delete=models.CASCADE) - user = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) - is_owner = models.BooleanField(default=False) - is_admin = models.BooleanField(default=False) - is_comms = models.BooleanField(default=False) - - def __str__(self) -> str: - return str(self.id) - - -# MARK: Resource - - -class OrganizationResource(Resource): - """ - Organization resource model. - """ - - org = models.ForeignKey( - Organization, on_delete=models.CASCADE, related_name="resources" - ) - - def __str__(self) -> str: - return self.name - - class Meta: - ordering = ["order"] - - -# MARK: Social Link - - -class OrganizationSocialLink(SocialLink): - """ - Class for adding social link parameters to organizations. - """ - - org = models.ForeignKey( - Organization, on_delete=models.CASCADE, null=True, related_name="social_links" - ) - - class Meta: - ordering = ["order"] - - -# MARK: Task - - -class OrganizationTask(models.Model): - """ - Class for adding task parameters to organizations. - """ - - id = models.UUIDField(primary_key=True, default=uuid4, editable=False) - org = models.ForeignKey(Organization, on_delete=models.CASCADE) - task = models.ForeignKey("content.Task", on_delete=models.CASCADE) - group = models.ForeignKey( - "Group", on_delete=models.CASCADE, blank=True, null=True, related_name="groups" - ) - - def __str__(self) -> str: - return str(self.id) - - -# MARK: Text - - -class OrganizationText(Text): - """ - Class for adding text parameters to organizations. - """ - - org = models.ForeignKey( - Organization, on_delete=models.CASCADE, null=True, related_name="texts" - ) - donate_prompt = models.TextField(max_length=500, blank=True) - - def __str__(self) -> str: - return f"{self.org} - {self.iso}" +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Models for the communities app. +""" + +from typing import Any +from uuid import uuid4 + +from django.db import models +from django.db.models import Q + +from authentication import enums +from content.models import Faq, Resource, SocialLink, Text + +# MARK: Organization + + +class Organization(models.Model): + """ + General organization class with all base parameters. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + created_by = models.ForeignKey( + "authentication.UserModel", + related_name="created_org", + on_delete=models.CASCADE, + ) + description = models.CharField(max_length=2500, blank=True, default="") + name = models.CharField(max_length=255) + tagline = models.CharField(max_length=255, blank=True) + icon_url = models.ForeignKey( + "content.Image", on_delete=models.CASCADE, blank=True, null=True + ) + location = models.OneToOneField( + "content.Location", on_delete=models.CASCADE, null=False, blank=False + ) + terms_checked = models.BooleanField(default=False) + is_high_risk = models.BooleanField(default=False) + status = models.ForeignKey( + "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(blank=True, null=True) + deletion_date = models.DateTimeField(blank=True, null=True) + + topics = models.ManyToManyField("content.Topic", blank=True) + + discussions = models.ManyToManyField("content.Discussion", blank=True) + + # Explicit type annotation required for mypy compatibility with django-stubs. + flags: Any = models.ManyToManyField( + "authentication.UserModel", + through="OrganizationFlag", + ) + supporters_users: Any = models.ManyToManyField( + "authentication.UserModel", + through="OrganizationSupport", + through_fields=("organization", "user_supporter"), + related_name="supported_organizations", + ) + supporters_orgs: Any = models.ManyToManyField( + "communities.Organization", + through="OrganizationSupport", + through_fields=("organization", "org_supporter"), + related_name="supported_organizations_by_org", + ) + def __str__(self) -> str: + return self.name + +class OrganizationSupport(models.Model): + """ + Model for support received by an organization. + + Notes + ----- + Exactly one of `supporter_user` / `supporter_org` must be set, + enforced by the `exactly_one_supporter_type` check constraint. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + organization = models.ForeignKey( + "Organization", + on_delete=models.CASCADE, + related_name="supports_received", + ) + user_supporter = models.ForeignKey( + "authentication.UserModel", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="organization_supports_given", + ) + org_supporter = models.ForeignKey( + "Organization", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="supports_given", + ) + creation_date = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=( + # Exactly one supporter type per row. + models.Q(user_supporter__isnull=False, org_supporter__isnull=True) + | models.Q(user_supporter__isnull=True, org_supporter__isnull=False) + ), + name="exactly_one_supporter_type", + ), + # A given user can support an organization at most once. + models.UniqueConstraint( + fields=["organization", "user_supporter"], + name="unique_organization_support_per_user", + ), + # A given org can support an organization at most once. + models.UniqueConstraint( + fields=["organization", "org_supporter"], + name="unique_organization_support_per_org", + ), + ] + + @property + def supporter(self) -> Any: + """ + Returns the actual supporter instance, regardless of type. + """ + return self.user_supporter or self.org_supporter + + def __str__(self) -> str: + return f"{self.supporter} supports {self.organization}" + +# MARK: Application + + +class OrganizationApplication(models.Model): + """ + Class covering the application of an organization to join the platform. + """ + + org = models.ForeignKey( + Organization, on_delete=models.CASCADE, related_name="application" + ) + status = models.ForeignKey( + "StatusType", on_delete=models.CASCADE, blank=True, null=True + ) + orgs_in_favor = models.ManyToManyField( + "communities.Organization", related_name="in_favor", blank=True + ) + orgs_against = models.ManyToManyField( + "communities.Organization", related_name="against", blank=True + ) + creation_date = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return str(self.creation_date) + + +class OrganizationApplicationStatus(models.Model): + """ + Class handling the status of an organization application. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + status_name = models.CharField(max_length=255) + + def __str__(self) -> str: + return self.status_name + + +# MARK: FAQ + + +class OrganizationFaq(Faq): + """ + Organization Frequently Asked Questions model. + """ + + org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="faqs") + + def __str__(self) -> str: + return self.question + + class Meta: + ordering = ["order"] + + +# MARK: Flag + + +class OrganizationFlag(models.Model): + """ + Model for flagged organizations. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + org = models.ForeignKey("communities.Organization", on_delete=models.CASCADE) + created_by = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) + creation_date = models.DateTimeField(auto_now=True) + + +# MARK: Image + + +class OrganizationImage(models.Model): + """ + Class for adding image parameters to organizations. + """ + + org = models.ForeignKey(Organization, on_delete=models.CASCADE) + image = models.ForeignKey("content.Image", on_delete=models.CASCADE) + sequence_index = models.IntegerField() + + def __str__(self) -> str: + return str(self.id) + + +# MARK: Member + + +class OrganizationMember(models.Model): + """ + Class for adding user membership parameters to organizations. + """ + + org = models.ForeignKey(Organization, on_delete=models.CASCADE) + user = models.ForeignKey("authentication.UserModel", on_delete=models.CASCADE) + is_owner = models.BooleanField(default=False) + is_admin = models.BooleanField(default=False) + is_comms = models.BooleanField(default=False) + + def __str__(self) -> str: + return str(self.id) + + +# MARK: Resource + + +class OrganizationResource(Resource): + """ + Organization resource model. + """ + + org = models.ForeignKey( + Organization, on_delete=models.CASCADE, related_name="resources" + ) + + def __str__(self) -> str: + return self.name + + class Meta: + ordering = ["order"] + + +# MARK: Social Link + + +class OrganizationSocialLink(SocialLink): + """ + Class for adding social link parameters to organizations. + """ + + org = models.ForeignKey( + Organization, on_delete=models.CASCADE, null=True, related_name="social_links" + ) + + class Meta: + ordering = ["order"] + + +# MARK: Task + + +class OrganizationTask(models.Model): + """ + Class for adding task parameters to organizations. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + org = models.ForeignKey(Organization, on_delete=models.CASCADE) + task = models.ForeignKey("content.Task", on_delete=models.CASCADE) + group = models.ForeignKey( + "Group", on_delete=models.CASCADE, blank=True, null=True, related_name="groups" + ) + + def __str__(self) -> str: + return str(self.id) + + +# MARK: Text + + +class OrganizationText(Text): + """ + Class for adding text parameters to organizations. + """ + + org = models.ForeignKey( + Organization, on_delete=models.CASCADE, null=True, related_name="texts" + ) + donate_prompt = models.TextField(max_length=500, blank=True) + + def __str__(self) -> str: + return f"{self.org} - {self.iso}" diff --git a/backend/communities/organizations/serializers.py b/backend/communities/organizations/serializers.py index 87eff8b49..c3569a923 100644 --- a/backend/communities/organizations/serializers.py +++ b/backend/communities/organizations/serializers.py @@ -4,6 +4,7 @@ """ import logging +from multiprocessing import Event from typing import Any from uuid import UUID @@ -20,6 +21,7 @@ OrganizationMember, OrganizationResource, OrganizationSocialLink, + OrganizationSupport, OrganizationTask, OrganizationText, ) @@ -182,7 +184,7 @@ class OrganizationPOSTSerializer(serializers.Serializer[Organization]): topics = TopicSerializer(many=True, required=False) country_code = serializers.CharField(max_length=3, default="en") city = serializers.CharField(max_length=255) - + def validate(self, data: dict[str, Any]) -> dict[str, Any]: """ Validate the data being posted. @@ -297,6 +299,14 @@ class OrganizationSerializer(serializers.ModelSerializer[Organization]): events = EventSerializer(many=True, read_only=True) icon_url = ImageSerializer(required=False) + supporter_user_count = serializers.SerializerMethodField() + supporter_org_count = serializers.SerializerMethodField() + + def get_supporter_user_count(self, obj: Organization) -> int: + return getattr(obj, "_user_supporter_count", None) or obj.supporters_users.count() + def get_supporter_org_count(self, obj: Organization) -> int: + return getattr(obj, "_org_supporter_count", None) or obj.supporters_orgs.count() + class Meta: model = Organization @@ -366,6 +376,70 @@ class Meta: model = OrganizationFlag fields = "__all__" +# MARK: Support +class OrganizationSupportSerializer(serializers.ModelSerializer[OrganizationSupport]): + """ + Serializer for OrganizationSupport model data. + + Notes + ----- + `supporter_user` is always set from the requesting user in the view, + so clients only ever provide the organization (and only when the organization id + isn't already in the URL). + """ + + class Meta: + model = OrganizationSupport + fields = "__all__" + read_only_fields = ["supporter_user", "supporter_org", "creation_date", "organization"] + def create(self, validated_data: dict[str, Any]) -> OrganizationSupport: + """ + Create an organization support record. + + Parameters + ---------- + validated_data : dict[str, Any] + Dictionary of validated data for creating the organization support. + + Returns + ------- + OrganizationSupport + Created OrganizationSupport instance. + """ + org_support = OrganizationSupport.objects.create(**validated_data) + logger.info(f"Created OrganizationSupport with id {org_support.id}") + + return org_support + def validate_organization(self, value: Organization | UUID | str) -> Organization: + """ + Validate that the organization exists. + + Parameters + ---------- + value : Organization | UUID | str + The value to validate: an Organization instance, UUID, or string id. + + Returns + ------- + Organization + The validated Organization instance. + + Raises + ------ + serializers.ValidationError + If the organization does not exist. + """ + if isinstance(value, Organization): + return value + + try: + org = Organization.objects.get(id=value) + logger.info(f"Organization found for value: {value}") + + except Organization.DoesNotExist as e: + raise serializers.ValidationError("Organization not found.") from e + + return org # MARK: Application diff --git a/backend/communities/organizations/views.py b/backend/communities/organizations/views.py index a4f8f481e..0ab8dab7d 100644 --- a/backend/communities/organizations/views.py +++ b/backend/communities/organizations/views.py @@ -44,6 +44,7 @@ OrganizationImage, OrganizationResource, OrganizationSocialLink, + OrganizationSupport, OrganizationText, ) from communities.organizations.serializers import ( @@ -54,6 +55,7 @@ OrganizationResourceSerializer, OrganizationSerializer, OrganizationSocialLinkSerializer, + OrganizationSupportSerializer, OrganizationTextSerializer, ) from content.models import Image @@ -154,7 +156,146 @@ def post(self, request: Request) -> Response: return Response(data, status=status.HTTP_201_CREATED) +# MARK: Support + +class OrganizationSupportAPIView(GenericAPIView[OrganizationSupport]): + """ + List, create and delete event support relationships. + + Notes + ----- + Registered on both "event_supports" and "event_supports/". + Only users can support events, so POST always uses the requesting user. + """ + + serializer_class = OrganizationSupportSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + + @extend_schema( + responses={ + 200: OrganizationSupportSerializer(many=True), + 404: OpenApiResponse(response={"detail": "Support not found."}), + }, + ) + def get(self, request: Request) -> Response: + try: + support = OrganizationSupport.objects.filter(user_supporter__id=request.user.id) + except OrganizationSupport.DoesNotExist: + return Response( + {"detail": "Support not found."}, status=status.HTTP_404_NOT_FOUND + ) + + return Response( + OrganizationSupportSerializer(support, many=True).data, status=status.HTTP_200_OK + ) + +class OrganizationSupportDetailAPIView(viewsets.ModelViewSet[OrganizationSupport]): + """ + API view for retrieving, updating, and deleting a specific organization support. + Only the supporter user or staff can delete the support. + """ + serializer_class = OrganizationSupportSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + queryset = OrganizationSupport.objects.all() + @extend_schema( + responses={ + 201: OrganizationSupportSerializer, + 400: OpenApiResponse(response={"detail": "Failed to create support."}), + } + ) + def post(self, request: Request, pk: None | UUID = None) -> Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + supported_types = ['user', 'org'] + if pk is None: + return Response( + {"detail": "Organization ID is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + organization_id = pk + if not Organization.objects.filter(id=organization_id).exists(): + return Response( + {"detail": "Organization not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + organization = Organization.objects.get(id=organization_id) + type_support = request.data.get("supporter_type") + if type_support is None: + return Response( + {"detail": "Type of support is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if type_support not in supported_types: + return Response( + {"detail": "Invalid type of support."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if type_support == 'user': + serializer.save(user_supporter=request.user, organization=organization) + logger.info(f"OrganizationSupport created by user {request.user.id}") + elif type_support == 'org': + serializer.save(org_supporter=request.user.organization, organization=organization) + logger.info(f"OrganizationSupport created by organization {request.user.organization.id}") + + except (IntegrityError, OperationalError) as e: + logger.exception( + f"Failed to create organization support for user {request.user.id}: {e}" + ) + return Response( + {"detail": "Failed to create support."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @extend_schema( + responses={ + 204: OpenApiResponse(response={"message": "Support deleted successfully."}), + 400: OpenApiResponse(response={"detail": "Support ID is required."}), + 403: OpenApiResponse( + response={"detail": "You are not authorized to delete this support."} + ), + 404: OpenApiResponse(response={"detail": "Support not found."}), + } + ) + def destroy(self, request: Request, pk: None | UUID = None) -> Response: + if pk is None: + return Response( + {"detail": "Organization ID is required to delete support."}, + status=status.HTTP_400_BAD_REQUEST, + ) + org = request.data.get("org") + try: + support = OrganizationSupport.objects.get( + Q(organization__id=pk, user_supporter=request.user) | + Q(organization__id=pk, org_supporter=org) + ) + + except OrganizationSupport.DoesNotExist as e: + logger.exception(f"OrganizationSupport for organization id {pk} does not exist for delete: {e}") + return Response( + {"detail": "Support for this organization not found."}, status=status.HTTP_404_NOT_FOUND + ) + supporter = support.supporter + if (supporter and isinstance(supporter, UserModel) and supporter.id != request.user.id) and not request.user.is_staff: + return Response( + {"detail": "You are not authorized to delete this support."}, + status=status.HTTP_403_FORBIDDEN, + ) + if (supporter and isinstance(supporter, Organization) and supporter.created_by.id != request.user.id) and not request.user.is_staff: + return Response( + {"detail": "You are not authorized to delete this support."}, + status=status.HTTP_403_FORBIDDEN, + ) + + support.delete() + logger.info(f"OrganizationSupport for organization id {pk} deleted") + return Response( + {"message": "Support deleted successfully."}, + status=status.HTTP_204_NO_CONTENT, + ) # MARK: Get Organization by User ID diff --git a/backend/communities/urls.py b/backend/communities/urls.py index 1fcc34105..ab66a3859 100644 --- a/backend/communities/urls.py +++ b/backend/communities/urls.py @@ -28,6 +28,8 @@ OrganizationImageViewSet, OrganizationResourceViewSet, OrganizationSocialLinkViewSet, + OrganizationSupportAPIView, + OrganizationSupportDetailAPIView, OrganizationTextViewSet, ) from communities.views import StatusViewSet @@ -92,6 +94,11 @@ viewset=OrganizationEventViewSet, basename="organization-events", ) +router.register( + prefix=r"org_supports", + viewset=OrganizationSupportDetailAPIView, + basename="org-supports", +) # MARK: URL Patterns @@ -111,4 +118,5 @@ ), path("organization_texts/", OrganizationTextViewSet.as_view()), path("organizations_by_user/", OrganizationByUserAPIView.as_view()), + path("org_supports", OrganizationSupportAPIView.as_view()), ] diff --git a/backend/events/models.py b/backend/events/models.py index 249b01ba4..cd42dd60e 100644 --- a/backend/events/models.py +++ b/backend/events/models.py @@ -64,7 +64,13 @@ class Event(models.Model): topics = models.ManyToManyField("content.Topic", blank=True) # Explicit type annotation required for mypy compatibility with django-stubs. - flags: Any = models.ManyToManyField("authentication.UserModel", through="EventFlag") + flags: Any = models.ManyToManyField("authentication.UserModel", through="EventFlag", related_name="flagged_events") + supporters: Any = models.ManyToManyField( + "authentication.UserModel", + through="EventSupport", + through_fields=("event", "supporter_user"), + related_name="supported_events", + ) def save(self, *args: Any, **kwargs: Any) -> None: """ @@ -173,6 +179,8 @@ class Meta: ordering = ["order"] + + # MARK: Flag @@ -187,6 +195,45 @@ class EventFlag(models.Model): creation_date = models.DateTimeField(auto_now=True) +# MARK: Support + + + +class EventSupport(models.Model): + """ + Model for support received by an event. + + Notes + ----- + Only users can support events, so the supporter is a plain FK. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + event = models.ForeignKey( + "Event", + on_delete=models.CASCADE, + related_name="supports_received", + ) + supporter_user = models.ForeignKey( + "authentication.UserModel", + on_delete=models.CASCADE, + related_name="event_supports_given", + ) + creation_date = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + # A user can support a given event at most once. + models.UniqueConstraint( + fields=["event", "supporter_user"], + name="unique_event_support_per_user", + ), + ] + + def __str__(self) -> str: + return f"{self.supporter_user} supports {self.event}" + + # MARK: Format diff --git a/backend/events/serializers.py b/backend/events/serializers.py index caaeb0181..d281f2586 100644 --- a/backend/events/serializers.py +++ b/backend/events/serializers.py @@ -29,6 +29,7 @@ EventFlag, EventResource, EventSocialLink, + EventSupport, EventText, EventTime, Format, @@ -411,6 +412,72 @@ def create(self, validated_data: dict[str, Any]) -> Event: return event +# MARK: Support + + +class EventSupportSerializer(serializers.ModelSerializer[EventSupport]): + """ + Serializer for EventSupport model data. + + Notes + ----- + `supporter_user` is always set from the requesting user in the view, + so clients only ever provide the event (and only when the event id + isn't already in the URL). + """ + + class Meta: + model = EventSupport + fields = "__all__" + read_only_fields = ["supporter_user", "creation_date", "event"] + def create(self, validated_data: dict[str, Any]) -> EventSupport: + """ + Create event support record. + + Parameters + ---------- + validated_data : dict[str, Any] + Dictionary of validated data for creating the event support. + + Returns + ------- + EventSupport + Created EventSupport instance. + """ + event_support = EventSupport.objects.create(**validated_data) + logger.info(f"Created EventSupport with id {event_support.id}") + + return event_support + def validate_event(self, value: Event | UUID | str) -> Event: + """ + Validate that the event exists. + + Parameters + ---------- + value : Event | UUID | str + The value to validate: an Event instance, UUID, or string id. + + Returns + ------- + Event + The validated Event instance. + + Raises + ------ + serializers.ValidationError + If the event does not exist. + """ + if isinstance(value, Event): + return value + + try: + event = Event.objects.get(id=value) + logger.info(f"Event found for value: {value}") + + except Event.DoesNotExist as e: + raise serializers.ValidationError("Event not found.") from e + + return event # MARK: Event @@ -431,6 +498,7 @@ class EventSerializer(serializers.ModelSerializer[Event]): times = EventTimesSerializer(many=True, read_only=True) icon_url = ImageSerializer(required=False) + supporter_count = serializers.SerializerMethodField() class Meta: model = Event @@ -438,9 +506,13 @@ class Meta: extra_kwargs = { "created_by": {"read_only": True}, } + exclude = ["supporters"] - fields = "__all__" - + def get_supporter_count(self, obj: Event) -> int: + """ + Return the supporter tally, using the queryset annotation when present. + """ + return getattr(obj, "_supporter_count", None) or obj.supporters.count() def validate(self, data: dict[str, str | int]) -> dict[str, str | int]: """ Validate event data including time constraints and terms. @@ -562,7 +634,6 @@ class Meta: model = EventFlag fields = "__all__" - # MARK: Format diff --git a/backend/events/urls.py b/backend/events/urls.py index c944c8cfb..bf3cb8816 100644 --- a/backend/events/urls.py +++ b/backend/events/urls.py @@ -15,6 +15,8 @@ EventFlagDetailAPIView, EventResourceViewSet, EventSocialLinkViewSet, + EventSupportAPIView, + EventSupportDetailAPIView, EventTextViewSet, ) @@ -36,6 +38,11 @@ viewset=EventSocialLinkViewSet, basename="event-social-links", ) +router.register( + prefix=r"event_supports", + viewset=EventSupportDetailAPIView, + basename="event-supports", +) urlpatterns = [ path("", include(router.urls)), @@ -45,4 +52,5 @@ path("event_flags/", EventFlagDetailAPIView.as_view()), path("event_calendar", EventCalendarAPIView.as_view()), path("event_texts/", EventTextViewSet.as_view()), + path("event_supports", EventSupportAPIView.as_view()), ] diff --git a/backend/events/views.py b/backend/events/views.py index 2db9be09b..fdf5bc248 100644 --- a/backend/events/views.py +++ b/backend/events/views.py @@ -40,6 +40,7 @@ EventFlag, EventResource, EventSocialLink, + EventSupport, EventText, ) from events.serializers import ( @@ -49,6 +50,7 @@ EventResourceSerializer, EventSerializer, EventSocialLinkSerializer, + EventSupportSerializer, EventTextSerializer, ) @@ -175,7 +177,6 @@ def post(self, request: Request) -> Response: class EventDetailAPIView(APIView): queryset = Event.objects.all() serializer_class = EventSerializer - def get_permissions(self) -> Sequence[Any]: """ Return permissions based on the HTTP method. @@ -396,7 +397,122 @@ def delete(self, request: Request, id: UUID | str) -> Response: {"message": "Flag deleted successfully."}, status=status.HTTP_204_NO_CONTENT ) +# MARK: Support + + +class EventSupportAPIView(GenericAPIView[EventSupport]): + """ + List, create and delete event support relationships. + + Notes + ----- + Registered on both "event_supports" and "event_supports/". + Only users can support events, so POST always uses the requesting user. + """ + + serializer_class = EventSupportSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + + @extend_schema( + responses={ + 200: EventSupportSerializer(many=True), + 404: OpenApiResponse(response={"detail": "Support not found."}), + }, + ) + def get(self, request: Request) -> Response: + try: + support = EventSupport.objects.filter(user_supporter__id=request.user.id) + except EventSupport.DoesNotExist: + return Response( + {"detail": "Support not found."}, status=status.HTTP_404_NOT_FOUND + ) + + return Response( + EventSupportSerializer(support, many=True).data, status=status.HTTP_200_OK + ) + +class EventSupportDetailAPIView(viewsets.ModelViewSet[EventSupport]): + """ + API view for retrieving, updating, and deleting a specific event support. + Only the supporter user or staff can delete the support. + """ + serializer_class = EventSupportSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + queryset = EventSupport.objects.all() + @extend_schema( + responses={ + 201: EventSupportSerializer, + 400: OpenApiResponse(response={"detail": "Failed to create support."}), + } + ) + def post(self, request: Request, pk: None | UUID = None) -> Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + if pk is None: + return Response( + {"detail": "Event ID is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + event_id = pk + if not Event.objects.filter(id=event_id).exists(): + return Response( + {"detail": "Event not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + event = Event.objects.get(id=event_id) + serializer.save(supporter_user=request.user,event=event) + logger.info(f"EventSupport created by user {request.user.id}") + except (IntegrityError, OperationalError) as e: + logger.exception( + f"Failed to create event support for user {request.user.id}: {e}" + ) + return Response( + {"detail": "Failed to create support."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @extend_schema( + responses={ + 204: OpenApiResponse(response={"message": "Support deleted successfully."}), + 400: OpenApiResponse(response={"detail": "Support ID is required."}), + 403: OpenApiResponse( + response={"detail": "You are not authorized to delete this support."} + ), + 404: OpenApiResponse(response={"detail": "Support not found."}), + } + ) + def destroy(self, request: Request, pk: None | UUID = None) -> Response: + if pk is None: + return Response( + {"detail": "Event ID is required to delete support."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + support = EventSupport.objects.get(event__id=pk, supporter_user=request.user) + + except EventSupport.DoesNotExist as e: + logger.exception(f"EventSupport for event id {pk} does not exist for delete: {e}") + return Response( + {"detail": "Support for this event not found."}, status=status.HTTP_404_NOT_FOUND + ) + + if support.supporter_user.id != request.user.id and not request.user.is_staff: + return Response( + {"detail": "You are not authorized to delete this support."}, + status=status.HTTP_403_FORBIDDEN, + ) + + support.delete() + logger.info(f"EventSupport for event id {pk} deleted") + return Response( + {"message": "Support deleted successfully."}, + status=status.HTTP_204_NO_CONTENT, + ) # MARK: FAQ diff --git a/frontend/app/components/card/about/CardAboutGroup.vue b/frontend/app/components/card/about/CardAboutGroup.vue index fc5d0b8bf..e2e5f03ec 100644 --- a/frontend/app/components/card/about/CardAboutGroup.vue +++ b/frontend/app/components/card/about/CardAboutGroup.vue @@ -27,7 +27,7 @@ {{ t("i18n._global.about") }} const { t } = useI18n(); -const { userIsSignedIn } = useUser(); +const { isUserSignedIn } = useUser(); const { $countryName } = useNuxtApp(); const paramsGroupId = useRoute().params.groupId; diff --git a/frontend/app/components/card/about/CardAboutOrganization.vue b/frontend/app/components/card/about/CardAboutOrganization.vue index f332ccb73..6c2b32bda 100644 --- a/frontend/app/components/card/about/CardAboutOrganization.vue +++ b/frontend/app/components/card/about/CardAboutOrganization.vue @@ -28,7 +28,7 @@ {{ t("i18n._global.about") }} const { t } = useI18n(); const { $countryName } = useNuxtApp(); -const { userIsSignedIn } = useUser(); +const { isUserSignedIn } = useUser(); const { openModal: openModalTextOrganization } = useModalHandlers( "ModalTextOrganization" diff --git a/frontend/app/components/card/connect/CardConnect.vue b/frontend/app/components/card/connect/CardConnect.vue index 2c89eb21f..fcace1818 100644 --- a/frontend/app/components/card/connect/CardConnect.vue +++ b/frontend/app/components/card/connect/CardConnect.vue @@ -6,7 +6,7 @@ {{ t("i18n.components._global.connect") }} const { t } = useI18n(); -const { userIsSignedIn } = useUser(); +const { isUserSignedIn } = useUser(); const { openModal: openModalTextEvent } = useModalHandlers("ModalTextEvent"); diff --git a/frontend/app/components/card/get-involved/CardGetInvolvedGroup.vue b/frontend/app/components/card/get-involved/CardGetInvolvedGroup.vue index aff80fa58..e8d944f3f 100644 --- a/frontend/app/components/card/get-involved/CardGetInvolvedGroup.vue +++ b/frontend/app/components/card/get-involved/CardGetInvolvedGroup.vue @@ -6,7 +6,7 @@ {{ t("i18n.components._global.get_involved") }} const { t } = useI18n(); -const { userIsSignedIn } = useUser(); +const { isUserSignedIn } = useUser(); const { openModal: openModalTextGroup } = useModalHandlers("ModalTextGroup"); diff --git a/frontend/app/components/card/get-involved/CardGetInvolvedOrganization.vue b/frontend/app/components/card/get-involved/CardGetInvolvedOrganization.vue index 042bc832a..537adae92 100644 --- a/frontend/app/components/card/get-involved/CardGetInvolvedOrganization.vue +++ b/frontend/app/components/card/get-involved/CardGetInvolvedOrganization.vue @@ -7,7 +7,7 @@ {{ t("i18n.components._global.get_involved") }} @@ -27,7 +27,7 @@ defineProps<{ const { t } = useI18n(); const { clear } = useUserSession(); -const { userIsSignedIn } = useUser(); +const { isUserSignedIn } = useUser(); const { openModal: openModalCreateEvent } = useModalHandlers("ModalCreateEvent"); diff --git a/frontend/app/components/header/HeaderMobile.vue b/frontend/app/components/header/HeaderMobile.vue index 8655a2fb7..83fa1c9ed 100644 --- a/frontend/app/components/header/HeaderMobile.vue +++ b/frontend/app/components/header/HeaderMobile.vue @@ -23,7 +23,7 @@
@@ -38,7 +38,7 @@ id="user-options" class="w-full" :location="dropdownLocation" - :userIsSignedIn="userIsSignedIn" + :isUserSignedIn="isUserSignedIn" />
@@ -53,7 +53,7 @@ const aboveMediumBP = useBreakpoint("md"); const dropdownLocation = DropdownLocation.SIDE_MENU; const searchBarLocation = SearchBarLocation.HEADER; -const { userIsSignedIn } = useUser(); +const { isUserSignedIn } = useUser(); const isSearchExpanded = ref(false); diff --git a/frontend/app/components/header/HeaderWebsite.vue b/frontend/app/components/header/HeaderWebsite.vue index 2e51ebf9d..b1f16ee76 100644 --- a/frontend/app/components/header/HeaderWebsite.vue +++ b/frontend/app/components/header/HeaderWebsite.vue @@ -28,7 +28,7 @@
@@ -45,7 +45,7 @@ v-if="devMode.active" class="w-full" :location="dropdownLocation" - :userIsSignedIn="userIsSignedIn" + :isUserSignedIn="isUserSignedIn" />
@@ -139,7 +139,7 @@ const aboveLargeBP = useBreakpoint("lg"); const devMode = useDevMode(); devMode.check(); const dropdownLocation = DropdownLocation.SIDE_MENU; -const { userIsSignedIn } = useUser(); +const { isUserSignedIn } = useUser(); const headerOpacity: Ref = ref(1); const prevScrollY: Ref = ref(0); diff --git a/frontend/app/components/logo/LogoActivist.vue b/frontend/app/components/logo/LogoActivist.vue index 21618a450..9e41ccfb4 100644 --- a/frontend/app/components/logo/LogoActivist.vue +++ b/frontend/app/components/logo/LogoActivist.vue @@ -3,7 +3,7 @@
+ +
+ + diff --git a/frontend/app/components/sidebar/left/SidebarLeftFooter.vue b/frontend/app/components/sidebar/left/SidebarLeftFooter.vue index b02bf6abc..79963e11c 100644 --- a/frontend/app/components/sidebar/left/SidebarLeftFooter.vue +++ b/frontend/app/components/sidebar/left/SidebarLeftFooter.vue @@ -18,7 +18,7 @@ > @@ -31,7 +31,7 @@ id="user-options" class="w-full" :location="dropdownLocationSideLeftMenu" - :userIsSignedIn="userIsSignedIn" + :isUserSignedIn="isUserSignedIn" /> @@ -42,6 +42,6 @@ defineProps<{ sidebarContentScrollable: boolean; }>(); -const { userIsSignedIn } = useUser(); +const { isUserSignedIn } = useUser(); const dropdownLocationSideLeftMenu = DropdownLocation.SIDE_LEFT_MENU; diff --git a/frontend/app/components/sidebar/left/SidebarLeftMainSectionSelectors.vue b/frontend/app/components/sidebar/left/SidebarLeftMainSectionSelectors.vue index b6af6f12a..a4e4463b6 100644 --- a/frontend/app/components/sidebar/left/SidebarLeftMainSectionSelectors.vue +++ b/frontend/app/components/sidebar/left/SidebarLeftMainSectionSelectors.vue @@ -19,7 +19,10 @@