mitlist/be/app/crud/group.py
mohamad a0d67f6c66 feat: Add comprehensive notes and tasks for project stabilization and enhancements
- Introduced a new `notes.md` file to document critical tasks and progress for stabilizing the core functionality of the MitList application.
- Documented the status and findings for key tasks, including backend financial logic fixes, frontend expense split settlement implementation, and core authentication flow reviews.
- Outlined remaining work for production deployment, including secret management, CI/CD pipeline setup, and performance optimizations.
- Updated the logging configuration to change the log level to WARNING for production readiness.
- Enhanced the database connection settings to disable SQL query logging in production.
- Added a new endpoint to list all chores for improved user experience and optimized database queries.
- Implemented various CRUD operations for chore assignments, including creation, retrieval, updating, and deletion.
- Updated frontend components and services to support new chore assignment features and improved error handling.
- Enhanced the expense management system with new fields and improved API interactions for better user experience.
2025-05-24 21:36:57 +02:00

299 lines
13 KiB
Python

# app/crud/group.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # For eager loading members
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List
from sqlalchemy import delete, func
import logging # Add logging import
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel
from app.schemas.group import GroupCreate
from app.models import UserRoleEnum # Import enum
from app.core.exceptions import (
GroupOperationError,
GroupNotFoundError,
DatabaseConnectionError,
DatabaseIntegrityError,
DatabaseQueryError,
DatabaseTransactionError,
GroupMembershipError,
GroupPermissionError # Import GroupPermissionError
)
logger = logging.getLogger(__name__) # Initialize logger
# --- Group CRUD ---
async def create_group(db: AsyncSession, group_in: GroupCreate, creator_id: int) -> GroupModel:
"""Creates a group and adds the creator as the owner."""
try:
# Use the composability pattern for transactions as per fastapi-db-strategy.
# This creates a savepoint if already in a transaction (e.g., from get_transactional_session)
# or starts a new transaction if called outside of one (e.g., from a script).
async with db.begin_nested() if db.in_transaction() else db.begin():
db_group = GroupModel(name=group_in.name, created_by_id=creator_id)
db.add(db_group)
await db.flush() # Assigns ID to db_group
db_user_group = UserGroupModel(
user_id=creator_id,
group_id=db_group.id,
role=UserRoleEnum.owner
)
db.add(db_user_group)
await db.flush() # Commits user_group, links to group
# After creation and linking, explicitly load the group with its member associations and users
stmt = (
select(GroupModel)
.where(GroupModel.id == db_group.id)
.options(
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user)
)
)
result = await db.execute(stmt)
loaded_group = result.scalar_one_or_none()
if loaded_group is None:
# This should not happen if we just created it, but as a safeguard
raise GroupOperationError("Failed to load group after creation.")
return loaded_group
except IntegrityError as e:
logger.error(f"Database integrity error during group creation: {str(e)}", exc_info=True)
raise DatabaseIntegrityError(f"Failed to create group due to integrity issue: {str(e)}")
except OperationalError as e:
logger.error(f"Database connection error during group creation: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error during group creation: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during group creation: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Database transaction error during group creation: {str(e)}")
async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
"""Gets all groups a user is a member of with optimized eager loading."""
try:
result = await db.execute(
select(GroupModel)
.join(UserGroupModel)
.where(UserGroupModel.user_id == user_id)
.options(
selectinload(GroupModel.member_associations).options(
selectinload(UserGroupModel.user)
)
)
)
return result.scalars().all()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query user groups: {str(e)}")
async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]:
"""Gets a single group by its ID, optionally loading members."""
try:
result = await db.execute(
select(GroupModel)
.where(GroupModel.id == group_id)
.options(
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user)
)
)
return result.scalars().first()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query group: {str(e)}")
async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool:
"""Checks if a user is a member of a specific group."""
try:
result = await db.execute(
select(UserGroupModel.id)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
.limit(1)
)
return result.scalar_one_or_none() is not None
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to check group membership: {str(e)}")
async def get_user_role_in_group(db: AsyncSession, group_id: int, user_id: int) -> Optional[UserRoleEnum]:
"""Gets the role of a user in a specific group."""
try:
result = await db.execute(
select(UserGroupModel.role)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
)
return result.scalar_one_or_none()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query user role: {str(e)}")
async def add_user_to_group(db: AsyncSession, group_id: int, user_id: int, role: UserRoleEnum = UserRoleEnum.member) -> Optional[UserGroupModel]:
"""Adds a user to a group if they aren't already a member."""
try:
# Check if user is already a member before starting a transaction
existing_stmt = select(UserGroupModel.id).where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
existing_result = await db.execute(existing_stmt)
if existing_result.scalar_one_or_none():
return None
# Use a single transaction
async with db.begin_nested() if db.in_transaction() else db.begin():
db_user_group = UserGroupModel(user_id=user_id, group_id=group_id, role=role)
db.add(db_user_group)
await db.flush() # Assigns ID to db_user_group
# Eagerly load the 'user' and 'group' relationships for the response
stmt = (
select(UserGroupModel)
.where(UserGroupModel.id == db_user_group.id)
.options(
selectinload(UserGroupModel.user),
selectinload(UserGroupModel.group)
)
)
result = await db.execute(stmt)
loaded_user_group = result.scalar_one_or_none()
if loaded_user_group is None:
raise GroupOperationError(f"Failed to load user group association after adding user {user_id} to group {group_id}.")
return loaded_user_group
except IntegrityError as e:
logger.error(f"Database integrity error while adding user to group: {str(e)}", exc_info=True)
raise DatabaseIntegrityError(f"Failed to add user to group: {str(e)}")
except OperationalError as e:
logger.error(f"Database connection error while adding user to group: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while adding user to group: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to add user to group: {str(e)}")
async def remove_user_from_group(db: AsyncSession, group_id: int, user_id: int) -> bool:
"""Removes a user from a group."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin():
result = await db.execute(
delete(UserGroupModel)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
.returning(UserGroupModel.id)
)
return result.scalar_one_or_none() is not None
except OperationalError as e:
logger.error(f"Database connection error while removing user from group: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while removing user from group: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to remove user from group: {str(e)}")
async def get_group_member_count(db: AsyncSession, group_id: int) -> int:
"""Counts the number of members in a group."""
try:
result = await db.execute(
select(func.count(UserGroupModel.id)).where(UserGroupModel.group_id == group_id)
)
return result.scalar_one()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to count group members: {str(e)}")
async def check_group_membership(
db: AsyncSession,
group_id: int,
user_id: int,
action: str = "access this group"
) -> None:
"""
Checks if a user is a member of a group. Raises exceptions if not found or not a member.
Raises:
GroupNotFoundError: If the group_id does not exist.
GroupMembershipError: If the user_id is not a member of the group.
"""
try:
# Check group existence first
group_exists = await db.get(GroupModel, group_id)
if not group_exists:
raise GroupNotFoundError(group_id)
# Check membership
membership = await db.execute(
select(UserGroupModel.id)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
.limit(1)
)
if membership.scalar_one_or_none() is None:
raise GroupMembershipError(group_id, action=action)
# If we reach here, the user is a member
return None
except GroupNotFoundError: # Re-raise specific errors
raise
except GroupMembershipError:
raise
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database while checking membership: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to check group membership: {str(e)}")
async def check_user_role_in_group(
db: AsyncSession,
group_id: int,
user_id: int,
required_role: UserRoleEnum,
action: str = "perform this action"
) -> None:
"""
Checks if a user is a member of a group and has the required role (or higher).
Raises:
GroupNotFoundError: If the group_id does not exist.
GroupMembershipError: If the user_id is not a member of the group.
GroupPermissionError: If the user does not have the required role.
"""
# First, ensure user is a member (this also checks group existence)
await check_group_membership(db, group_id, user_id, action=f"be checked for permissions to {action}")
# Get the user's actual role
actual_role = await get_user_role_in_group(db, group_id, user_id)
# Define role hierarchy (assuming owner > member)
role_hierarchy = {UserRoleEnum.owner: 2, UserRoleEnum.member: 1}
if not actual_role or role_hierarchy.get(actual_role, 0) < role_hierarchy.get(required_role, 0):
raise GroupPermissionError(
group_id=group_id,
action=f"{action} (requires at least '{required_role.value}' role)"
)
# If role is sufficient, return None
return None
async def delete_group(db: AsyncSession, group_id: int) -> None:
"""
Deletes a group and all its associated data (members, invites, lists, etc.).
The cascade delete in the models will handle the deletion of related records.
Raises:
GroupNotFoundError: If the group doesn't exist.
DatabaseError: If there's an error during deletion.
"""
try:
# Get the group first to ensure it exists
group = await get_group_by_id(db, group_id)
if not group:
raise GroupNotFoundError(group_id)
# Delete the group - cascading delete will handle related records
await db.delete(group)
await db.flush()
logger.info(f"Group {group_id} deleted successfully")
except OperationalError as e:
logger.error(f"Database connection error while deleting group {group_id}: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while deleting group {group_id}: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to delete group: {str(e)}")