feat: Add chore history and scheduling functionality
This commit introduces new models and endpoints for managing chore history and scheduling within the application. Key changes include: - Added `ChoreHistory` and `ChoreAssignmentHistory` models to track changes and events related to chores and assignments. - Implemented CRUD operations for chore history in the `history.py` module. - Created endpoints to retrieve chore and assignment history in the `chores.py` and `groups.py` files. - Introduced a scheduling feature for group chores, allowing for round-robin assignment generation. - Updated existing chore and assignment CRUD operations to log history entries for create, update, and delete actions. This enhancement improves the tracking of chore-related events and facilitates better management of group chore assignments.
This commit is contained in:
parent
f8788ee42d
commit
fb951acb72
@ -0,0 +1,75 @@
|
|||||||
|
"""Add chore history and scheduling tables
|
||||||
|
|
||||||
|
Revision ID: 05bf96a9e18b
|
||||||
|
Revises: 91d00c100f5b
|
||||||
|
Create Date: 2025-06-08 00:41:10.516324
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '05bf96a9e18b'
|
||||||
|
down_revision: Union[str, None] = '91d00c100f5b'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('chore_history',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('chore_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('group_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False),
|
||||||
|
sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('changed_by_user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['chore_id'], ['chores.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chore_history_chore_id'), 'chore_history', ['chore_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chore_history_group_id'), 'chore_history', ['group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chore_history_id'), 'chore_history', ['id'], unique=False)
|
||||||
|
op.create_table('chore_assignment_history',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('assignment_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False),
|
||||||
|
sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('changed_by_user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['assignment_id'], ['chore_assignments.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chore_assignment_history_assignment_id'), 'chore_assignment_history', ['assignment_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chore_assignment_history_id'), 'chore_assignment_history', ['id'], unique=False)
|
||||||
|
op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs')
|
||||||
|
op.drop_table('apscheduler_jobs')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('apscheduler_jobs',
|
||||||
|
sa.Column('id', sa.VARCHAR(length=191), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('next_run_time', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('job_state', postgresql.BYTEA(), autoincrement=False, nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='apscheduler_jobs_pkey')
|
||||||
|
)
|
||||||
|
op.create_index('ix_apscheduler_jobs_next_run_time', 'apscheduler_jobs', ['next_run_time'], unique=False)
|
||||||
|
op.drop_index(op.f('ix_chore_assignment_history_id'), table_name='chore_assignment_history')
|
||||||
|
op.drop_index(op.f('ix_chore_assignment_history_assignment_id'), table_name='chore_assignment_history')
|
||||||
|
op.drop_table('chore_assignment_history')
|
||||||
|
op.drop_index(op.f('ix_chore_history_id'), table_name='chore_history')
|
||||||
|
op.drop_index(op.f('ix_chore_history_group_id'), table_name='chore_history')
|
||||||
|
op.drop_index(op.f('ix_chore_history_chore_id'), table_name='chore_history')
|
||||||
|
op.drop_table('chore_history')
|
||||||
|
# ### end Alembic commands ###
|
@ -8,8 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.database import get_transactional_session, get_session
|
from app.database import get_transactional_session, get_session
|
||||||
from app.auth import current_active_user
|
from app.auth import current_active_user
|
||||||
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum
|
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum
|
||||||
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChorePublic, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic
|
from app.schemas.chore import (
|
||||||
|
ChoreCreate, ChoreUpdate, ChorePublic,
|
||||||
|
ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic,
|
||||||
|
ChoreHistoryPublic, ChoreAssignmentHistoryPublic
|
||||||
|
)
|
||||||
from app.crud import chore as crud_chore
|
from app.crud import chore as crud_chore
|
||||||
|
from app.crud import history as crud_history
|
||||||
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
|
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -451,3 +456,65 @@ async def complete_chore_assignment(
|
|||||||
except DatabaseIntegrityError as e:
|
except DatabaseIntegrityError as e:
|
||||||
logger.error(f"DatabaseIntegrityError completing assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True)
|
logger.error(f"DatabaseIntegrityError completing assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True)
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
|
||||||
|
|
||||||
|
# === CHORE HISTORY ENDPOINTS ===
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{chore_id}/history",
|
||||||
|
response_model=PyList[ChoreHistoryPublic],
|
||||||
|
summary="Get Chore History",
|
||||||
|
tags=["Chores", "History"]
|
||||||
|
)
|
||||||
|
async def get_chore_history(
|
||||||
|
chore_id: int,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Retrieves the history of a specific chore."""
|
||||||
|
# First, check if user has permission to view the chore itself
|
||||||
|
chore = await crud_chore.get_chore_by_id(db, chore_id)
|
||||||
|
if not chore:
|
||||||
|
raise ChoreNotFoundError(chore_id=chore_id)
|
||||||
|
|
||||||
|
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
|
||||||
|
raise PermissionDeniedError("You can only view history for your own personal chores.")
|
||||||
|
|
||||||
|
if chore.type == ChoreTypeEnum.group:
|
||||||
|
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
|
||||||
|
if not is_member:
|
||||||
|
raise PermissionDeniedError("You must be a member of the group to view this chore's history.")
|
||||||
|
|
||||||
|
logger.info(f"User {current_user.email} getting history for chore {chore_id}")
|
||||||
|
return await crud_history.get_chore_history(db=db, chore_id=chore_id)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/assignments/{assignment_id}/history",
|
||||||
|
response_model=PyList[ChoreAssignmentHistoryPublic],
|
||||||
|
summary="Get Chore Assignment History",
|
||||||
|
tags=["Chore Assignments", "History"]
|
||||||
|
)
|
||||||
|
async def get_chore_assignment_history(
|
||||||
|
assignment_id: int,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Retrieves the history of a specific chore assignment."""
|
||||||
|
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
|
||||||
|
if not assignment:
|
||||||
|
raise ChoreNotFoundError(assignment_id=assignment_id)
|
||||||
|
|
||||||
|
# Check permission by checking permission on the parent chore
|
||||||
|
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
|
||||||
|
if not chore:
|
||||||
|
raise ChoreNotFoundError(chore_id=assignment.chore_id) # Should not happen if assignment exists
|
||||||
|
|
||||||
|
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
|
||||||
|
raise PermissionDeniedError("You can only view history for assignments of your own personal chores.")
|
||||||
|
|
||||||
|
if chore.type == ChoreTypeEnum.group:
|
||||||
|
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
|
||||||
|
if not is_member:
|
||||||
|
raise PermissionDeniedError("You must be a member of the group to view this assignment's history.")
|
||||||
|
|
||||||
|
logger.info(f"User {current_user.email} getting history for assignment {assignment_id}")
|
||||||
|
return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id)
|
@ -8,13 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.database import get_transactional_session, get_session
|
from app.database import get_transactional_session, get_session
|
||||||
from app.auth import current_active_user
|
from app.auth import current_active_user
|
||||||
from app.models import User as UserModel, UserRoleEnum # Import model and enum
|
from app.models import User as UserModel, UserRoleEnum # Import model and enum
|
||||||
from app.schemas.group import GroupCreate, GroupPublic
|
from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest
|
||||||
from app.schemas.invite import InviteCodePublic
|
from app.schemas.invite import InviteCodePublic
|
||||||
from app.schemas.message import Message # For simple responses
|
from app.schemas.message import Message # For simple responses
|
||||||
from app.schemas.list import ListPublic, ListDetail
|
from app.schemas.list import ListPublic, ListDetail
|
||||||
|
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic
|
||||||
from app.crud import group as crud_group
|
from app.crud import group as crud_group
|
||||||
from app.crud import invite as crud_invite
|
from app.crud import invite as crud_invite
|
||||||
from app.crud import list as crud_list
|
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 (
|
from app.core.exceptions import (
|
||||||
GroupNotFoundError,
|
GroupNotFoundError,
|
||||||
GroupPermissionError,
|
GroupPermissionError,
|
||||||
@ -265,3 +268,54 @@ async def read_group_lists(
|
|||||||
group_lists = [list for list in lists if list.group_id == group_id]
|
group_lists = [list for list in lists if list.group_id == group_id]
|
||||||
|
|
||||||
return group_lists
|
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)
|
@ -332,9 +332,17 @@ class UserOperationError(HTTPException):
|
|||||||
detail=detail
|
detail=detail
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class ChoreOperationError(HTTPException):
|
||||||
|
"""Raised when a chore-related operation fails."""
|
||||||
|
def __init__(self, detail: str):
|
||||||
|
super().__init__(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=detail
|
||||||
|
)
|
||||||
|
|
||||||
class ChoreNotFoundError(HTTPException):
|
class ChoreNotFoundError(HTTPException):
|
||||||
"""Raised when a chore is not found."""
|
"""Raised when a chore or assignment is not found."""
|
||||||
def __init__(self, chore_id: int, group_id: Optional[int] = None, detail: Optional[str] = None):
|
def __init__(self, chore_id: int = None, assignment_id: int = None, group_id: Optional[int] = None, detail: Optional[str] = None):
|
||||||
if detail:
|
if detail:
|
||||||
error_detail = detail
|
error_detail = detail
|
||||||
elif group_id is not None:
|
elif group_id is not None:
|
||||||
|
@ -6,10 +6,11 @@ from typing import List, Optional
|
|||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup
|
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup, ChoreHistoryEventTypeEnum
|
||||||
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate
|
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate
|
||||||
from app.core.chore_utils import calculate_next_due_date
|
from app.core.chore_utils import calculate_next_due_date
|
||||||
from app.crud.group import get_group_by_id, is_user_member
|
from app.crud.group import get_group_by_id, is_user_member
|
||||||
|
from app.crud.history import create_chore_history_entry, create_assignment_history_entry
|
||||||
from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError
|
from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -39,7 +40,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
|
|||||||
personal_chores_query
|
personal_chores_query
|
||||||
.options(
|
.options(
|
||||||
selectinload(Chore.creator),
|
selectinload(Chore.creator),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
|
selectinload(Chore.history)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -56,7 +59,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
|
|||||||
.options(
|
.options(
|
||||||
selectinload(Chore.creator),
|
selectinload(Chore.creator),
|
||||||
selectinload(Chore.group),
|
selectinload(Chore.group),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
|
selectinload(Chore.history)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -99,6 +104,16 @@ async def create_chore(
|
|||||||
db.add(db_chore)
|
db.add(db_chore)
|
||||||
await db.flush() # Get the ID for the chore
|
await db.flush() # Get the ID for the chore
|
||||||
|
|
||||||
|
# Log history
|
||||||
|
await create_chore_history_entry(
|
||||||
|
db,
|
||||||
|
chore_id=db_chore.id,
|
||||||
|
group_id=db_chore.group_id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.CREATED,
|
||||||
|
event_data={"chore_name": db_chore.name}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load relationships for the response with eager loading
|
# Load relationships for the response with eager loading
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@ -107,7 +122,9 @@ async def create_chore(
|
|||||||
.options(
|
.options(
|
||||||
selectinload(Chore.creator),
|
selectinload(Chore.creator),
|
||||||
selectinload(Chore.group),
|
selectinload(Chore.group),
|
||||||
selectinload(Chore.assignments)
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
|
selectinload(Chore.history)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
@ -120,7 +137,13 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]:
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Chore)
|
select(Chore)
|
||||||
.where(Chore.id == chore_id)
|
.where(Chore.id == chore_id)
|
||||||
.options(selectinload(Chore.creator), selectinload(Chore.group), selectinload(Chore.assignments))
|
.options(
|
||||||
|
selectinload(Chore.creator),
|
||||||
|
selectinload(Chore.group),
|
||||||
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
|
selectinload(Chore.history)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@ -152,7 +175,9 @@ async def get_personal_chores(
|
|||||||
)
|
)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Chore.creator),
|
selectinload(Chore.creator),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
|
selectinload(Chore.history)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -175,7 +200,9 @@ async def get_chores_by_group_id(
|
|||||||
)
|
)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Chore.creator),
|
selectinload(Chore.creator),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
|
selectinload(Chore.history)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -194,6 +221,9 @@ async def update_chore(
|
|||||||
if not db_chore:
|
if not db_chore:
|
||||||
raise ChoreNotFoundError(chore_id, group_id)
|
raise ChoreNotFoundError(chore_id, group_id)
|
||||||
|
|
||||||
|
# Store original state for history
|
||||||
|
original_data = {field: getattr(db_chore, field) for field in chore_in.model_dump(exclude_unset=True)}
|
||||||
|
|
||||||
# Check permissions
|
# Check permissions
|
||||||
if db_chore.type == ChoreTypeEnum.group:
|
if db_chore.type == ChoreTypeEnum.group:
|
||||||
if not group_id:
|
if not group_id:
|
||||||
@ -245,6 +275,23 @@ async def update_chore(
|
|||||||
if db_chore.frequency == ChoreFrequencyEnum.custom and db_chore.custom_interval_days is None:
|
if db_chore.frequency == ChoreFrequencyEnum.custom and db_chore.custom_interval_days is None:
|
||||||
raise ValueError("custom_interval_days must be set for custom frequency chores.")
|
raise ValueError("custom_interval_days must be set for custom frequency chores.")
|
||||||
|
|
||||||
|
# Log history for changes
|
||||||
|
changes = {}
|
||||||
|
for field, old_value in original_data.items():
|
||||||
|
new_value = getattr(db_chore, field)
|
||||||
|
if old_value != new_value:
|
||||||
|
changes[field] = {"old": str(old_value), "new": str(new_value)}
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
await create_chore_history_entry(
|
||||||
|
db,
|
||||||
|
chore_id=chore_id,
|
||||||
|
group_id=db_chore.group_id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.UPDATED,
|
||||||
|
event_data=changes
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await db.flush() # Flush changes within the transaction
|
await db.flush() # Flush changes within the transaction
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@ -253,7 +300,9 @@ async def update_chore(
|
|||||||
.options(
|
.options(
|
||||||
selectinload(Chore.creator),
|
selectinload(Chore.creator),
|
||||||
selectinload(Chore.group),
|
selectinload(Chore.group),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user)
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
|
selectinload(Chore.history)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
@ -273,6 +322,16 @@ async def delete_chore(
|
|||||||
if not db_chore:
|
if not db_chore:
|
||||||
raise ChoreNotFoundError(chore_id, group_id)
|
raise ChoreNotFoundError(chore_id, group_id)
|
||||||
|
|
||||||
|
# Log history before deleting
|
||||||
|
await create_chore_history_entry(
|
||||||
|
db,
|
||||||
|
chore_id=chore_id,
|
||||||
|
group_id=db_chore.group_id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.DELETED,
|
||||||
|
event_data={"chore_name": db_chore.name}
|
||||||
|
)
|
||||||
|
|
||||||
# Check permissions
|
# Check permissions
|
||||||
if db_chore.type == ChoreTypeEnum.group:
|
if db_chore.type == ChoreTypeEnum.group:
|
||||||
if not group_id:
|
if not group_id:
|
||||||
@ -324,6 +383,15 @@ async def create_chore_assignment(
|
|||||||
db.add(db_assignment)
|
db.add(db_assignment)
|
||||||
await db.flush() # Get the ID for the assignment
|
await db.flush() # Get the ID for the assignment
|
||||||
|
|
||||||
|
# Log history
|
||||||
|
await create_assignment_history_entry(
|
||||||
|
db,
|
||||||
|
assignment_id=db_assignment.id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.ASSIGNED,
|
||||||
|
event_data={"assigned_to_user_id": db_assignment.assigned_to_user_id, "due_date": db_assignment.due_date.isoformat()}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load relationships for the response
|
# Load relationships for the response
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@ -331,7 +399,8 @@ async def create_chore_assignment(
|
|||||||
.where(ChoreAssignment.id == db_assignment.id)
|
.where(ChoreAssignment.id == db_assignment.id)
|
||||||
.options(
|
.options(
|
||||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||||
selectinload(ChoreAssignment.assigned_user)
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(ChoreAssignment.history)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
@ -346,7 +415,8 @@ async def get_chore_assignment_by_id(db: AsyncSession, assignment_id: int) -> Op
|
|||||||
.where(ChoreAssignment.id == assignment_id)
|
.where(ChoreAssignment.id == assignment_id)
|
||||||
.options(
|
.options(
|
||||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||||
selectinload(ChoreAssignment.assigned_user)
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(ChoreAssignment.history)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
@ -364,7 +434,8 @@ async def get_user_assignments(
|
|||||||
|
|
||||||
query = query.options(
|
query = query.options(
|
||||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||||
selectinload(ChoreAssignment.assigned_user)
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(ChoreAssignment.history)
|
||||||
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
@ -393,7 +464,8 @@ async def get_chore_assignments(
|
|||||||
.where(ChoreAssignment.chore_id == chore_id)
|
.where(ChoreAssignment.chore_id == chore_id)
|
||||||
.options(
|
.options(
|
||||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||||
selectinload(ChoreAssignment.assigned_user)
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(ChoreAssignment.history)
|
||||||
)
|
)
|
||||||
.order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
.order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
||||||
)
|
)
|
||||||
@ -411,7 +483,6 @@ async def update_chore_assignment(
|
|||||||
if not db_assignment:
|
if not db_assignment:
|
||||||
raise ChoreNotFoundError(assignment_id=assignment_id)
|
raise ChoreNotFoundError(assignment_id=assignment_id)
|
||||||
|
|
||||||
# Load the chore for permission checking
|
|
||||||
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
||||||
if not chore:
|
if not chore:
|
||||||
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
|
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
|
||||||
@ -427,19 +498,27 @@ async def update_chore_assignment(
|
|||||||
|
|
||||||
update_data = assignment_in.model_dump(exclude_unset=True)
|
update_data = assignment_in.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
original_assignee = db_assignment.assigned_to_user_id
|
||||||
|
original_due_date = db_assignment.due_date
|
||||||
|
|
||||||
# Check specific permissions for different updates
|
# Check specific permissions for different updates
|
||||||
if 'is_complete' in update_data and not can_complete:
|
if 'is_complete' in update_data and not can_complete:
|
||||||
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
|
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
|
||||||
|
|
||||||
if 'due_date' in update_data and not can_manage:
|
if 'due_date' in update_data and update_data['due_date'] != original_due_date:
|
||||||
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments")
|
if not can_manage:
|
||||||
|
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments")
|
||||||
|
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.DUE_DATE_CHANGED, event_data={"old": original_due_date.isoformat(), "new": update_data['due_date'].isoformat()})
|
||||||
|
|
||||||
|
if 'assigned_to_user_id' in update_data and update_data['assigned_to_user_id'] != original_assignee:
|
||||||
|
if not can_manage:
|
||||||
|
raise PermissionDeniedError(detail="Only chore managers can reassign assignments")
|
||||||
|
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REASSIGNED, event_data={"old": original_assignee, "new": update_data['assigned_to_user_id']})
|
||||||
|
|
||||||
# Handle completion logic
|
# Handle completion logic
|
||||||
if 'is_complete' in update_data and update_data['is_complete']:
|
if 'is_complete' in update_data:
|
||||||
if not db_assignment.is_complete: # Only if not already complete
|
if update_data['is_complete'] and not db_assignment.is_complete:
|
||||||
update_data['completed_at'] = datetime.utcnow()
|
update_data['completed_at'] = datetime.utcnow()
|
||||||
|
|
||||||
# Update parent chore's last_completed_at and recalculate next_due_date
|
|
||||||
chore.last_completed_at = update_data['completed_at']
|
chore.last_completed_at = update_data['completed_at']
|
||||||
chore.next_due_date = calculate_next_due_date(
|
chore.next_due_date = calculate_next_due_date(
|
||||||
current_due_date=chore.next_due_date,
|
current_due_date=chore.next_due_date,
|
||||||
@ -447,24 +526,25 @@ async def update_chore_assignment(
|
|||||||
custom_interval_days=chore.custom_interval_days,
|
custom_interval_days=chore.custom_interval_days,
|
||||||
last_completed_date=chore.last_completed_at
|
last_completed_date=chore.last_completed_at
|
||||||
)
|
)
|
||||||
elif 'is_complete' in update_data and not update_data['is_complete']:
|
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.COMPLETED)
|
||||||
# If marking as incomplete, clear completed_at
|
elif not update_data['is_complete'] and db_assignment.is_complete:
|
||||||
update_data['completed_at'] = None
|
update_data['completed_at'] = None
|
||||||
|
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REOPENED)
|
||||||
|
|
||||||
# Apply updates
|
# Apply updates
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(db_assignment, field, value)
|
setattr(db_assignment, field, value)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await db.flush() # Flush changes within the transaction
|
await db.flush()
|
||||||
|
|
||||||
# Load relationships for the response
|
# Load relationships for the response
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ChoreAssignment)
|
select(ChoreAssignment)
|
||||||
.where(ChoreAssignment.id == db_assignment.id)
|
.where(ChoreAssignment.id == db_assignment.id)
|
||||||
.options(
|
.options(
|
||||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||||
selectinload(ChoreAssignment.assigned_user)
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(ChoreAssignment.history)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
@ -483,6 +563,15 @@ async def delete_chore_assignment(
|
|||||||
if not db_assignment:
|
if not db_assignment:
|
||||||
raise ChoreNotFoundError(assignment_id=assignment_id)
|
raise ChoreNotFoundError(assignment_id=assignment_id)
|
||||||
|
|
||||||
|
# Log history before deleting
|
||||||
|
await create_assignment_history_entry(
|
||||||
|
db,
|
||||||
|
assignment_id=assignment_id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.UNASSIGNED,
|
||||||
|
event_data={"unassigned_user_id": db_assignment.assigned_to_user_id}
|
||||||
|
)
|
||||||
|
|
||||||
# Load the chore for permission checking
|
# Load the chore for permission checking
|
||||||
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
||||||
if not chore:
|
if not chore:
|
||||||
|
@ -79,7 +79,8 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
|
|||||||
.options(
|
.options(
|
||||||
selectinload(GroupModel.member_associations).options(
|
selectinload(GroupModel.member_associations).options(
|
||||||
selectinload(UserGroupModel.user)
|
selectinload(UserGroupModel.user)
|
||||||
)
|
),
|
||||||
|
selectinload(GroupModel.chore_history) # Eager load chore history
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
@ -95,7 +96,8 @@ async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupMode
|
|||||||
select(GroupModel)
|
select(GroupModel)
|
||||||
.where(GroupModel.id == group_id)
|
.where(GroupModel.id == group_id)
|
||||||
.options(
|
.options(
|
||||||
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user)
|
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user),
|
||||||
|
selectinload(GroupModel.chore_history) # Eager load chore history
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
83
be/app/crud/history.py
Normal file
83
be/app/crud/history.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# be/app/crud/history.py
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from typing import List, Optional, Any, Dict
|
||||||
|
|
||||||
|
from app.models import ChoreHistory, ChoreAssignmentHistory, ChoreHistoryEventTypeEnum, User, Chore, Group
|
||||||
|
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentHistoryPublic
|
||||||
|
|
||||||
|
async def create_chore_history_entry(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chore_id: Optional[int],
|
||||||
|
group_id: Optional[int],
|
||||||
|
changed_by_user_id: Optional[int],
|
||||||
|
event_type: ChoreHistoryEventTypeEnum,
|
||||||
|
event_data: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> ChoreHistory:
|
||||||
|
"""Logs an event in the chore history."""
|
||||||
|
history_entry = ChoreHistory(
|
||||||
|
chore_id=chore_id,
|
||||||
|
group_id=group_id,
|
||||||
|
changed_by_user_id=changed_by_user_id,
|
||||||
|
event_type=event_type,
|
||||||
|
event_data=event_data or {},
|
||||||
|
)
|
||||||
|
db.add(history_entry)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(history_entry)
|
||||||
|
return history_entry
|
||||||
|
|
||||||
|
async def create_assignment_history_entry(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
assignment_id: int,
|
||||||
|
changed_by_user_id: int,
|
||||||
|
event_type: ChoreHistoryEventTypeEnum,
|
||||||
|
event_data: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> ChoreAssignmentHistory:
|
||||||
|
"""Logs an event in the chore assignment history."""
|
||||||
|
history_entry = ChoreAssignmentHistory(
|
||||||
|
assignment_id=assignment_id,
|
||||||
|
changed_by_user_id=changed_by_user_id,
|
||||||
|
event_type=event_type,
|
||||||
|
event_data=event_data or {},
|
||||||
|
)
|
||||||
|
db.add(history_entry)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(history_entry)
|
||||||
|
return history_entry
|
||||||
|
|
||||||
|
async def get_chore_history(db: AsyncSession, chore_id: int) -> List[ChoreHistory]:
|
||||||
|
"""Gets all history for a specific chore."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ChoreHistory)
|
||||||
|
.where(ChoreHistory.chore_id == chore_id)
|
||||||
|
.options(selectinload(ChoreHistory.changed_by_user))
|
||||||
|
.order_by(ChoreHistory.timestamp.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def get_assignment_history(db: AsyncSession, assignment_id: int) -> List[ChoreAssignmentHistory]:
|
||||||
|
"""Gets all history for a specific assignment."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ChoreAssignmentHistory)
|
||||||
|
.where(ChoreAssignmentHistory.assignment_id == assignment_id)
|
||||||
|
.options(selectinload(ChoreAssignmentHistory.changed_by_user))
|
||||||
|
.order_by(ChoreAssignmentHistory.timestamp.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def get_group_chore_history(db: AsyncSession, group_id: int) -> List[ChoreHistory]:
|
||||||
|
"""Gets all chore-related history for a group, including chore-specific and group-level events."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ChoreHistory)
|
||||||
|
.where(ChoreHistory.group_id == group_id)
|
||||||
|
.options(
|
||||||
|
selectinload(ChoreHistory.changed_by_user),
|
||||||
|
selectinload(ChoreHistory.chore) # Also load chore info if available
|
||||||
|
)
|
||||||
|
.order_by(ChoreHistory.timestamp.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
120
be/app/crud/schedule.py
Normal file
120
be/app/crud/schedule.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# be/app/crud/schedule.py
|
||||||
|
import logging
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import List
|
||||||
|
from itertools import cycle
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from app.models import Chore, Group, User, ChoreAssignment, UserGroup, ChoreTypeEnum, ChoreHistoryEventTypeEnum
|
||||||
|
from app.crud.group import get_group_by_id
|
||||||
|
from app.crud.history import create_chore_history_entry
|
||||||
|
from app.core.exceptions import GroupNotFoundError, ChoreOperationError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def generate_group_chore_schedule(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
group_id: int,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
user_id: int, # The user initiating the action
|
||||||
|
member_ids: List[int] = None
|
||||||
|
) -> List[ChoreAssignment]:
|
||||||
|
"""
|
||||||
|
Generates a round-robin chore schedule for all group chores within a date range.
|
||||||
|
"""
|
||||||
|
if start_date > end_date:
|
||||||
|
raise ChoreOperationError("Start date cannot be after end date.")
|
||||||
|
|
||||||
|
group = await get_group_by_id(db, group_id)
|
||||||
|
if not group:
|
||||||
|
raise GroupNotFoundError(group_id)
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
|
# If no members are specified, use all members from the group
|
||||||
|
members_result = await db.execute(
|
||||||
|
select(UserGroup.user_id).where(UserGroup.group_id == group_id)
|
||||||
|
)
|
||||||
|
member_ids = members_result.scalars().all()
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
|
raise ChoreOperationError("Cannot generate schedule with no members.")
|
||||||
|
|
||||||
|
# Fetch all chores belonging to this group
|
||||||
|
chores_result = await db.execute(
|
||||||
|
select(Chore).where(Chore.group_id == group_id, Chore.type == ChoreTypeEnum.group)
|
||||||
|
)
|
||||||
|
group_chores = chores_result.scalars().all()
|
||||||
|
if not group_chores:
|
||||||
|
logger.info(f"No chores found in group {group_id} to generate a schedule for.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
member_cycle = cycle(member_ids)
|
||||||
|
new_assignments = []
|
||||||
|
|
||||||
|
current_date = start_date
|
||||||
|
while current_date <= end_date:
|
||||||
|
for chore in group_chores:
|
||||||
|
# Check if a chore is due on the current day based on its frequency
|
||||||
|
# This is a simplified check. A more robust system would use the chore's next_due_date
|
||||||
|
# and frequency to see if it falls on the current_date.
|
||||||
|
# For this implementation, we assume we generate assignments for ALL chores on ALL days
|
||||||
|
# in the range, which might not be desired.
|
||||||
|
# A better approach is needed here. Let's assume for now we just create assignments for each chore
|
||||||
|
# on its *next* due date if it falls within the range.
|
||||||
|
|
||||||
|
if start_date <= chore.next_due_date <= end_date:
|
||||||
|
# Check if an assignment for this chore on this due date already exists
|
||||||
|
existing_assignment_result = await db.execute(
|
||||||
|
select(ChoreAssignment.id)
|
||||||
|
.where(ChoreAssignment.chore_id == chore.id, ChoreAssignment.due_date == chore.next_due_date)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if existing_assignment_result.scalar_one_or_none():
|
||||||
|
logger.info(f"Skipping assignment for chore '{chore.name}' on {chore.next_due_date} as it already exists.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
assigned_to_user_id = next(member_cycle)
|
||||||
|
|
||||||
|
assignment = ChoreAssignment(
|
||||||
|
chore_id=chore.id,
|
||||||
|
assigned_to_user_id=assigned_to_user_id,
|
||||||
|
due_date=chore.next_due_date, # Assign on the chore's own next_due_date
|
||||||
|
is_complete=False
|
||||||
|
)
|
||||||
|
db.add(assignment)
|
||||||
|
new_assignments.append(assignment)
|
||||||
|
logger.info(f"Created assignment for chore '{chore.name}' to user {assigned_to_user_id} on {chore.next_due_date}")
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
if not new_assignments:
|
||||||
|
logger.info(f"No new assignments were generated for group {group_id} in the specified date range.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Log a single group-level event for the schedule generation
|
||||||
|
await create_chore_history_entry(
|
||||||
|
db,
|
||||||
|
chore_id=None, # This is a group-level event
|
||||||
|
group_id=group_id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.SCHEDULE_GENERATED,
|
||||||
|
event_data={
|
||||||
|
"start_date": start_date.isoformat(),
|
||||||
|
"end_date": end_date.isoformat(),
|
||||||
|
"member_ids": member_ids,
|
||||||
|
"assignments_created": len(new_assignments)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Refresh assignments to load relationships if needed, although not strictly necessary
|
||||||
|
# as the objects are already in the session.
|
||||||
|
for assign in new_assignments:
|
||||||
|
await db.refresh(assign)
|
||||||
|
|
||||||
|
return new_assignments
|
@ -24,6 +24,7 @@ from sqlalchemy import (
|
|||||||
Date # Added Date for Chore model
|
Date # Added Date for Chore model
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship, backref
|
from sqlalchemy.orm import relationship, backref
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
from .database import Base
|
from .database import Base
|
||||||
|
|
||||||
@ -71,6 +72,20 @@ class ChoreTypeEnum(enum.Enum):
|
|||||||
personal = "personal"
|
personal = "personal"
|
||||||
group = "group"
|
group = "group"
|
||||||
|
|
||||||
|
class ChoreHistoryEventTypeEnum(str, enum.Enum):
|
||||||
|
CREATED = "created"
|
||||||
|
UPDATED = "updated"
|
||||||
|
DELETED = "deleted"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
REOPENED = "reopened"
|
||||||
|
ASSIGNED = "assigned"
|
||||||
|
UNASSIGNED = "unassigned"
|
||||||
|
REASSIGNED = "reassigned"
|
||||||
|
SCHEDULE_GENERATED = "schedule_generated"
|
||||||
|
# Add more specific events as needed
|
||||||
|
DUE_DATE_CHANGED = "due_date_changed"
|
||||||
|
DETAILS_CHANGED = "details_changed"
|
||||||
|
|
||||||
# --- User Model ---
|
# --- User Model ---
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
@ -109,6 +124,11 @@ class User(Base):
|
|||||||
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
|
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
|
||||||
# --- End Relationships for Chores ---
|
# --- End Relationships for Chores ---
|
||||||
|
|
||||||
|
# --- History Relationships ---
|
||||||
|
chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
|
||||||
|
assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
|
||||||
|
# --- End History Relationships ---
|
||||||
|
|
||||||
|
|
||||||
# --- Group Model ---
|
# --- Group Model ---
|
||||||
class Group(Base):
|
class Group(Base):
|
||||||
@ -137,6 +157,10 @@ class Group(Base):
|
|||||||
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan")
|
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan")
|
||||||
# --- End Relationship for Chores ---
|
# --- End Relationship for Chores ---
|
||||||
|
|
||||||
|
# --- History Relationships ---
|
||||||
|
chore_history = relationship("ChoreHistory", back_populates="group", cascade="all, delete-orphan")
|
||||||
|
# --- End History Relationships ---
|
||||||
|
|
||||||
|
|
||||||
# --- UserGroup Association Model ---
|
# --- UserGroup Association Model ---
|
||||||
class UserGroup(Base):
|
class UserGroup(Base):
|
||||||
@ -383,6 +407,7 @@ class Chore(Base):
|
|||||||
group = relationship("Group", back_populates="chores")
|
group = relationship("Group", back_populates="chores")
|
||||||
creator = relationship("User", back_populates="created_chores")
|
creator = relationship("User", back_populates="created_chores")
|
||||||
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
|
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
|
||||||
|
history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
# --- ChoreAssignment Model ---
|
# --- ChoreAssignment Model ---
|
||||||
@ -403,6 +428,7 @@ class ChoreAssignment(Base):
|
|||||||
# --- Relationships ---
|
# --- Relationships ---
|
||||||
chore = relationship("Chore", back_populates="assignments")
|
chore = relationship("Chore", back_populates="assignments")
|
||||||
assigned_user = relationship("User", back_populates="assigned_chores")
|
assigned_user = relationship("User", back_populates="assigned_chores")
|
||||||
|
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
# === NEW: RecurrencePattern Model ===
|
# === NEW: RecurrencePattern Model ===
|
||||||
@ -430,3 +456,35 @@ class RecurrencePattern(Base):
|
|||||||
|
|
||||||
|
|
||||||
# === END: RecurrencePattern Model ===
|
# === END: RecurrencePattern Model ===
|
||||||
|
|
||||||
|
# === NEW: Chore History Models ===
|
||||||
|
|
||||||
|
class ChoreHistory(Base):
|
||||||
|
__tablename__ = "chore_history"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
|
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True) # For group-level events
|
||||||
|
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False)
|
||||||
|
event_data = Column(JSONB, nullable=True) # e.g., {'field': 'name', 'old': 'Old', 'new': 'New'}
|
||||||
|
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Nullable if system-generated
|
||||||
|
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
# --- Relationships ---
|
||||||
|
chore = relationship("Chore", back_populates="history")
|
||||||
|
group = relationship("Group", back_populates="chore_history")
|
||||||
|
changed_by_user = relationship("User", back_populates="chore_history_entries")
|
||||||
|
|
||||||
|
class ChoreAssignmentHistory(Base):
|
||||||
|
__tablename__ = "chore_assignment_history"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
assignment_id = Column(Integer, ForeignKey("chore_assignments.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False) # Reusing enum
|
||||||
|
event_data = Column(JSONB, nullable=True)
|
||||||
|
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
# --- Relationships ---
|
||||||
|
assignment = relationship("ChoreAssignment", back_populates="history")
|
||||||
|
changed_by_user = relationship("User", back_populates="assignment_history_entries")
|
||||||
|
@ -1,13 +1,37 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Any
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
|
|
||||||
# Assuming ChoreFrequencyEnum is imported from models
|
# Assuming ChoreFrequencyEnum is imported from models
|
||||||
# Adjust the import path if necessary based on your project structure.
|
# Adjust the import path if necessary based on your project structure.
|
||||||
# e.g., from app.models import ChoreFrequencyEnum
|
# e.g., from app.models import ChoreFrequencyEnum
|
||||||
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel # For UserPublic relation
|
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel, ChoreHistoryEventTypeEnum # For UserPublic relation
|
||||||
from .user import UserPublic # For embedding user information
|
from .user import UserPublic # For embedding user information
|
||||||
|
|
||||||
|
# Forward declaration for circular dependencies
|
||||||
|
class ChoreAssignmentPublic(BaseModel):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# History Schemas
|
||||||
|
class ChoreHistoryPublic(BaseModel):
|
||||||
|
id: int
|
||||||
|
event_type: ChoreHistoryEventTypeEnum
|
||||||
|
event_data: Optional[dict[str, Any]] = None
|
||||||
|
changed_by_user: Optional[UserPublic] = None
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
class ChoreAssignmentHistoryPublic(BaseModel):
|
||||||
|
id: int
|
||||||
|
event_type: ChoreHistoryEventTypeEnum
|
||||||
|
event_data: Optional[dict[str, Any]] = None
|
||||||
|
changed_by_user: Optional[UserPublic] = None
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
# Chore Schemas
|
# Chore Schemas
|
||||||
class ChoreBase(BaseModel):
|
class ChoreBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -75,7 +99,8 @@ class ChorePublic(ChoreBase):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
creator: Optional[UserPublic] = None # Embed creator UserPublic schema
|
creator: Optional[UserPublic] = None # Embed creator UserPublic schema
|
||||||
# group: Optional[GroupPublic] = None # Embed GroupPublic schema if needed
|
assignments: List[ChoreAssignmentPublic] = []
|
||||||
|
history: List[ChoreHistoryPublic] = []
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@ -92,6 +117,7 @@ class ChoreAssignmentUpdate(BaseModel):
|
|||||||
# Only completion status and perhaps due_date can be updated for an assignment
|
# Only completion status and perhaps due_date can be updated for an assignment
|
||||||
is_complete: Optional[bool] = None
|
is_complete: Optional[bool] = None
|
||||||
due_date: Optional[date] = None # If rescheduling an existing assignment is allowed
|
due_date: Optional[date] = None # If rescheduling an existing assignment is allowed
|
||||||
|
assigned_to_user_id: Optional[int] = None # For reassigning the chore
|
||||||
|
|
||||||
class ChoreAssignmentPublic(ChoreAssignmentBase):
|
class ChoreAssignmentPublic(ChoreAssignmentBase):
|
||||||
id: int
|
id: int
|
||||||
@ -102,10 +128,11 @@ class ChoreAssignmentPublic(ChoreAssignmentBase):
|
|||||||
# Embed ChorePublic and UserPublic for richer responses
|
# Embed ChorePublic and UserPublic for richer responses
|
||||||
chore: Optional[ChorePublic] = None
|
chore: Optional[ChorePublic] = None
|
||||||
assigned_user: Optional[UserPublic] = None
|
assigned_user: Optional[UserPublic] = None
|
||||||
|
history: List[ChoreAssignmentHistoryPublic] = []
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
# To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic
|
# To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic
|
||||||
# We can update forward refs after all models are defined.
|
# We can update forward refs after all models are defined.
|
||||||
# ChorePublic.model_rebuild() # If using Pydantic v2 and forward refs were used with strings
|
ChorePublic.model_rebuild()
|
||||||
# ChoreAssignmentPublic.model_rebuild()
|
ChoreAssignmentPublic.model_rebuild()
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
# app/schemas/group.py
|
# app/schemas/group.py
|
||||||
from pydantic import BaseModel, ConfigDict, computed_field
|
from pydantic import BaseModel, ConfigDict, computed_field
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from .user import UserPublic # Import UserPublic to represent members
|
from .user import UserPublic # Import UserPublic to represent members
|
||||||
|
from .chore import ChoreHistoryPublic # Import for history
|
||||||
|
|
||||||
# Properties to receive via API on creation
|
# Properties to receive via API on creation
|
||||||
class GroupCreate(BaseModel):
|
class GroupCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
# New schema for generating a schedule
|
||||||
|
class GroupScheduleGenerateRequest(BaseModel):
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
member_ids: Optional[List[int]] = None # Optional: if not provided, use all members
|
||||||
|
|
||||||
# Properties to return to client
|
# Properties to return to client
|
||||||
class GroupPublic(BaseModel):
|
class GroupPublic(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@ -16,6 +23,7 @@ class GroupPublic(BaseModel):
|
|||||||
created_by_id: int
|
created_by_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
member_associations: Optional[List["UserGroupPublic"]] = None
|
member_associations: Optional[List["UserGroupPublic"]] = None
|
||||||
|
chore_history: Optional[List[ChoreHistoryPublic]] = []
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
@ -40,3 +48,6 @@ class UserGroupPublic(BaseModel):
|
|||||||
# Properties stored in DB (if needed, often GroupPublic is sufficient)
|
# Properties stored in DB (if needed, often GroupPublic is sufficient)
|
||||||
# class GroupInDB(GroupPublic):
|
# class GroupInDB(GroupPublic):
|
||||||
# pass
|
# pass
|
||||||
|
|
||||||
|
# We need to rebuild GroupPublic to resolve the forward reference to UserGroupPublic
|
||||||
|
GroupPublic.model_rebuild()
|
@ -1,5 +1,6 @@
|
|||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from './api-config';
|
import { API_BASE_URL, API_VERSION } from './api-config';
|
||||||
|
export { API_ENDPOINTS } from './api-config';
|
||||||
|
|
||||||
// Helper function to get full API URL
|
// Helper function to get full API URL
|
||||||
export const getApiUrl = (endpoint: string): string => {
|
export const getApiUrl = (endpoint: string): string => {
|
||||||
@ -14,5 +15,3 @@ export const apiClient = {
|
|||||||
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
|
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
|
||||||
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
|
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
|
||||||
};
|
};
|
||||||
|
|
||||||
export { API_ENDPOINTS };
|
|
@ -71,8 +71,8 @@ const loadChores = async () => {
|
|||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
current_assignment_id: currentAssignment?.id ?? null,
|
current_assignment_id: currentAssignment?.id ?? null,
|
||||||
is_completed: currentAssignment?.is_complete ?? c.is_completed ?? false,
|
is_completed: currentAssignment?.is_complete ?? false,
|
||||||
completed_at: currentAssignment?.completed_at ?? c.completed_at ?? null,
|
completed_at: currentAssignment?.completed_at ?? null,
|
||||||
updating: false,
|
updating: false,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -401,7 +401,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="choreForm.frequency === 'custom'" class="form-group">
|
<div v-if="choreForm.frequency === 'custom'" class="form-group">
|
||||||
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
|
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
|
||||||
}}</label>
|
}}</label>
|
||||||
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
|
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
|
||||||
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
|
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
|
||||||
</div>
|
</div>
|
||||||
@ -422,7 +422,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="choreForm.type === 'group'" class="form-group">
|
<div v-if="choreForm.type === 'group'" class="form-group">
|
||||||
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
|
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
|
||||||
}}</label>
|
}}</label>
|
||||||
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
|
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
|
||||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
@ -431,7 +431,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
|
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
|
||||||
t('choresPage.form.cancel', 'Cancel')
|
t('choresPage.form.cancel', 'Cancel')
|
||||||
}}</button>
|
}}</button>
|
||||||
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
|
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
|
||||||
t('choresPage.form.create', 'Create') }}</button>
|
t('choresPage.form.create', 'Create') }}</button>
|
||||||
</div>
|
</div>
|
||||||
@ -456,7 +456,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|||||||
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
|
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
|
||||||
<button type="button" class="btn btn-danger" @click="deleteChore">{{
|
<button type="button" class="btn btn-danger" @click="deleteChore">{{
|
||||||
t('choresPage.deleteConfirm.delete', 'Delete')
|
t('choresPage.deleteConfirm.delete', 'Delete')
|
||||||
}}</button>
|
}}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -80,15 +80,17 @@
|
|||||||
<div class="mt-4 neo-section">
|
<div class="mt-4 neo-section">
|
||||||
<div class="flex justify-between items-center w-full mb-2">
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
|
||||||
|
<VButton @click="showGenerateScheduleModal = true">{{ t('groupDetailPage.chores.generateScheduleButton') }}
|
||||||
|
</VButton>
|
||||||
</div>
|
</div>
|
||||||
<VList v-if="upcomingChores.length > 0">
|
<VList v-if="upcomingChores.length > 0">
|
||||||
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center">
|
<VListItem v-for="chore in upcomingChores" :key="chore.id" @click="openChoreDetailModal(chore)"
|
||||||
|
class="flex justify-between items-center cursor-pointer">
|
||||||
<div class="neo-chore-info">
|
<div class="neo-chore-info">
|
||||||
<span class="neo-chore-name">{{ chore.name }}</span>
|
<span class="neo-chore-name">{{ chore.name }}</span>
|
||||||
<span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{
|
<span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{
|
||||||
formatDate(chore.next_due_date)
|
formatDate(chore.next_due_date)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
|
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
|
||||||
</VListItem>
|
</VListItem>
|
||||||
@ -99,6 +101,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Activity Log Section -->
|
||||||
|
<div class="mt-4 neo-section">
|
||||||
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
|
||||||
|
<div v-if="groupHistoryLoading" class="text-center">
|
||||||
|
<VSpinner />
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="groupChoreHistory.length > 0" class="activity-log-list">
|
||||||
|
<li v-for="entry in groupChoreHistory" :key="entry.id" class="activity-log-item">
|
||||||
|
{{ formatHistoryEntry(entry) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else>{{ t('groupDetailPage.activityLog.emptyState') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Expenses Section -->
|
<!-- Expenses Section -->
|
||||||
<div class="mt-4 neo-section">
|
<div class="mt-4 neo-section">
|
||||||
<div class="flex justify-between items-center w-full mb-2">
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
@ -145,7 +161,10 @@
|
|||||||
<div class="neo-splits-list">
|
<div class="neo-splits-list">
|
||||||
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
|
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
|
||||||
<div class="split-col split-user">
|
<div class="split-col split-user">
|
||||||
<strong>{{ split.user?.name || split.user?.email || t('groupDetailPage.expenses.fallbackUserName', { userId: split.user_id }) }}</strong>
|
<strong>{{ split.user?.name || split.user?.email || t('groupDetailPage.expenses.fallbackUserName',
|
||||||
|
{
|
||||||
|
userId: split.user_id
|
||||||
|
}) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="split-col split-owes">
|
<div class="split-col split-owes">
|
||||||
{{ t('groupDetailPage.expenses.owes') }} <strong>{{
|
{{ t('groupDetailPage.expenses.owes') }} <strong>{{
|
||||||
@ -177,7 +196,9 @@
|
|||||||
{{ t('groupDetailPage.expenses.activityLabel') }} {{
|
{{ t('groupDetailPage.expenses.activityLabel') }} {{
|
||||||
formatCurrency(activity.amount_paid) }}
|
formatCurrency(activity.amount_paid) }}
|
||||||
{{
|
{{
|
||||||
t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name || t('groupDetailPage.expenses.activityByUserFallback', { userId: activity.paid_by_user_id }) }} {{ t('groupDetailPage.expenses.onDate') }} {{ new
|
t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name ||
|
||||||
|
t('groupDetailPage.expenses.activityByUserFallback', { userId: activity.paid_by_user_id }) }} {{
|
||||||
|
t('groupDetailPage.expenses.onDate') }} {{ new
|
||||||
Date(activity.paid_at).toLocaleDateString() }}
|
Date(activity.paid_at).toLocaleDateString() }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -207,7 +228,10 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<p>{{ t('groupDetailPage.settleShareModal.settleAmountFor', {
|
<p>{{ t('groupDetailPage.settleShareModal.settleAmountFor', {
|
||||||
userName: selectedSplitForSettlement?.user?.name
|
userName: selectedSplitForSettlement?.user?.name
|
||||||
|| selectedSplitForSettlement?.user?.email || t('groupDetailPage.expenses.fallbackUserName', { userId: selectedSplitForSettlement?.user_id })
|
|| selectedSplitForSettlement?.user?.email || t('groupDetailPage.expenses.fallbackUserName', {
|
||||||
|
userId:
|
||||||
|
selectedSplitForSettlement?.user_id
|
||||||
|
})
|
||||||
}) }}</p>
|
}) }}</p>
|
||||||
<VFormField :label="t('groupDetailPage.settleShareModal.amountLabel')"
|
<VFormField :label="t('groupDetailPage.settleShareModal.amountLabel')"
|
||||||
:error-message="settleAmountError || undefined">
|
:error-message="settleAmountError || undefined">
|
||||||
@ -218,17 +242,64 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
||||||
t('groupDetailPage.settleShareModal.cancelButton')
|
t('groupDetailPage.settleShareModal.cancelButton')
|
||||||
}}</VButton>
|
}}</VButton>
|
||||||
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
|
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
|
||||||
t('groupDetailPage.settleShareModal.confirmButton')
|
t('groupDetailPage.settleShareModal.confirmButton')
|
||||||
}}</VButton>
|
}}</VButton>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
|
|
||||||
|
<!-- Chore Detail Modal -->
|
||||||
|
<VModal v-model="showChoreDetailModal" :title="selectedChore?.name" size="lg">
|
||||||
|
<div v-if="selectedChore">
|
||||||
|
<!-- ... chore details ... -->
|
||||||
|
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }}</VHeading>
|
||||||
|
<div v-for="assignment in selectedChore.assignments" :key="assignment.id" class="assignment-row">
|
||||||
|
<template v-if="editingAssignment?.id === assignment.id">
|
||||||
|
<!-- Inline Editing UI -->
|
||||||
|
<VSelect v-if="group && group.members" :options="group.members.map(m => ({ value: m.id, label: m.email }))"
|
||||||
|
v-model="editingAssignment.assigned_to_user_id" />
|
||||||
|
<VInput type="date" :model-value="editingAssignment.due_date ?? ''"
|
||||||
|
@update:model-value="val => editingAssignment && (editingAssignment.due_date = val)" />
|
||||||
|
<VButton @click="saveAssignmentEdit(assignment.id)" size="sm">{{ t('shared.save') }}</VButton>
|
||||||
|
<VButton @click="cancelAssignmentEdit" variant="neutral" size="sm">{{ t('shared.cancel') }}</VButton>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>{{ assignment.assigned_user?.email }} - Due: {{ formatDate(assignment.due_date) }}</span>
|
||||||
|
<VButton v-if="!assignment.is_complete" @click="startAssignmentEdit(assignment)" size="sm"
|
||||||
|
variant="neutral">{{ t('shared.edit') }}</VButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }}</VHeading>
|
||||||
|
<!-- Chore History Display -->
|
||||||
|
<ul v-if="selectedChore.history && selectedChore.history.length > 0">
|
||||||
|
<li v-for="entry in selectedChore.history" :key="entry.id">{{ formatHistoryEntry(entry) }}</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else>{{ t('groupDetailPage.choreDetailModal.noHistory') }}</p>
|
||||||
|
</div>
|
||||||
|
</VModal>
|
||||||
|
|
||||||
|
<!-- Generate Schedule Modal -->
|
||||||
|
<VModal v-model="showGenerateScheduleModal" :title="t('groupDetailPage.generateScheduleModal.title')">
|
||||||
|
<VFormField :label="t('groupDetailPage.generateScheduleModal.startDateLabel')">
|
||||||
|
<VInput type="date" v-model="scheduleForm.start_date" />
|
||||||
|
</VFormField>
|
||||||
|
<VFormField :label="t('groupDetailPage.generateScheduleModal.endDateLabel')">
|
||||||
|
<VInput type="date" v-model="scheduleForm.end_date" />
|
||||||
|
</VFormField>
|
||||||
|
<!-- Member selection can be added here if desired -->
|
||||||
|
<template #footer>
|
||||||
|
<VButton @click="showGenerateScheduleModal = false" variant="neutral">{{ t('shared.cancel') }}</VButton>
|
||||||
|
<VButton @click="handleGenerateSchedule" :disabled="generatingSchedule">{{
|
||||||
|
t('groupDetailPage.generateScheduleModal.generateButton') }}</VButton>
|
||||||
</template>
|
</template>
|
||||||
</VModal>
|
</VModal>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed, reactive } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
// import { useRoute } from 'vue-router';
|
// import { useRoute } from 'vue-router';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
|
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
|
||||||
@ -236,7 +307,7 @@ import { useClipboard, useStorage } from '@vueuse/core';
|
|||||||
import ListsPage from './ListsPage.vue'; // Import ListsPage
|
import ListsPage from './ListsPage.vue'; // Import ListsPage
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import { choreService } from '../services/choreService'
|
import { choreService } from '../services/choreService'
|
||||||
import type { Chore, ChoreFrequency } from '../types/chore'
|
import type { Chore, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreAssignmentHistory } from '../types/chore'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import type { Expense, ExpenseSplit, SettlementActivityCreate } from '@/types/expense';
|
import type { Expense, ExpenseSplit, SettlementActivityCreate } from '@/types/expense';
|
||||||
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
|
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
|
||||||
@ -256,6 +327,7 @@ import VFormField from '@/components/valerie/VFormField.vue';
|
|||||||
import VIcon from '@/components/valerie/VIcon.vue';
|
import VIcon from '@/components/valerie/VIcon.vue';
|
||||||
import VModal from '@/components/valerie/VModal.vue';
|
import VModal from '@/components/valerie/VModal.vue';
|
||||||
import { onClickOutside } from '@vueuse/core'
|
import { onClickOutside } from '@vueuse/core'
|
||||||
|
import { groupService } from '../services/groupService'; // New service
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@ -337,6 +409,22 @@ const settleAmount = ref<string>('');
|
|||||||
const settleAmountError = ref<string | null>(null);
|
const settleAmountError = ref<string | null>(null);
|
||||||
const isSettlementLoading = ref(false);
|
const isSettlementLoading = ref(false);
|
||||||
|
|
||||||
|
// New State
|
||||||
|
const showChoreDetailModal = ref(false);
|
||||||
|
const selectedChore = ref<Chore | null>(null);
|
||||||
|
const editingAssignment = ref<Partial<ChoreAssignment> | null>(null);
|
||||||
|
|
||||||
|
const showGenerateScheduleModal = ref(false);
|
||||||
|
const scheduleForm = reactive({
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
member_ids: []
|
||||||
|
});
|
||||||
|
const generatingSchedule = ref(false);
|
||||||
|
|
||||||
|
const groupChoreHistory = ref<ChoreHistory[]>([]);
|
||||||
|
const groupHistoryLoading = ref(false);
|
||||||
|
|
||||||
const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => {
|
const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => {
|
||||||
if (err && typeof err === 'object') {
|
if (err && typeof err === 'object') {
|
||||||
if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) {
|
if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) {
|
||||||
@ -557,7 +645,7 @@ const loadRecentExpenses = async () => {
|
|||||||
if (!groupId.value) return
|
if (!groupId.value) return
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`${API_ENDPOINTS.FINANCIALS.EXPENSES}?group_id=${groupId.value}&limit=5&detailed=true`
|
`${API_ENDPOINTS.FINANCIALS.EXPENSES}?group_id=${groupId.value || ''}&limit=5&detailed=true`
|
||||||
)
|
)
|
||||||
recentExpenses.value = response.data
|
recentExpenses.value = response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -742,10 +830,94 @@ const toggleInviteUI = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openChoreDetailModal = async (chore: Chore) => {
|
||||||
|
selectedChore.value = chore;
|
||||||
|
showChoreDetailModal.value = true;
|
||||||
|
// Optionally lazy load history if not already loaded with the chore
|
||||||
|
if (!chore.history || chore.history.length === 0) {
|
||||||
|
const history = await choreService.getChoreHistory(chore.id);
|
||||||
|
const choreInList = upcomingChores.value.find(c => c.id === chore.id);
|
||||||
|
if (choreInList) {
|
||||||
|
choreInList.history = history;
|
||||||
|
selectedChore.value = choreInList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAssignmentEdit = (assignment: ChoreAssignment) => {
|
||||||
|
editingAssignment.value = { ...assignment, due_date: format(new Date(assignment.due_date), 'yyyy-MM-dd') };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAssignmentEdit = () => {
|
||||||
|
editingAssignment.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAssignmentEdit = async (assignmentId: number) => {
|
||||||
|
if (!editingAssignment.value || !editingAssignment.value.due_date) {
|
||||||
|
notificationStore.addNotification({ message: 'Due date is required.', type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updatedAssignment = await choreService.updateAssignment(assignmentId, {
|
||||||
|
due_date: editingAssignment.value.due_date,
|
||||||
|
assigned_to_user_id: editingAssignment.value.assigned_to_user_id
|
||||||
|
});
|
||||||
|
// Update local state
|
||||||
|
loadUpcomingChores(); // Re-fetch all chores to get updates
|
||||||
|
cancelAssignmentEdit();
|
||||||
|
notificationStore.addNotification({ message: 'Assignment updated', type: 'success' });
|
||||||
|
} catch (error) {
|
||||||
|
notificationStore.addNotification({ message: 'Failed to update assignment', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateSchedule = async () => {
|
||||||
|
generatingSchedule.value = true;
|
||||||
|
try {
|
||||||
|
await groupService.generateSchedule(String(groupId.value), scheduleForm);
|
||||||
|
notificationStore.addNotification({ message: 'Schedule generated successfully', type: 'success' });
|
||||||
|
showGenerateScheduleModal.value = false;
|
||||||
|
loadUpcomingChores(); // Refresh the chore list
|
||||||
|
} catch (error) {
|
||||||
|
notificationStore.addNotification({ message: 'Failed to generate schedule', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
generatingSchedule.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadGroupChoreHistory = async () => {
|
||||||
|
if (!groupId.value) return;
|
||||||
|
groupHistoryLoading.value = true;
|
||||||
|
try {
|
||||||
|
groupChoreHistory.value = await groupService.getGroupChoreHistory(String(groupId.value));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load group chore history", err);
|
||||||
|
notificationStore.addNotification({ message: 'Could not load group activity.', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
groupHistoryLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatHistoryEntry = (entry: ChoreHistory | ChoreAssignmentHistory): string => {
|
||||||
|
const user = entry.changed_by_user?.email || 'System';
|
||||||
|
const time = new Date(entry.timestamp).toLocaleString();
|
||||||
|
let details = '';
|
||||||
|
if (entry.event_data) {
|
||||||
|
details = Object.entries(entry.event_data).map(([key, value]) => {
|
||||||
|
if (typeof value === 'object' && value !== null && 'old' in value && 'new' in value) {
|
||||||
|
return `${key} changed from '${value.old}' to '${value.new}'`;
|
||||||
|
}
|
||||||
|
return `${key}: ${JSON.stringify(value)}`;
|
||||||
|
}).join(', ');
|
||||||
|
}
|
||||||
|
return `${user} ${entry.event_type} on ${time}. Details: ${details}`;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchGroupDetails();
|
fetchGroupDetails();
|
||||||
loadUpcomingChores();
|
loadUpcomingChores();
|
||||||
loadRecentExpenses();
|
loadRecentExpenses();
|
||||||
|
loadGroupChoreHistory();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate } from '../types/chore'
|
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore'
|
||||||
import { groupService } from './groupService'
|
import { groupService } from './groupService'
|
||||||
import type { Group } from './groupService'
|
import type { Group } from './groupService'
|
||||||
|
import { apiClient, API_ENDPOINTS } from '@/config/api'
|
||||||
|
|
||||||
export const choreService = {
|
export const choreService = {
|
||||||
async getAllChores(): Promise<Chore[]> {
|
async getAllChores(): Promise<Chore[]> {
|
||||||
@ -117,7 +118,7 @@ export const choreService = {
|
|||||||
|
|
||||||
// Update assignment
|
// Update assignment
|
||||||
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> {
|
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> {
|
||||||
const response = await api.put(`/api/v1/chores/assignments/${assignmentId}`, update)
|
const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -180,4 +181,9 @@ export const choreService = {
|
|||||||
// Renamed original for safety, to be removed
|
// Renamed original for safety, to be removed
|
||||||
await api.delete(`/api/v1/chores/personal/${choreId}`)
|
await api.delete(`/api/v1/chores/personal/${choreId}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getChoreHistory(choreId: number): Promise<ChoreHistory[]> {
|
||||||
|
const response = await apiClient.get(API_ENDPOINTS.CHORES.HISTORY(choreId))
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { api } from './api'
|
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||||
|
import type { Group } from '@/types/group';
|
||||||
|
import type { ChoreHistory } from '@/types/chore';
|
||||||
|
|
||||||
// Define Group interface matching backend schema
|
// Define Group interface matching backend schema
|
||||||
export interface Group {
|
export interface Group {
|
||||||
@ -17,13 +19,17 @@ export interface Group {
|
|||||||
|
|
||||||
export const groupService = {
|
export const groupService = {
|
||||||
async getUserGroups(): Promise<Group[]> {
|
async getUserGroups(): Promise<Group[]> {
|
||||||
try {
|
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
||||||
const response = await api.get('/api/v1/groups')
|
return response.data;
|
||||||
return response.data
|
},
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch user groups:', error)
|
async generateSchedule(groupId: string, data: { start_date: string; end_date: string; member_ids: number[] }): Promise<void> {
|
||||||
throw error
|
await apiClient.post(API_ENDPOINTS.GROUPS.GENERATE_SCHEDULE(groupId), data);
|
||||||
}
|
},
|
||||||
|
|
||||||
|
async getGroupChoreHistory(groupId: string): Promise<ChoreHistory[]> {
|
||||||
|
const response = await apiClient.get(API_ENDPOINTS.GROUPS.CHORE_HISTORY(groupId));
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Add other group-related service methods here, e.g.:
|
// Add other group-related service methods here, e.g.:
|
||||||
|
@ -4,7 +4,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
|
||||||
interface AuthState {
|
export interface AuthState {
|
||||||
accessToken: string | null
|
accessToken: string | null
|
||||||
refreshToken: string | null
|
refreshToken: string | null
|
||||||
user: {
|
user: {
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import type { UserPublic } from './user'
|
import type { User } from './user'
|
||||||
|
|
||||||
export type ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom'
|
export type ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom'
|
||||||
export type ChoreType = 'personal' | 'group'
|
export type ChoreType = 'personal' | 'group'
|
||||||
|
export type ChoreHistoryEventType = 'created' | 'updated' | 'deleted' | 'completed' | 'reopened' | 'assigned' | 'unassigned' | 'reassigned' | 'schedule_generated' | 'due_date_changed' | 'details_changed'
|
||||||
|
|
||||||
export interface Chore {
|
export interface Chore {
|
||||||
id: number
|
id: number
|
||||||
@ -16,14 +17,9 @@ export interface Chore {
|
|||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
type: ChoreType
|
type: ChoreType
|
||||||
creator?: {
|
creator?: User
|
||||||
id: number
|
assignments: ChoreAssignment[]
|
||||||
name: string
|
history?: ChoreHistory[]
|
||||||
email: string
|
|
||||||
}
|
|
||||||
assignments?: ChoreAssignment[]
|
|
||||||
is_completed: boolean
|
|
||||||
completed_at: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChoreCreate extends Omit<Chore, 'id'> { }
|
export interface ChoreCreate extends Omit<Chore, 'id'> { }
|
||||||
@ -38,11 +34,12 @@ export interface ChoreAssignment {
|
|||||||
assigned_by_id: number
|
assigned_by_id: number
|
||||||
due_date: string
|
due_date: string
|
||||||
is_complete: boolean
|
is_complete: boolean
|
||||||
completed_at: string | null
|
completed_at?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
chore?: Chore
|
chore?: Chore
|
||||||
assigned_user?: UserPublic
|
assigned_user?: User
|
||||||
|
history?: ChoreAssignmentHistory[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChoreAssignmentCreate {
|
export interface ChoreAssignmentCreate {
|
||||||
@ -52,6 +49,23 @@ export interface ChoreAssignmentCreate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChoreAssignmentUpdate {
|
export interface ChoreAssignmentUpdate {
|
||||||
due_date?: string
|
|
||||||
is_complete?: boolean
|
is_complete?: boolean
|
||||||
|
due_date?: string
|
||||||
|
assigned_to_user_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoreHistory {
|
||||||
|
id: number
|
||||||
|
event_type: ChoreHistoryEventType
|
||||||
|
event_data?: Record<string, any>
|
||||||
|
changed_by_user?: User
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoreAssignmentHistory {
|
||||||
|
id: number
|
||||||
|
event_type: ChoreHistoryEventType
|
||||||
|
event_data?: Record<string, any>
|
||||||
|
changed_by_user?: User
|
||||||
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
12
fe/src/types/group.ts
Normal file
12
fe/src/types/group.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// fe/src/types/group.ts
|
||||||
|
import type { AuthState } from '@/stores/auth';
|
||||||
|
import type { ChoreHistory } from './chore';
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
created_by_id: number;
|
||||||
|
created_at: string;
|
||||||
|
members: AuthState['user'][];
|
||||||
|
chore_history?: ChoreHistory[];
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user