
This commit introduces a significant update to the database schema and models by implementing soft delete functionality across multiple tables, including users, groups, lists, items, expenses, and more. Key changes include: - Addition of `deleted_at` and `is_deleted` columns to facilitate soft deletes. - Removal of cascading delete behavior from foreign key constraints to prevent accidental data loss. - Updates to the models to incorporate the new soft delete mixin, ensuring consistent handling of deletions across the application. - Introduction of a new endpoint for group deletion, requiring owner confirmation to enhance data integrity. These changes aim to improve data safety and compliance with data protection regulations while maintaining the integrity of related records.
360 lines
15 KiB
Python
360 lines
15 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, GroupDelete
|
|
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}",
|
|
response_model=Message,
|
|
summary="Delete Group (Owner Only)",
|
|
tags=["Groups"]
|
|
)
|
|
async def delete_group(
|
|
group_id: int,
|
|
delete_confirmation: GroupDelete,
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Permanently deletes a group and all associated data. Requires owner role and explicit confirmation."""
|
|
logger.info(f"Owner {current_user.email} attempting to delete group {group_id}")
|
|
|
|
# Check if user is owner
|
|
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 delete group {group_id}")
|
|
raise GroupPermissionError(group_id, "delete group")
|
|
|
|
# Get group to verify name
|
|
group = await crud_group.get_group_by_id(db, group_id)
|
|
if not group:
|
|
raise GroupNotFoundError(group_id)
|
|
|
|
# Verify confirmation name matches group name
|
|
if delete_confirmation.confirmation_name != group.name:
|
|
raise GroupValidationError(
|
|
f"Confirmation name '{delete_confirmation.confirmation_name}' does not match group name '{group.name}'"
|
|
)
|
|
|
|
# Delete the group
|
|
await crud_group.delete_group(db, group_id)
|
|
logger.info(f"Group {group_id} successfully deleted by owner {current_user.email}")
|
|
return Message(detail="Group successfully deleted")
|
|
|
|
@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 user is the owner and last member, the group will be archived."""
|
|
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. Group {group_id} will be archived.")
|
|
# TODO: Implement group archiving logic here
|
|
# For now, we'll just remove the user but keep the group
|
|
await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id)
|
|
return Message(detail="You have left the group. As you were the last member, the group has been archived.")
|
|
|
|
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) |