Archived
feat: implement group creation and management (US #28)
Backend: - Add groups Django app with models: Group, GroupMember, GroupInvite, JoinRequest - Create serializers with business rule validation - Implement GroupViewSet with full CRUD + custom actions (members, invites, roles, leave, join requests) - Add JoinGroupViewSet for invite-based joining flow - Register app in Django config and URL routing Frontend: - Add shared types for groups to @cloud-reader/shared - Create groups API client (groupsApi) - Build GroupsListPage, GroupDetailPage (member mgmt, invites, role transfer) - Build CreateGroupPage and JoinGroupPage - Add lazy-loaded routes to App.tsx with ProtectedRoute - Add navigation links to Library header Ref: #28
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user