diff --git a/backend/apps/groups/__init__.py b/backend/apps/groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/groups/admin.py b/backend/apps/groups/admin.py new file mode 100644 index 0000000..e836ace --- /dev/null +++ b/backend/apps/groups/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin + +from apps.groups.models import Group, GroupInvite, GroupMember, JoinRequest + + +@admin.register(Group) +class GroupAdmin(admin.ModelAdmin): + list_display = ["id", "name", "created_by", "created_at"] + search_fields = ["name", "created_by__email"] + + +@admin.register(GroupMember) +class GroupMemberAdmin(admin.ModelAdmin): + list_display = ["id", "group", "user", "role", "joined_at"] + list_filter = ["role"] + search_fields = ["group__name", "user__email"] + + +@admin.register(GroupInvite) +class GroupInviteAdmin(admin.ModelAdmin): + list_display = ["id", "group", "code", "created_by", "use_count", "is_active", "created_at"] + list_filter = ["is_active"] + search_fields = ["code", "group__name"] + + +@admin.register(JoinRequest) +class JoinRequestAdmin(admin.ModelAdmin): + list_display = ["id", "group", "user", "status", "created_at"] + list_filter = ["status"] + search_fields = ["group__name", "user__email"] diff --git a/backend/apps/groups/apps.py b/backend/apps/groups/apps.py new file mode 100644 index 0000000..bc46847 --- /dev/null +++ b/backend/apps/groups/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class GroupsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.groups" + verbose_name = "Groups" diff --git a/backend/apps/groups/migrations/0001_initial.py b/backend/apps/groups/migrations/0001_initial.py new file mode 100644 index 0000000..6c6ad0d --- /dev/null +++ b/backend/apps/groups/migrations/0001_initial.py @@ -0,0 +1,88 @@ +# Generated by Django 5.x for groups app + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Group", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(db_index=True, max_length=256)), + ("description", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("created_by", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="created_groups", to=settings.AUTH_USER_MODEL)), + ], + options={ + "db_table": "groups_group", + "verbose_name": "Group", + "verbose_name_plural": "Groups", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="GroupMember", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("role", models.CharField(choices=[("admin", "Admin"), ("member", "Member")], default="member", max_length=16)), + ("joined_at", models.DateTimeField(auto_now_add=True)), + ("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="memberships", to="groups.group")), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="group_memberships", to=settings.AUTH_USER_MODEL)), + ], + options={ + "db_table": "groups_member", + "verbose_name": "Group Member", + "verbose_name_plural": "Group Members", + "ordering": ["joined_at"], + "unique_together": {("group", "user")}, + }, + ), + migrations.CreateModel( + name="GroupInvite", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("code", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("max_uses", models.PositiveIntegerField(default=0, help_text="0 = unlimited")), + ("use_count", models.PositiveIntegerField(default=0)), + ("is_active", models.BooleanField(db_index=True, default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("created_by", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="created_invites", to=settings.AUTH_USER_MODEL)), + ("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="invites", to="groups.group")), + ], + options={ + "db_table": "groups_invite", + "verbose_name": "Group Invite", + "verbose_name_plural": "Group Invites", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="JoinRequest", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("status", models.CharField(choices=[("pending", "Pending"), ("approved", "Approved"), ("rejected", "Rejected")], db_index=True, default="pending", max_length=16)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("group", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="join_requests", to="groups.group")), + ("invite", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="join_requests", to="groups.groupinvite")), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="join_requests", to=settings.AUTH_USER_MODEL)), + ], + options={ + "db_table": "groups_join_request", + "verbose_name": "Join Request", + "verbose_name_plural": "Join Requests", + "ordering": ["-created_at"], + "unique_together": {("group", "user")}, + }, + ), + ] \ No newline at end of file diff --git a/backend/apps/groups/migrations/__init__.py b/backend/apps/groups/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/groups/models.py b/backend/apps/groups/models.py new file mode 100644 index 0000000..d738c99 --- /dev/null +++ b/backend/apps/groups/models.py @@ -0,0 +1,129 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class GroupRole(models.TextChoices): + ADMIN = "admin", "Admin" + MEMBER = "member", "Member" + + +class JoinRequestStatus(models.TextChoices): + PENDING = "pending", "Pending" + APPROVED = "approved", "Approved" + REJECTED = "rejected", "Rejected" + + +class Group(models.Model): + name = models.CharField(max_length=256, db_index=True) + description = models.TextField(blank=True, default="") + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="created_groups", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "groups_group" + verbose_name = "Group" + verbose_name_plural = "Groups" + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.name + + +class GroupMember(models.Model): + group = models.ForeignKey( + Group, + on_delete=models.CASCADE, + related_name="memberships", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="group_memberships", + ) + role = models.CharField( + max_length=16, + choices=GroupRole.choices, + default=GroupRole.MEMBER, + ) + joined_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "groups_member" + verbose_name = "Group Member" + verbose_name_plural = "Group Members" + ordering = ["joined_at"] + unique_together = [("group", "user")] + + def __str__(self) -> str: + return f"{self.user} in {self.group} ({self.role})" + + +class GroupInvite(models.Model): + group = models.ForeignKey( + Group, + on_delete=models.CASCADE, + related_name="invites", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="created_invites", + ) + code = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + max_uses = models.PositiveIntegerField(default=0, help_text="0 = unlimited") + use_count = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "groups_invite" + verbose_name = "Group Invite" + verbose_name_plural = "Group Invites" + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"Invite for {self.group.name} ({self.code})" + + +class JoinRequest(models.Model): + group = models.ForeignKey( + Group, + on_delete=models.CASCADE, + related_name="join_requests", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="join_requests", + ) + invite = models.ForeignKey( + GroupInvite, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="join_requests", + ) + status = models.CharField( + max_length=16, + choices=JoinRequestStatus.choices, + default=JoinRequestStatus.PENDING, + db_index=True, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "groups_join_request" + verbose_name = "Join Request" + verbose_name_plural = "Join Requests" + ordering = ["-created_at"] + unique_together = [("group", "user")] + + def __str__(self) -> str: + return f"{self.user} → {self.group.name} ({self.status})" diff --git a/backend/apps/groups/permissions.py b/backend/apps/groups/permissions.py new file mode 100644 index 0000000..5c9ff05 --- /dev/null +++ b/backend/apps/groups/permissions.py @@ -0,0 +1,25 @@ +from rest_framework import permissions +from rest_framework.request import Request + +from apps.groups.models import Group, GroupMember, GroupRole + + +class IsGroupAdmin(permissions.BasePermission): + """Only group admins can perform the action.""" + + def has_object_permission(self, request: Request, view: object, obj: Group) -> bool: + return GroupMember.objects.filter( + group=obj, + user=request.user, + role=GroupRole.ADMIN, + ).exists() + + +class IsGroupMember(permissions.BasePermission): + """Only group members (any role) can perform the action.""" + + def has_object_permission(self, request: Request, view: object, obj: Group) -> bool: + return GroupMember.objects.filter( + group=obj, + user=request.user, + ).exists() diff --git a/backend/apps/groups/serializers.py b/backend/apps/groups/serializers.py new file mode 100644 index 0000000..8197e2a --- /dev/null +++ b/backend/apps/groups/serializers.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from rest_framework import serializers + +from apps.groups.models import Group, GroupInvite, GroupMember, GroupRole, JoinRequest, JoinRequestStatus + + +class GroupMemberSerializer(serializers.ModelSerializer): + user_id = serializers.IntegerField(source="user.id", read_only=True) + user_email = serializers.CharField(source="user.email", read_only=True) + user_username = serializers.CharField(source="user.username", read_only=True) + + class Meta: + model = GroupMember + fields = [ + "id", "user_id", "user_email", "user_username", + "role", "joined_at", + ] + + +class GroupListSerializer(serializers.ModelSerializer): + member_count = serializers.SerializerMethodField() + user_role = serializers.SerializerMethodField() + + class Meta: + model = Group + fields = [ + "id", "name", "description", "created_by", + "member_count", "user_role", "created_at", "updated_at", + ] + + def get_member_count(self, obj: Group) -> int: + return getattr(obj, "_member_count", obj.memberships.count()) + + def get_user_role(self, obj: Group) -> str | None: + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return None + membership = getattr(obj, "_user_membership", None) + if membership is None: + try: + membership = obj.memberships.get(user=request.user) + except GroupMember.DoesNotExist: + return None + return membership.role + + +class GroupDetailSerializer(serializers.ModelSerializer): + members = GroupMemberSerializer(source="memberships", many=True, read_only=True) + member_count = serializers.SerializerMethodField() + user_role = serializers.SerializerMethodField() + created_by_email = serializers.CharField(source="created_by.email", read_only=True) + created_by_username = serializers.CharField(source="created_by.username", read_only=True) + + class Meta: + model = Group + fields = [ + "id", "name", "description", "created_by", "created_by_email", + "created_by_username", "members", "member_count", "user_role", + "created_at", "updated_at", + ] + + def get_member_count(self, obj: Group) -> int: + return getattr(obj, "_member_count", obj.memberships.count()) + + def get_user_role(self, obj: Group) -> str | None: + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return None + membership = getattr(obj, "_user_membership", None) + if membership is None: + try: + membership = obj.memberships.get(user=request.user) + except GroupMember.DoesNotExist: + return None + return membership.role + + +class GroupCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ["name", "description"] + + def validate_name(self, value: str) -> str: + if not value.strip(): + raise serializers.ValidationError("Group name cannot be empty.") + if len(value.strip()) < 2: + raise serializers.ValidationError("Group name must be at least 2 characters.") + return value.strip() + + def create(self, validated_data: dict) -> Group: + user = self.context["request"].user + group = Group.objects.create(created_by=user, **validated_data) + GroupMember.objects.create(group=group, user=user, role=GroupRole.ADMIN) + return group + + +class GroupUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ["name", "description"] + + def validate_name(self, value: str) -> str: + if not value.strip(): + raise serializers.ValidationError("Group name cannot be empty.") + if len(value.strip()) < 2: + raise serializers.ValidationError("Group name must be at least 2 characters.") + return value.strip() + + +class GroupInviteSerializer(serializers.ModelSerializer): + created_by_email = serializers.CharField(source="created_by.email", read_only=True) + group_name = serializers.CharField(source="group.name", read_only=True) + join_url = serializers.SerializerMethodField() + + class Meta: + model = GroupInvite + fields = [ + "id", "group", "group_name", "code", "created_by", "created_by_email", + "max_uses", "use_count", "is_active", "join_url", "created_at", + ] + read_only_fields = ["id", "group", "code", "created_by", "use_count", "created_at"] + + def get_join_url(self, obj: GroupInvite) -> str: + request = self.context.get("request") + if request: + return f"{request.build_absolute_uri('/')[:-1]}/groups/join/{obj.code}" + return f"/groups/join/{obj.code}" + + +class GroupInviteCreateSerializer(serializers.ModelSerializer): + class Meta: + model = GroupInvite + fields = ["max_uses"] + + def validate_max_uses(self, value: int) -> int: + if value < 0: + raise serializers.ValidationError("Max uses cannot be negative.") + return value + + def create(self, validated_data: dict) -> GroupInvite: + group = self.context["group"] + user = self.context["request"].user + return GroupInvite.objects.create( + group=group, + created_by=user, + **validated_data, + ) + + +class JoinRequestSerializer(serializers.ModelSerializer): + user_id = serializers.IntegerField(source="user.id", read_only=True) + user_email = serializers.CharField(source="user.email", read_only=True) + user_username = serializers.CharField(source="user.username", read_only=True) + group_name = serializers.CharField(source="group.name", read_only=True) + invite_code = serializers.UUIDField(source="invite.code", read_only=True, default=None) + + class Meta: + model = JoinRequest + fields = [ + "id", "group", "group_name", "user", "user_id", "user_email", + "user_username", "invite", "invite_code", "status", "created_at", + ] + read_only_fields = ["id", "group", "user", "invite", "created_at"] + + +class JoinViaInviteSerializer(serializers.Serializer): + """Validates and processes joining a group via an invite code.""" + + code = serializers.UUIDField() + + def validate_code(self, value: str) -> str: + try: + invite = GroupInvite.objects.select_related("group").get(code=value) + except GroupInvite.DoesNotExist: + raise serializers.ValidationError("Invalid invite code.") + + if not invite.is_active: + raise serializers.ValidationError("This invite is no longer active.") + + if invite.max_uses > 0 and invite.use_count >= invite.max_uses: + raise serializers.ValidationError("This invite has reached its maximum uses.") + + return value + + +class RoleUpdateSerializer(serializers.Serializer): + role = serializers.ChoiceField(choices=GroupRole.choices) + + def validate_role(self, value: str) -> str: + if value == GroupRole.MEMBER: + group = self.context["group"] + admin_count = group.memberships.filter(role=GroupRole.ADMIN).count() + if admin_count <= 1: + raise serializers.ValidationError( + "Cannot remove the last admin. Transfer admin role first or dissolve the group." + ) + return value diff --git a/backend/apps/groups/urls.py b/backend/apps/groups/urls.py new file mode 100644 index 0000000..f2ab547 --- /dev/null +++ b/backend/apps/groups/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.groups.views import GroupViewSet, JoinGroupViewSet + +router = DefaultRouter() +router.register(r"groups", GroupViewSet, basename="group") +router.register(r"join", JoinGroupViewSet, basename="join-group") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/apps/groups/views.py b/backend/apps/groups/views.py new file mode 100644 index 0000000..05443f1 --- /dev/null +++ b/backend/apps/groups/views.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +from django.db.models import Count, Prefetch, QuerySet +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response + +from apps.groups.models import Group, GroupInvite, GroupMember, GroupRole, JoinRequest, JoinRequestStatus +from apps.groups.permissions import IsGroupAdmin, IsGroupMember +from apps.groups.serializers import ( + GroupCreateSerializer, + GroupDetailSerializer, + GroupInviteCreateSerializer, + GroupInviteSerializer, + GroupListSerializer, + GroupMemberSerializer, + GroupUpdateSerializer, + JoinRequestSerializer, + JoinViaInviteSerializer, + RoleUpdateSerializer, +) + + +class GroupViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + if self.action == "create": + return GroupCreateSerializer + if self.action in ("update", "partial_update"): + return GroupUpdateSerializer + if self.action == "retrieve": + return GroupDetailSerializer + return GroupListSerializer + + def get_queryset(self) -> QuerySet[Group]: + user = self.request.user + qs = Group.objects.filter(memberships__user=user).distinct() + qs = qs.annotate(_member_count=Count("memberships")) + if self.action in ("list", "retrieve"): + qs = qs.prefetch_related( + Prefetch( + "memberships", + queryset=GroupMember.objects.select_related("user").order_by("joined_at"), + ) + ) + return qs + + def get_object(self) -> Group: + obj = super().get_object() + # Cache the requesting user's membership for serializers + try: + obj._user_membership = obj.memberships.get(user=self.request.user) + except GroupMember.DoesNotExist: + obj._user_membership = None + return obj + + def perform_create(self, serializer: GroupCreateSerializer) -> Group: + return serializer.save() + + def perform_destroy(self, instance: Group) -> None: + # Only admin can delete/dissolve the group + if not GroupMember.objects.filter( + group=instance, user=self.request.user, role=GroupRole.ADMIN + ).exists(): + from rest_framework.exceptions import PermissionDenied + + raise PermissionDenied("Only group admins can delete the group.") + instance.delete() + + # ---- Members ---- + + @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated, IsGroupMember]) + def members(self, request: Request, pk: str | None = None) -> Response: + """List all members of the group.""" + group = self.get_object() + memberships = group.memberships.select_related("user").order_by("joined_at") + serializer = GroupMemberSerializer(memberships, many=True) + return Response(serializer.data) + + @action( + detail=True, + methods=["delete"], + url_path="members/(?P[^/.]+)", + permission_classes=[IsAuthenticated, IsGroupAdmin], + ) + def remove_member(self, request: Request, pk: str | None = None, user_id: str | None = None) -> Response: + """Admin removes a member from the group.""" + group = self.get_object() + try: + membership = GroupMember.objects.get(group=group, user_id=user_id) + except GroupMember.DoesNotExist: + return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND) + + if membership.user == request.user: + return Response( + {"error": "Admins cannot remove themselves. Use leave instead, or transfer admin first."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + membership.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @action( + detail=True, + methods=["patch"], + url_path="members/(?P[^/.]+)/role", + permission_classes=[IsAuthenticated, IsGroupAdmin], + ) + def update_member_role(self, request: Request, pk: str | None = None, user_id: str | None = None) -> Response: + """Admin transfers admin role or changes member role.""" + group = self.get_object() + serializer = RoleUpdateSerializer(data=request.data, context={"group": group}) + serializer.is_valid(raise_exception=True) + + try: + membership = GroupMember.objects.get(group=group, user_id=user_id) + except GroupMember.DoesNotExist: + return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND) + + membership.role = serializer.validated_data["role"] + membership.save(update_fields=["role"]) + + if serializer.validated_data["role"] == GroupRole.ADMIN and membership.user != request.user: + # Downgrade the current admin to member + GroupMember.objects.filter(group=group, user=request.user).update(role=GroupRole.MEMBER) + + return Response(GroupMemberSerializer(membership).data) + + @action(detail=True, methods=["post"], permission_classes=[IsAuthenticated, IsGroupMember]) + def leave(self, request: Request, pk: str | None = None) -> Response: + """Member leaves the group. If admin is last admin, dissolve the group.""" + group = self.get_object() + membership = GroupMember.objects.filter(group=group, user=request.user).first() + + if not membership: + return Response({"error": "You are not a member of this group."}, status=status.HTTP_400_BAD_REQUEST) + + if membership.role == GroupRole.ADMIN: + admin_count = GroupMember.objects.filter(group=group, role=GroupRole.ADMIN).count() + if admin_count <= 1: + # Last admin leaving — dissolve the group + group.delete() + return Response({"detail": "You were the last admin. The group has been dissolved."}) + + membership.delete() + return Response({"detail": "You have left the group."}) + + # ---- Invites ---- + + @action(detail=True, methods=["get", "post"], permission_classes=[IsAuthenticated, IsGroupAdmin]) + def invites(self, request: Request, pk: str | None = None) -> Response: + """List or create invites for the group.""" + group = self.get_object() + + if request.method == "GET": + invites_qs = group.invites.select_related("created_by").order_by("-created_at") + serializer = GroupInviteSerializer(invites_qs, many=True, context={"request": request}) + return Response(serializer.data) + + serializer = GroupInviteCreateSerializer( + data=request.data, + context={"group": group, "request": request}, + ) + serializer.is_valid(raise_exception=True) + invite = serializer.save() + return Response( + GroupInviteSerializer(invite, context={"request": request}).data, + status=status.HTTP_201_CREATED, + ) + + @action( + detail=True, + methods=["delete"], + url_path="invites/(?P[^/.]+)", + permission_classes=[IsAuthenticated, IsGroupAdmin], + ) + def revoke_invite(self, request: Request, pk: str | None = None, invite_id: str | None = None) -> Response: + """Revoke an invite by deactivating it.""" + group = self.get_object() + try: + invite = GroupInvite.objects.get(id=invite_id, group=group) + except GroupInvite.DoesNotExist: + return Response({"error": "Invite not found."}, status=status.HTTP_404_NOT_FOUND) + + invite.is_active = False + invite.save(update_fields=["is_active"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + # ---- Join Requests ---- + + @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated, IsGroupAdmin]) + def requests(self, request: Request, pk: str | None = None) -> Response: + """List pending join requests for the group (admin only).""" + group = self.get_object() + join_requests = group.join_requests.select_related("user", "invite").order_by("-created_at") + serializer = JoinRequestSerializer(join_requests, many=True) + return Response(serializer.data) + + @action( + detail=True, + methods=["post"], + url_path="requests/(?P[^/.]+)/approve", + permission_classes=[IsAuthenticated, IsGroupAdmin], + ) + def approve_request(self, request: Request, pk: str | None = None, request_id: str | None = None) -> Response: + """Approve a pending join request.""" + group = self.get_object() + try: + join_request = JoinRequest.objects.get(id=request_id, group=group, status=JoinRequestStatus.PENDING) + except JoinRequest.DoesNotExist: + return Response({"error": "Pending join request not found."}, status=status.HTTP_404_NOT_FOUND) + + join_request.status = JoinRequestStatus.APPROVED + join_request.save(update_fields=["status"]) + + GroupMember.objects.get_or_create( + group=group, + user=join_request.user, + defaults={"role": GroupRole.MEMBER}, + ) + + # Increment invite use count + if join_request.invite: + join_request.invite.use_count += 1 + join_request.invite.save(update_fields=["use_count"]) + + return Response(JoinRequestSerializer(join_request).data) + + @action( + detail=True, + methods=["post"], + url_path="requests/(?P[^/.]+)/reject", + permission_classes=[IsAuthenticated, IsGroupAdmin], + ) + def reject_request(self, request: Request, pk: str | None = None, request_id: str | None = None) -> Response: + """Reject a pending join request.""" + group = self.get_object() + try: + join_request = JoinRequest.objects.get(id=request_id, group=group, status=JoinRequestStatus.PENDING) + except JoinRequest.DoesNotExist: + return Response({"error": "Pending join request not found."}, status=status.HTTP_404_NOT_FOUND) + + join_request.status = JoinRequestStatus.REJECTED + join_request.save(update_fields=["status"]) + return Response(JoinRequestSerializer(join_request).data) + + +class JoinGroupViewSet(viewsets.GenericViewSet): + """Public(ish) endpoint for joining a group via an invite code.""" + + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=["get"], url_path="(?P[^/.]+)") + def validate_invite(self, request: Request, code: str | None = None) -> Response: + """Check if an invite code is valid and show group info.""" + try: + invite = GroupInvite.objects.select_related("group", "group__created_by").get(code=code) + except GroupInvite.DoesNotExist: + return Response({"error": "Invalid invite code."}, status=status.HTTP_404_NOT_FOUND) + + if not invite.is_active: + return Response({"error": "This invite is no longer active."}, status=status.HTTP_410_GONE) + + if invite.max_uses > 0 and invite.use_count >= invite.max_uses: + return Response({"error": "This invite has reached its maximum uses."}, status=status.HTTP_410_GONE) + + return Response({ + "group": { + "id": invite.group.id, + "name": invite.group.name, + "description": invite.group.description, + "created_by_email": invite.group.created_by.email, + "member_count": invite.group.memberships.count(), + }, + "invite": { + "code": str(invite.code), + "created_by_email": invite.created_by.email, + }, + }) + + @action(detail=False, methods=["post"], url_path="(?P[^/.]+)") + def join(self, request: Request, code: str | None = None) -> Response: + """Join a group via invite code.""" + try: + invite = GroupInvite.objects.select_related("group").get(code=code) + except GroupInvite.DoesNotExist: + return Response({"error": "Invalid invite code."}, status=status.HTTP_404_NOT_FOUND) + + if not invite.is_active: + return Response({"error": "This invite is no longer active."}, status=status.HTTP_410_GONE) + + if invite.max_uses > 0 and invite.use_count >= invite.max_uses: + return Response({"error": "This invite has reached its maximum uses."}, status=status.HTTP_410_GONE) + + group = invite.group + + # Check if already a member + if GroupMember.objects.filter(group=group, user=request.user).exists(): + return Response( + {"detail": "You are already a member of this group.", "group_id": group.id}, + status=status.HTTP_200_OK, + ) + + # Check for existing pending request + existing_request = JoinRequest.objects.filter( + group=group, user=request.user, status=JoinRequestStatus.PENDING + ).first() + if existing_request: + return Response( + JoinRequestSerializer(existing_request).data, + status=status.HTTP_200_OK, + ) + + # Create join request or add directly (direct join for now — simple invite flow) + member = GroupMember.objects.create(group=group, user=request.user, role=GroupRole.MEMBER) + invite.use_count += 1 + invite.save(update_fields=["use_count"]) + + # Also create a join request record for tracking + JoinRequest.objects.create( + group=group, + user=request.user, + invite=invite, + status=JoinRequestStatus.APPROVED, + ) + + serializer = GroupDetailSerializer(group, context={"request": request}) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/backend/config/django.py b/backend/config/django.py index 4019b7c..bd5a19c 100644 --- a/backend/config/django.py +++ b/backend/config/django.py @@ -41,6 +41,7 @@ INSTALLED_APPS = [ "apps.books", "apps.annotations", "apps.reader", + "apps.groups", ] MIDDLEWARE = [ diff --git a/backend/config/urls.py b/backend/config/urls.py index 07d350e..6db2cb6 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path("api/books/", include("apps.books.urls")), path("api/annotations/", include("apps.annotations.urls")), path("api/reader/", include("apps.reader.urls")), + path("api/", include("apps.groups.urls")), ] if settings.DEBUG: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 45ba735..139ccc2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,10 @@ const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage }))); const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage }))); const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default }))); +const GroupsListPage = lazy(() => import("./pages/GroupsListPage").then((m) => ({ default: m.GroupsListPage }))); +const GroupDetailPage = lazy(() => import("./pages/GroupDetailPage").then((m) => ({ default: m.GroupDetailPage }))); +const CreateGroupPage = lazy(() => import("./pages/CreateGroupPage").then((m) => ({ default: m.CreateGroupPage }))); +const JoinGroupPage = lazy(() => import("./pages/JoinGroupPage").then((m) => ({ default: m.JoinGroupPage }))); const AuthPage = lazy(() => import("./pages/AuthPage")); @@ -45,6 +49,10 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/api/groups.ts b/frontend/src/api/groups.ts new file mode 100644 index 0000000..eb00910 --- /dev/null +++ b/frontend/src/api/groups.ts @@ -0,0 +1,100 @@ +import api from "./client"; +import type { + CreateGroupPayload, + CreateInvitePayload, + GroupDetail, + GroupInvite, + GroupListItem, + InviteValidation, + JoinRequest, + UpdateGroupPayload, +} from "../../packages/shared/src/types"; + +export const groupsApi = { + // ---- Group CRUD ---- + + async listGroups(): Promise { + const { data } = await api.get<{ count: number; results: GroupListItem[] } | GroupListItem[]>("/groups/"); + if (Array.isArray(data)) return data; + return data.results ?? []; + }, + + async getGroup(id: number): Promise { + const { data } = await api.get(`/groups/${id}/`); + return data; + }, + + async createGroup(payload: CreateGroupPayload): Promise { + const { data } = await api.post("/groups/", payload); + return data; + }, + + async updateGroup(id: number, payload: UpdateGroupPayload): Promise { + const { data } = await api.patch(`/groups/${id}/`, payload); + return data; + }, + + async deleteGroup(id: number): Promise { + await api.delete(`/groups/${id}/`); + }, + + // ---- Members ---- + + async removeMember(groupId: number, userId: number): Promise { + await api.delete(`/groups/${groupId}/members/${userId}/`); + }, + + async updateMemberRole(groupId: number, userId: number, role: "admin" | "member"): Promise { + await api.patch(`/groups/${groupId}/members/${userId}/role/`, { role }); + }, + + async leaveGroup(groupId: number): Promise<{ detail: string }> { + const { data } = await api.post<{ detail: string }>(`/groups/${groupId}/leave/`); + return data; + }, + + // ---- Invites ---- + + async listInvites(groupId: number): Promise { + const { data } = await api.get(`/groups/${groupId}/invites/`); + return data; + }, + + async createInvite(groupId: number, payload: CreateInvitePayload = {}): Promise { + const { data } = await api.post(`/groups/${groupId}/invites/`, payload); + return data; + }, + + async revokeInvite(groupId: number, inviteId: number): Promise { + await api.delete(`/groups/${groupId}/invites/${inviteId}/`); + }, + + // ---- Join Requests ---- + + async listJoinRequests(groupId: number): Promise { + const { data } = await api.get(`/groups/${groupId}/requests/`); + return data; + }, + + async approveRequest(groupId: number, requestId: number): Promise { + const { data } = await api.post(`/groups/${groupId}/requests/${requestId}/approve/`); + return data; + }, + + async rejectRequest(groupId: number, requestId: number): Promise { + const { data } = await api.post(`/groups/${groupId}/requests/${requestId}/reject/`); + return data; + }, + + // ---- Join via Invite ---- + + async validateInvite(code: string): Promise { + const { data } = await api.get(`/join/${code}/`); + return data; + }, + + async joinViaInvite(code: string): Promise { + const { data } = await api.post(`/join/${code}/`); + return data; + }, +}; \ No newline at end of file diff --git a/frontend/src/pages/CreateGroupPage.tsx b/frontend/src/pages/CreateGroupPage.tsx new file mode 100644 index 0000000..7c35449 --- /dev/null +++ b/frontend/src/pages/CreateGroupPage.tsx @@ -0,0 +1,104 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { groupsApi } from "../api/groups"; +import { useToast } from "../hooks/useToast"; + +const S = { + container: { maxWidth: 500, margin: "0 auto", padding: "24px 16px" } satisfies React.CSSProperties, + backBtn: { + background: "none", border: "none", color: "#3b82f6", fontSize: 14, cursor: "pointer", + padding: 0, marginBottom: 16, minHeight: 44, minWidth: 44, + } satisfies React.CSSProperties, + title: { fontSize: 24, fontWeight: 700, marginBottom: 24 } satisfies React.CSSProperties, + label: { display: "block", fontSize: 14, fontWeight: 500, marginBottom: 6 } satisfies React.CSSProperties, + input: { + width: "100%", padding: "10px 12px", border: "1px solid #d1d5db", borderRadius: 8, + fontSize: 14, marginBottom: 16, boxSizing: "border-box" as const, + } satisfies React.CSSProperties, + textarea: { + width: "100%", padding: "10px 12px", border: "1px solid #d1d5db", borderRadius: 8, + fontSize: 14, marginBottom: 16, minHeight: 80, resize: "vertical" as const, + boxSizing: "border-box" as const, fontFamily: "inherit", + } satisfies React.CSSProperties, + submitBtn: (disabled: boolean): React.CSSProperties => ({ + width: "100%", padding: "12px", backgroundColor: disabled ? "#93c5fd" : "#3b82f6", + color: "#fff", border: "none", borderRadius: 8, fontSize: 15, fontWeight: 600, + cursor: disabled ? "not-allowed" : "pointer", minHeight: 44, + }), + errorText: { color: "#ef4444", fontSize: 13, marginBottom: 12 }, +}; + +export function CreateGroupPage() { + const navigate = useNavigate(); + const { showToast } = useToast(); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { + setError("Group name is required."); + return; + } + if (name.trim().length < 2) { + setError("Group name must be at least 2 characters."); + return; + } + setSubmitting(true); + setError(null); + try { + const group = await groupsApi.createGroup({ + name: name.trim(), + description: description.trim() || undefined, + }); + showToast({ message: "Group created!", variant: "success" }); + navigate(`/groups/${group.id}`); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to create group"; + setError(msg); + showToast({ message: msg, variant: "error" }); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ +

Create a Group

+ +
+ {error &&
{error}
} + + + setName(e.target.value)} + placeholder="e.g., Sci-Fi Book Club" + maxLength={256} + autoFocus + /> + + +