
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m23s
This commit introduces a new API endpoint to retrieve group members, ensuring that only authorized users can access member information. Additionally, it updates the UI with improved translations for chore management, group lists, and activity logs in both English and Dutch. Styling adjustments in the ListDetailPage enhance user interaction, while minor changes in the SCSS file improve the overall visual presentation.
349 lines
15 KiB
Python
349 lines
15 KiB
Python
# app/api/v1/endpoints/groups.py
|
|
import logging
|
|
from typing import List
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_transactional_session, get_session
|
|
from app.auth import current_active_user
|
|
from app.models import User as UserModel, UserRoleEnum # Import model and enum
|
|
from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest
|
|
from app.schemas.invite import InviteCodePublic
|
|
from app.schemas.message import Message # For simple responses
|
|
from app.schemas.list import ListPublic, ListDetail
|
|
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic
|
|
from app.schemas.user import UserPublic
|
|
from app.crud import group as crud_group
|
|
from app.crud import invite as crud_invite
|
|
from app.crud import list as crud_list
|
|
from app.crud import history as crud_history
|
|
from app.crud import schedule as crud_schedule
|
|
from app.core.exceptions import (
|
|
GroupNotFoundError,
|
|
GroupPermissionError,
|
|
GroupMembershipError,
|
|
GroupOperationError,
|
|
GroupValidationError,
|
|
InviteCreationError
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
@router.post(
|
|
"", # Route relative to prefix "/groups"
|
|
response_model=GroupPublic,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create New Group",
|
|
tags=["Groups"]
|
|
)
|
|
async def create_group(
|
|
group_in: GroupCreate,
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Creates a new group, adding the creator as the owner."""
|
|
logger.info(f"User {current_user.email} creating group: {group_in.name}")
|
|
created_group = await crud_group.create_group(db=db, group_in=group_in, creator_id=current_user.id)
|
|
# Load members explicitly if needed for the response (optional here)
|
|
# created_group = await crud_group.get_group_by_id(db, created_group.id)
|
|
return created_group
|
|
|
|
|
|
@router.get(
|
|
"", # Route relative to prefix "/groups"
|
|
response_model=List[GroupPublic],
|
|
summary="List User's Groups",
|
|
tags=["Groups"]
|
|
)
|
|
async def read_user_groups(
|
|
db: AsyncSession = Depends(get_session), # Use read-only session for GET
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Retrieves all groups the current user is a member of."""
|
|
logger.info(f"Fetching groups for user: {current_user.email}")
|
|
groups = await crud_group.get_user_groups(db=db, user_id=current_user.id)
|
|
return groups
|
|
|
|
|
|
@router.get(
|
|
"/{group_id}",
|
|
response_model=GroupPublic,
|
|
summary="Get Group Details",
|
|
tags=["Groups"]
|
|
)
|
|
async def read_group(
|
|
group_id: int,
|
|
db: AsyncSession = Depends(get_session), # Use read-only session for GET
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Retrieves details for a specific group, including members, if the user is part of it."""
|
|
logger.info(f"User {current_user.email} requesting details for group ID: {group_id}")
|
|
# Check if user is a member first
|
|
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
|
|
if not is_member:
|
|
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
|
|
raise GroupMembershipError(group_id, "view group details")
|
|
|
|
group = await crud_group.get_group_by_id(db=db, group_id=group_id)
|
|
if not group:
|
|
logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)")
|
|
raise GroupNotFoundError(group_id)
|
|
|
|
return group
|
|
|
|
@router.get(
|
|
"/{group_id}/members",
|
|
response_model=List[UserPublic],
|
|
summary="Get Group Members",
|
|
tags=["Groups"]
|
|
)
|
|
async def read_group_members(
|
|
group_id: int,
|
|
db: AsyncSession = Depends(get_session), # Use read-only session for GET
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Retrieves all members of a specific group, if the user is part of it."""
|
|
logger.info(f"User {current_user.email} requesting members for group ID: {group_id}")
|
|
|
|
# Check if user is a member first
|
|
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
|
|
if not is_member:
|
|
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
|
|
raise GroupMembershipError(group_id, "view group members")
|
|
|
|
group = await crud_group.get_group_by_id(db=db, group_id=group_id)
|
|
if not group:
|
|
logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)")
|
|
raise GroupNotFoundError(group_id)
|
|
|
|
# Extract and return just the user information from member associations
|
|
return [member_assoc.user for member_assoc in group.member_associations]
|
|
|
|
@router.post(
|
|
"/{group_id}/invites",
|
|
response_model=InviteCodePublic,
|
|
summary="Create Group Invite",
|
|
tags=["Groups", "Invites"]
|
|
)
|
|
async def create_group_invite(
|
|
group_id: int,
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Generates a new invite code for the group. Requires owner/admin role (MVP: owner only)."""
|
|
logger.info(f"User {current_user.email} attempting to create invite for group {group_id}")
|
|
user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
|
|
|
|
# --- Permission Check (MVP: Owner only) ---
|
|
if user_role != UserRoleEnum.owner:
|
|
logger.warning(f"Permission denied: User {current_user.email} (role: {user_role}) cannot create invite for group {group_id}")
|
|
raise GroupPermissionError(group_id, "create invites")
|
|
|
|
# Check if group exists (implicitly done by role check, but good practice)
|
|
group = await crud_group.get_group_by_id(db, group_id)
|
|
if not group:
|
|
raise GroupNotFoundError(group_id)
|
|
|
|
invite = await crud_invite.create_invite(db=db, group_id=group_id, creator_id=current_user.id)
|
|
if not invite:
|
|
logger.error(f"Failed to generate unique invite code for group {group_id}")
|
|
# This case should ideally be covered by exceptions from create_invite now
|
|
raise InviteCreationError(group_id)
|
|
|
|
logger.info(f"User {current_user.email} created invite code for group {group_id}")
|
|
return invite
|
|
|
|
@router.get(
|
|
"/{group_id}/invites",
|
|
response_model=InviteCodePublic, # Or Optional[InviteCodePublic] if it can be null
|
|
summary="Get Group Active Invite Code",
|
|
tags=["Groups", "Invites"]
|
|
)
|
|
async def get_group_active_invite(
|
|
group_id: int,
|
|
db: AsyncSession = Depends(get_session), # Use read-only session for GET
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Retrieves the active invite code for the group. Requires group membership (owner/admin to be stricter later if needed)."""
|
|
logger.info(f"User {current_user.email} attempting to get active invite for group {group_id}")
|
|
|
|
# Permission check: Ensure user is a member of the group to view invite code
|
|
# Using get_user_role_in_group which also checks membership indirectly
|
|
user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
|
|
if user_role is None: # Not a member
|
|
logger.warning(f"Permission denied: User {current_user.email} is not a member of group {group_id} and cannot view invite code.")
|
|
# More specific error or let GroupPermissionError handle if we want to be generic
|
|
raise GroupMembershipError(group_id, "view invite code for this group (not a member)")
|
|
|
|
# Fetch the active invite for the group
|
|
invite = await crud_invite.get_active_invite_for_group(db, group_id=group_id)
|
|
|
|
if not invite:
|
|
# This case means no active (non-expired, active=true) invite exists.
|
|
# The frontend can then prompt to generate one.
|
|
logger.info(f"No active invite code found for group {group_id} when requested by {current_user.email}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="No active invite code found for this group. Please generate one."
|
|
)
|
|
|
|
logger.info(f"User {current_user.email} retrieved active invite code for group {group_id}")
|
|
return invite # Pydantic will convert InviteModel to InviteCodePublic
|
|
|
|
@router.delete(
|
|
"/{group_id}/leave",
|
|
response_model=Message,
|
|
summary="Leave Group",
|
|
tags=["Groups"]
|
|
)
|
|
async def leave_group(
|
|
group_id: int,
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Removes the current user from the specified group. If the owner is the last member, the group will be deleted."""
|
|
logger.info(f"User {current_user.email} attempting to leave group {group_id}")
|
|
user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
|
|
|
|
if user_role is None:
|
|
raise GroupMembershipError(group_id, "leave (you are not a member)")
|
|
|
|
# Check if owner is the last member
|
|
if user_role == UserRoleEnum.owner:
|
|
member_count = await crud_group.get_group_member_count(db, group_id)
|
|
if member_count <= 1:
|
|
# Delete the group since owner is the last member
|
|
logger.info(f"Owner {current_user.email} is the last member. Deleting group {group_id}")
|
|
await crud_group.delete_group(db, group_id)
|
|
return Message(detail="Group deleted as you were the last member")
|
|
|
|
# Proceed with removal for non-owner or if there are other members
|
|
deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id)
|
|
|
|
if not deleted:
|
|
# Should not happen if role check passed, but handle defensively
|
|
logger.error(f"Failed to remove user {current_user.email} from group {group_id} despite being a member.")
|
|
raise GroupOperationError("Failed to leave group")
|
|
|
|
logger.info(f"User {current_user.email} successfully left group {group_id}")
|
|
return Message(detail="Successfully left the group")
|
|
|
|
# --- Optional: Remove Member Endpoint ---
|
|
@router.delete(
|
|
"/{group_id}/members/{user_id_to_remove}",
|
|
response_model=Message,
|
|
summary="Remove Member From Group (Owner Only)",
|
|
tags=["Groups"]
|
|
)
|
|
async def remove_group_member(
|
|
group_id: int,
|
|
user_id_to_remove: int,
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Removes a specified user from the group. Requires current user to be owner."""
|
|
logger.info(f"Owner {current_user.email} attempting to remove user {user_id_to_remove} from group {group_id}")
|
|
owner_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
|
|
|
|
# --- Permission Check ---
|
|
if owner_role != UserRoleEnum.owner:
|
|
logger.warning(f"Permission denied: User {current_user.email} (role: {owner_role}) cannot remove members from group {group_id}")
|
|
raise GroupPermissionError(group_id, "remove members")
|
|
|
|
# Prevent owner removing themselves via this endpoint
|
|
if current_user.id == user_id_to_remove:
|
|
raise GroupValidationError("Owner cannot remove themselves using this endpoint. Use 'Leave Group' instead.")
|
|
|
|
# Check if target user is actually in the group
|
|
target_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=user_id_to_remove)
|
|
if target_role is None:
|
|
raise GroupMembershipError(group_id, "remove this user (they are not a member)")
|
|
|
|
# Proceed with removal
|
|
deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=user_id_to_remove)
|
|
|
|
if not deleted:
|
|
logger.error(f"Owner {current_user.email} failed to remove user {user_id_to_remove} from group {group_id}.")
|
|
raise GroupOperationError("Failed to remove member")
|
|
|
|
logger.info(f"Owner {current_user.email} successfully removed user {user_id_to_remove} from group {group_id}")
|
|
return Message(detail="Successfully removed member from the group")
|
|
|
|
@router.get(
|
|
"/{group_id}/lists",
|
|
response_model=List[ListDetail],
|
|
summary="Get Group Lists",
|
|
tags=["Groups", "Lists"]
|
|
)
|
|
async def read_group_lists(
|
|
group_id: int,
|
|
db: AsyncSession = Depends(get_session), # Use read-only session for GET
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Retrieves all lists belonging to a specific group, if the user is a member."""
|
|
logger.info(f"User {current_user.email} requesting lists for group ID: {group_id}")
|
|
|
|
# Check if user is a member first
|
|
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
|
|
if not is_member:
|
|
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
|
|
raise GroupMembershipError(group_id, "view group lists")
|
|
|
|
# Get all lists for the user and filter by group_id
|
|
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id)
|
|
group_lists = [list for list in lists if list.group_id == group_id]
|
|
|
|
return group_lists
|
|
|
|
@router.post(
|
|
"/{group_id}/chores/generate-schedule",
|
|
response_model=List[ChoreAssignmentPublic],
|
|
summary="Generate Group Chore Schedule",
|
|
tags=["Groups", "Chores"]
|
|
)
|
|
async def generate_group_chore_schedule(
|
|
group_id: int,
|
|
schedule_in: GroupScheduleGenerateRequest,
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Generates a round-robin chore schedule for a group."""
|
|
logger.info(f"User {current_user.email} generating chore schedule for group {group_id}")
|
|
# Permission check: ensure user is a member (or owner/admin if stricter rules are needed)
|
|
if not await crud_group.is_user_member(db, group_id, current_user.id):
|
|
raise GroupMembershipError(group_id, "generate chore schedule for this group")
|
|
|
|
try:
|
|
assignments = await crud_schedule.generate_group_chore_schedule(
|
|
db=db,
|
|
group_id=group_id,
|
|
start_date=schedule_in.start_date,
|
|
end_date=schedule_in.end_date,
|
|
user_id=current_user.id,
|
|
member_ids=schedule_in.member_ids,
|
|
)
|
|
return assignments
|
|
except Exception as e:
|
|
logger.error(f"Error generating schedule for group {group_id}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
|
|
@router.get(
|
|
"/{group_id}/chores/history",
|
|
response_model=List[ChoreHistoryPublic],
|
|
summary="Get Group Chore History",
|
|
tags=["Groups", "Chores", "History"]
|
|
)
|
|
async def get_group_chore_history(
|
|
group_id: int,
|
|
db: AsyncSession = Depends(get_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Retrieves all chore-related history for a specific group."""
|
|
logger.info(f"User {current_user.email} requesting chore history for group {group_id}")
|
|
# Permission check
|
|
if not await crud_group.is_user_member(db, group_id, current_user.id):
|
|
raise GroupMembershipError(group_id, "view chore history for this group")
|
|
|
|
return await crud_history.get_group_chore_history(db=db, group_id=group_id) |