mitlist/be/app/api/v1/endpoints/groups.py

321 lines
13 KiB
Python

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
from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest
from app.schemas.invite import InviteCodePublic
from app.schemas.message import Message
from app.schemas.list import 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)
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),
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),
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}")
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),
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}")
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)
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)
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")
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}")
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),
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}")
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.")
raise GroupMembershipError(group_id, "view invite code for this group (not a member)")
invite = await crud_invite.get_active_invite_for_group(db, group_id=group_id)
if not invite:
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
@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)")
if user_role == UserRoleEnum.owner:
member_count = await crud_group.get_group_member_count(db, group_id)
if member_count <= 1:
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")
deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id)
if not deleted:
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")
@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)
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")
if current_user.id == user_id_to_remove:
raise GroupValidationError("Owner cannot remove themselves using this endpoint. Use 'Leave Group' instead.")
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)")
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),
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}")
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")
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}")
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}")
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)