ph5 #60
@ -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.auth import current_active_user
|
||||
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 history as crud_history
|
||||
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -450,4 +455,66 @@ async def complete_chore_assignment(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
|
||||
except DatabaseIntegrityError as e:
|
||||
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.auth import current_active_user
|
||||
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.message import Message # For simple responses
|
||||
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 invite as crud_invite
|
||||
from app.crud import list as crud_list
|
||||
from app.crud import history as crud_history
|
||||
from app.crud import schedule as crud_schedule
|
||||
from app.core.exceptions import (
|
||||
GroupNotFoundError,
|
||||
GroupPermissionError,
|
||||
@ -264,4 +267,55 @@ async def read_group_lists(
|
||||
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id)
|
||||
group_lists = [list for list in lists if list.group_id == group_id]
|
||||
|
||||
return group_lists
|
||||
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
|
||||
)
|
||||
|
||||
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):
|
||||
"""Raised when a chore is not found."""
|
||||
def __init__(self, chore_id: int, group_id: Optional[int] = None, detail: Optional[str] = None):
|
||||
"""Raised when a chore or assignment is not found."""
|
||||
def __init__(self, chore_id: int = None, assignment_id: int = None, group_id: Optional[int] = None, detail: Optional[str] = None):
|
||||
if detail:
|
||||
error_detail = detail
|
||||
elif group_id is not None:
|
||||
|
@ -6,10 +6,11 @@ from typing import List, Optional
|
||||
import logging
|
||||
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.core.chore_utils import calculate_next_due_date
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -39,7 +40,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
|
||||
personal_chores_query
|
||||
.options(
|
||||
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)
|
||||
)
|
||||
@ -56,7 +59,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
|
||||
.options(
|
||||
selectinload(Chore.creator),
|
||||
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)
|
||||
)
|
||||
@ -99,6 +104,16 @@ async def create_chore(
|
||||
db.add(db_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:
|
||||
# Load relationships for the response with eager loading
|
||||
result = await db.execute(
|
||||
@ -107,7 +122,9 @@ async def create_chore(
|
||||
.options(
|
||||
selectinload(Chore.creator),
|
||||
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()
|
||||
@ -120,7 +137,13 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]:
|
||||
result = await db.execute(
|
||||
select(Chore)
|
||||
.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()
|
||||
|
||||
@ -152,7 +175,9 @@ async def get_personal_chores(
|
||||
)
|
||||
.options(
|
||||
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)
|
||||
)
|
||||
@ -175,7 +200,9 @@ async def get_chores_by_group_id(
|
||||
)
|
||||
.options(
|
||||
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)
|
||||
)
|
||||
@ -194,6 +221,9 @@ async def update_chore(
|
||||
if not db_chore:
|
||||
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
|
||||
if db_chore.type == ChoreTypeEnum.group:
|
||||
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:
|
||||
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:
|
||||
await db.flush() # Flush changes within the transaction
|
||||
result = await db.execute(
|
||||
@ -253,7 +300,9 @@ async def update_chore(
|
||||
.options(
|
||||
selectinload(Chore.creator),
|
||||
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()
|
||||
@ -273,6 +322,16 @@ async def delete_chore(
|
||||
if not db_chore:
|
||||
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
|
||||
if db_chore.type == ChoreTypeEnum.group:
|
||||
if not group_id:
|
||||
@ -324,6 +383,15 @@ async def create_chore_assignment(
|
||||
db.add(db_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:
|
||||
# Load relationships for the response
|
||||
result = await db.execute(
|
||||
@ -331,7 +399,8 @@ async def create_chore_assignment(
|
||||
.where(ChoreAssignment.id == db_assignment.id)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.assigned_user)
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
)
|
||||
)
|
||||
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)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.assigned_user)
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@ -364,7 +434,8 @@ async def get_user_assignments(
|
||||
|
||||
query = query.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.assigned_user)
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
||||
|
||||
result = await db.execute(query)
|
||||
@ -393,7 +464,8 @@ async def get_chore_assignments(
|
||||
.where(ChoreAssignment.chore_id == chore_id)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.assigned_user)
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
)
|
||||
.order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
||||
)
|
||||
@ -411,11 +483,10 @@ async def update_chore_assignment(
|
||||
if not db_assignment:
|
||||
raise ChoreNotFoundError(assignment_id=assignment_id)
|
||||
|
||||
# Load the chore for permission checking
|
||||
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
||||
if not chore:
|
||||
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
|
||||
|
||||
|
||||
# Check permissions - only assignee can complete, but chore managers can reschedule
|
||||
can_manage = False
|
||||
if chore.type == ChoreTypeEnum.personal:
|
||||
@ -427,19 +498,27 @@ async def update_chore_assignment(
|
||||
|
||||
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
|
||||
if 'is_complete' in update_data and not can_complete:
|
||||
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
|
||||
|
||||
if 'due_date' in update_data and not can_manage:
|
||||
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments")
|
||||
|
||||
if 'due_date' in update_data and update_data['due_date'] != original_due_date:
|
||||
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
|
||||
if 'is_complete' in update_data and update_data['is_complete']:
|
||||
if not db_assignment.is_complete: # Only if not already complete
|
||||
if 'is_complete' in update_data:
|
||||
if update_data['is_complete'] and not db_assignment.is_complete:
|
||||
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.next_due_date = calculate_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,
|
||||
last_completed_date=chore.last_completed_at
|
||||
)
|
||||
elif 'is_complete' in update_data and not update_data['is_complete']:
|
||||
# If marking as incomplete, clear completed_at
|
||||
update_data['completed_at'] = None
|
||||
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.COMPLETED)
|
||||
elif not update_data['is_complete'] and db_assignment.is_complete:
|
||||
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
|
||||
for field, value in update_data.items():
|
||||
setattr(db_assignment, field, value)
|
||||
|
||||
try:
|
||||
await db.flush() # Flush changes within the transaction
|
||||
|
||||
await db.flush()
|
||||
# Load relationships for the response
|
||||
result = await db.execute(
|
||||
select(ChoreAssignment)
|
||||
.where(ChoreAssignment.id == db_assignment.id)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.assigned_user)
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
@ -483,6 +563,15 @@ async def delete_chore_assignment(
|
||||
if not db_assignment:
|
||||
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
|
||||
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
||||
if not chore:
|
||||
|
@ -79,7 +79,8 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
|
||||
.options(
|
||||
selectinload(GroupModel.member_associations).options(
|
||||
selectinload(UserGroupModel.user)
|
||||
)
|
||||
),
|
||||
selectinload(GroupModel.chore_history) # Eager load chore history
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@ -95,7 +96,8 @@ async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupMode
|
||||
select(GroupModel)
|
||||
.where(GroupModel.id == group_id)
|
||||
.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()
|
||||
|
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
|
||||
)
|
||||
from sqlalchemy.orm import relationship, backref
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from .database import Base
|
||||
|
||||
@ -71,6 +72,20 @@ class ChoreTypeEnum(enum.Enum):
|
||||
personal = "personal"
|
||||
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 ---
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
@ -109,6 +124,11 @@ class User(Base):
|
||||
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
|
||||
# --- 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 ---
|
||||
class Group(Base):
|
||||
@ -137,6 +157,10 @@ class Group(Base):
|
||||
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan")
|
||||
# --- End Relationship for Chores ---
|
||||
|
||||
# --- History Relationships ---
|
||||
chore_history = relationship("ChoreHistory", back_populates="group", cascade="all, delete-orphan")
|
||||
# --- End History Relationships ---
|
||||
|
||||
|
||||
# --- UserGroup Association Model ---
|
||||
class UserGroup(Base):
|
||||
@ -383,6 +407,7 @@ class Chore(Base):
|
||||
group = relationship("Group", back_populates="chores")
|
||||
creator = relationship("User", back_populates="created_chores")
|
||||
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
|
||||
history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
# --- ChoreAssignment Model ---
|
||||
@ -403,6 +428,7 @@ class ChoreAssignment(Base):
|
||||
# --- Relationships ---
|
||||
chore = relationship("Chore", back_populates="assignments")
|
||||
assigned_user = relationship("User", back_populates="assigned_chores")
|
||||
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
# === NEW: RecurrencePattern Model ===
|
||||
@ -430,3 +456,35 @@ class RecurrencePattern(Base):
|
||||
|
||||
|
||||
# === 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 typing import Optional, List
|
||||
from typing import Optional, List, Any
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
# Assuming ChoreFrequencyEnum is imported from models
|
||||
# Adjust the import path if necessary based on your project structure.
|
||||
# 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
|
||||
|
||||
# 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
|
||||
class ChoreBase(BaseModel):
|
||||
name: str
|
||||
@ -75,7 +99,8 @@ class ChorePublic(ChoreBase):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
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)
|
||||
|
||||
@ -92,6 +117,7 @@ class ChoreAssignmentUpdate(BaseModel):
|
||||
# Only completion status and perhaps due_date can be updated for an assignment
|
||||
is_complete: Optional[bool] = None
|
||||
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):
|
||||
id: int
|
||||
@ -100,12 +126,13 @@ class ChoreAssignmentPublic(ChoreAssignmentBase):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# Embed ChorePublic and UserPublic for richer responses
|
||||
chore: Optional[ChorePublic] = None
|
||||
chore: Optional[ChorePublic] = None
|
||||
assigned_user: Optional[UserPublic] = None
|
||||
history: List[ChoreAssignmentHistoryPublic] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic
|
||||
# We can update forward refs after all models are defined.
|
||||
# ChorePublic.model_rebuild() # If using Pydantic v2 and forward refs were used with strings
|
||||
# ChoreAssignmentPublic.model_rebuild()
|
||||
ChorePublic.model_rebuild()
|
||||
ChoreAssignmentPublic.model_rebuild()
|
||||
|
@ -1,14 +1,21 @@
|
||||
# app/schemas/group.py
|
||||
from pydantic import BaseModel, ConfigDict, computed_field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List
|
||||
|
||||
from .user import UserPublic # Import UserPublic to represent members
|
||||
from .chore import ChoreHistoryPublic # Import for history
|
||||
|
||||
# Properties to receive via API on creation
|
||||
class GroupCreate(BaseModel):
|
||||
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
|
||||
class GroupPublic(BaseModel):
|
||||
id: int
|
||||
@ -16,6 +23,7 @@ class GroupPublic(BaseModel):
|
||||
created_by_id: int
|
||||
created_at: datetime
|
||||
member_associations: Optional[List["UserGroupPublic"]] = None
|
||||
chore_history: Optional[List[ChoreHistoryPublic]] = []
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
@ -39,4 +47,7 @@ class UserGroupPublic(BaseModel):
|
||||
|
||||
# Properties stored in DB (if needed, often GroupPublic is sufficient)
|
||||
# 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_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
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
@ -13,6 +14,4 @@ export const apiClient = {
|
||||
put: (endpoint: string, data = {}, config = {}) => api.put(getApiUrl(endpoint), data, config),
|
||||
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
|
||||
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
|
||||
};
|
||||
|
||||
export { API_ENDPOINTS };
|
||||
};
|
@ -71,8 +71,8 @@ const loadChores = async () => {
|
||||
return {
|
||||
...c,
|
||||
current_assignment_id: currentAssignment?.id ?? null,
|
||||
is_completed: currentAssignment?.is_complete ?? c.is_completed ?? false,
|
||||
completed_at: currentAssignment?.completed_at ?? c.completed_at ?? null,
|
||||
is_completed: currentAssignment?.is_complete ?? false,
|
||||
completed_at: currentAssignment?.completed_at ?? null,
|
||||
updating: false,
|
||||
}
|
||||
});
|
||||
@ -401,7 +401,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
||||
</div>
|
||||
<div v-if="choreForm.frequency === 'custom'" class="form-group">
|
||||
<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"
|
||||
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
|
||||
</div>
|
||||
@ -422,7 +422,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
||||
</div>
|
||||
<div v-if="choreForm.type === 'group'" class="form-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">
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
@ -431,7 +431,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
|
||||
t('choresPage.form.cancel', 'Cancel')
|
||||
}}</button>
|
||||
}}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
|
||||
t('choresPage.form.create', 'Create') }}</button>
|
||||
</div>
|
||||
@ -456,7 +456,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
||||
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
|
||||
<button type="button" class="btn btn-danger" @click="deleteChore">{{
|
||||
t('choresPage.deleteConfirm.delete', 'Delete')
|
||||
}}</button>
|
||||
}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -80,15 +80,17 @@
|
||||
<div class="mt-4 neo-section">
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
|
||||
|
||||
<VButton @click="showGenerateScheduleModal = true">{{ t('groupDetailPage.chores.generateScheduleButton') }}
|
||||
</VButton>
|
||||
</div>
|
||||
<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">
|
||||
<span class="neo-chore-name">{{ chore.name }}</span>
|
||||
<span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{
|
||||
formatDate(chore.next_due_date)
|
||||
}}</span>
|
||||
}}</span>
|
||||
</div>
|
||||
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
|
||||
</VListItem>
|
||||
@ -99,6 +101,20 @@
|
||||
</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 -->
|
||||
<div class="mt-4 neo-section">
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
@ -145,7 +161,10 @@
|
||||
<div class="neo-splits-list">
|
||||
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
|
||||
<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 class="split-col split-owes">
|
||||
{{ t('groupDetailPage.expenses.owes') }} <strong>{{
|
||||
@ -177,7 +196,9 @@
|
||||
{{ t('groupDetailPage.expenses.activityLabel') }} {{
|
||||
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() }}
|
||||
</li>
|
||||
</ul>
|
||||
@ -207,7 +228,10 @@
|
||||
<div v-else>
|
||||
<p>{{ t('groupDetailPage.settleShareModal.settleAmountFor', {
|
||||
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>
|
||||
<VFormField :label="t('groupDetailPage.settleShareModal.amountLabel')"
|
||||
:error-message="settleAmountError || undefined">
|
||||
@ -218,17 +242,64 @@
|
||||
<template #footer>
|
||||
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
||||
t('groupDetailPage.settleShareModal.cancelButton')
|
||||
}}</VButton>
|
||||
}}</VButton>
|
||||
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
|
||||
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>
|
||||
</VModal>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, computed, reactive } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
// import { useRoute } from 'vue-router';
|
||||
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 { useNotificationStore } from '@/stores/notifications';
|
||||
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 type { Expense, ExpenseSplit, SettlementActivityCreate } 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 VModal from '@/components/valerie/VModal.vue';
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { groupService } from '../services/groupService'; // New service
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@ -337,6 +409,22 @@ const settleAmount = ref<string>('');
|
||||
const settleAmountError = ref<string | null>(null);
|
||||
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 => {
|
||||
if (err && typeof err === 'object') {
|
||||
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
|
||||
try {
|
||||
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
|
||||
} 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(() => {
|
||||
fetchGroupDetails();
|
||||
loadUpcomingChores();
|
||||
loadRecentExpenses();
|
||||
loadGroupChoreHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
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 type { Group } from './groupService'
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api'
|
||||
|
||||
export const choreService = {
|
||||
async getAllChores(): Promise<Chore[]> {
|
||||
@ -117,7 +118,7 @@ export const choreService = {
|
||||
|
||||
// Update assignment
|
||||
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
|
||||
},
|
||||
|
||||
@ -180,4 +181,9 @@ export const choreService = {
|
||||
// Renamed original for safety, to be removed
|
||||
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
|
||||
export interface Group {
|
||||
@ -17,13 +19,17 @@ export interface Group {
|
||||
|
||||
export const groupService = {
|
||||
async getUserGroups(): Promise<Group[]> {
|
||||
try {
|
||||
const response = await api.get('/api/v1/groups')
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user groups:', error)
|
||||
throw error
|
||||
}
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async generateSchedule(groupId: string, data: { start_date: string; end_date: string; member_ids: number[] }): Promise<void> {
|
||||
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.:
|
||||
|
@ -4,7 +4,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import router from '@/router'
|
||||
|
||||
interface AuthState {
|
||||
export interface AuthState {
|
||||
accessToken: string | null
|
||||
refreshToken: string | null
|
||||
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 ChoreType = 'personal' | 'group'
|
||||
export type ChoreHistoryEventType = 'created' | 'updated' | 'deleted' | 'completed' | 'reopened' | 'assigned' | 'unassigned' | 'reassigned' | 'schedule_generated' | 'due_date_changed' | 'details_changed'
|
||||
|
||||
export interface Chore {
|
||||
id: number
|
||||
@ -16,14 +17,9 @@ export interface Chore {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
type: ChoreType
|
||||
creator?: {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
assignments?: ChoreAssignment[]
|
||||
is_completed: boolean
|
||||
completed_at: string | null
|
||||
creator?: User
|
||||
assignments: ChoreAssignment[]
|
||||
history?: ChoreHistory[]
|
||||
}
|
||||
|
||||
export interface ChoreCreate extends Omit<Chore, 'id'> { }
|
||||
@ -38,11 +34,12 @@ export interface ChoreAssignment {
|
||||
assigned_by_id: number
|
||||
due_date: string
|
||||
is_complete: boolean
|
||||
completed_at: string | null
|
||||
completed_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
chore?: Chore
|
||||
assigned_user?: UserPublic
|
||||
assigned_user?: User
|
||||
history?: ChoreAssignmentHistory[]
|
||||
}
|
||||
|
||||
export interface ChoreAssignmentCreate {
|
||||
@ -52,6 +49,23 @@ export interface ChoreAssignmentCreate {
|
||||
}
|
||||
|
||||
export interface ChoreAssignmentUpdate {
|
||||
due_date?: string
|
||||
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