
This commit introduces a new endpoint for updating chores of any type, allowing conversions between personal and group chores while enforcing permission checks. Additionally, structured logging has been implemented through a new middleware, improving request tracing and logging details for better monitoring and debugging. These changes aim to enhance the functionality and maintainability of the chore management system.
186 lines
7.8 KiB
Python
186 lines
7.8 KiB
Python
from datetime import datetime, timedelta
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, and_
|
|
from sqlalchemy.orm import selectinload
|
|
from app.models import Expense, RecurrencePattern, SplitTypeEnum
|
|
from app.crud.expense import create_expense
|
|
from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate
|
|
import logging
|
|
from typing import Optional
|
|
import enum
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
async def generate_recurring_expenses(db: AsyncSession) -> None:
|
|
"""
|
|
Background job to generate recurring expenses.
|
|
Should be run daily to check for and create new recurring expenses.
|
|
"""
|
|
try:
|
|
now = datetime.utcnow()
|
|
query = select(Expense).join(RecurrencePattern).where(
|
|
and_(
|
|
Expense.is_recurring == True,
|
|
Expense.next_occurrence <= now,
|
|
(
|
|
(RecurrencePattern.max_occurrences == None) |
|
|
(RecurrencePattern.max_occurrences > 0)
|
|
),
|
|
(
|
|
(RecurrencePattern.end_date == None) |
|
|
(RecurrencePattern.end_date > now)
|
|
)
|
|
)
|
|
).options(
|
|
selectinload(Expense.splits) # Eager load splits to use as a template
|
|
)
|
|
|
|
result = await db.execute(query)
|
|
recurring_expenses = result.scalars().all()
|
|
|
|
for expense in recurring_expenses:
|
|
try:
|
|
await _generate_next_occurrence(db, expense)
|
|
except Exception as e:
|
|
logger.error(f"Error generating next occurrence for expense {expense.id}: {str(e)}", exc_info=True)
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in generate_recurring_expenses job during expense fetch: {str(e)}", exc_info=True)
|
|
# Do not re-raise, allow the job scheduler to run again later
|
|
|
|
async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
|
|
"""Generate the next occurrence of a recurring expense."""
|
|
pattern = expense.recurrence_pattern
|
|
if not pattern:
|
|
logger.warning(f"Recurring expense {expense.id} is missing its recurrence pattern.")
|
|
return
|
|
|
|
next_date = _calculate_next_occurrence(expense.next_occurrence, pattern)
|
|
if not next_date:
|
|
logger.info(f"No next occurrence date for expense {expense.id}, stopping recurrence.")
|
|
expense.is_recurring = False # Stop future processing
|
|
await db.flush()
|
|
return
|
|
|
|
# Recreate splits from the template expense if needed
|
|
splits_data = None
|
|
if expense.split_type not in [SplitTypeEnum.EQUAL, SplitTypeEnum.ITEM_BASED]:
|
|
if not expense.splits:
|
|
logger.error(f"Cannot generate next occurrence for expense {expense.id} with split type {expense.split_type.value} because it has no splits to use as a template.")
|
|
return
|
|
|
|
splits_data = [
|
|
ExpenseSplitCreate(
|
|
user_id=split.user_id,
|
|
owed_amount=split.owed_amount,
|
|
share_percentage=split.share_percentage,
|
|
share_units=split.share_units,
|
|
) for split in expense.splits
|
|
]
|
|
|
|
new_expense = ExpenseCreate(
|
|
description=expense.description,
|
|
total_amount=expense.total_amount,
|
|
currency=expense.currency,
|
|
expense_date=next_date,
|
|
split_type=expense.split_type,
|
|
list_id=expense.list_id,
|
|
group_id=expense.group_id,
|
|
item_id=expense.item_id,
|
|
paid_by_user_id=expense.paid_by_user_id,
|
|
is_recurring=False, # The new expense is a single occurrence, not a recurring template
|
|
splits_in=splits_data
|
|
)
|
|
|
|
# We pass the original creator's ID
|
|
created_expense = await create_expense(db, new_expense, expense.created_by_user_id)
|
|
logger.info(f"Generated new expense {created_expense.id} from recurring expense {expense.id}.")
|
|
|
|
# Update the template expense for the next run
|
|
expense.last_occurrence = next_date
|
|
next_next_date = _calculate_next_occurrence(next_date, pattern)
|
|
|
|
# Decrement occurrence count if it exists
|
|
if pattern.max_occurrences is not None:
|
|
pattern.max_occurrences -= 1
|
|
if pattern.max_occurrences <= 0:
|
|
next_next_date = None # Stop recurrence
|
|
|
|
expense.next_occurrence = next_next_date
|
|
if not expense.next_occurrence:
|
|
expense.is_recurring = False # End the recurrence
|
|
|
|
await db.flush()
|
|
|
|
def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePattern) -> Optional[datetime]:
|
|
"""Calculate the next occurrence date based on the recurrence pattern provided."""
|
|
|
|
if not current_date:
|
|
return None
|
|
|
|
# Extract a lowercase string of the recurrence type regardless of whether it is an Enum member or a str.
|
|
if isinstance(pattern.type, enum.Enum):
|
|
pattern_type = pattern.type.value.lower()
|
|
else:
|
|
pattern_type = str(pattern.type).lower()
|
|
|
|
next_date: Optional[datetime] = None
|
|
|
|
if pattern_type == 'daily':
|
|
next_date = current_date + timedelta(days=pattern.interval)
|
|
|
|
elif pattern_type == 'weekly':
|
|
if not pattern.days_of_week:
|
|
next_date = current_date + timedelta(weeks=pattern.interval)
|
|
else:
|
|
current_weekday = current_date.weekday()
|
|
# ``days_of_week`` can be stored either as a list[int] (Python-side) or as a
|
|
# comma-separated string in the database. We normalise it to a list[int].
|
|
days_of_week_iterable = []
|
|
if pattern.days_of_week is None:
|
|
days_of_week_iterable = []
|
|
elif isinstance(pattern.days_of_week, (list, tuple)):
|
|
days_of_week_iterable = list(pattern.days_of_week)
|
|
else:
|
|
# Assume comma-separated string like "1,3,5"
|
|
try:
|
|
days_of_week_iterable = [int(d.strip()) for d in str(pattern.days_of_week).split(',') if d.strip().isdigit()]
|
|
except Exception:
|
|
days_of_week_iterable = []
|
|
|
|
# Find the next valid weekday after the current one
|
|
next_days = sorted([d for d in days_of_week_iterable if d > current_weekday])
|
|
if next_days:
|
|
days_ahead = next_days[0] - current_weekday
|
|
next_date = current_date + timedelta(days=days_ahead)
|
|
else:
|
|
# Jump to the first valid day in a future week respecting the interval
|
|
if days_of_week_iterable:
|
|
days_ahead = (7 - current_weekday) + min(days_of_week_iterable)
|
|
next_date = current_date + timedelta(days=days_ahead)
|
|
if pattern.interval > 1:
|
|
next_date += timedelta(weeks=pattern.interval - 1)
|
|
|
|
elif pattern_type == 'monthly':
|
|
# Move `interval` months forward while keeping the day component stable where possible.
|
|
year = current_date.year + (current_date.month + pattern.interval - 1) // 12
|
|
month = (current_date.month + pattern.interval - 1) % 12 + 1
|
|
try:
|
|
next_date = current_date.replace(year=year, month=month)
|
|
except ValueError:
|
|
# Handle cases like Feb-31st by rolling back to the last valid day of the new month.
|
|
next_date = (current_date.replace(day=1, year=year, month=month) + timedelta(days=31)).replace(day=1) - timedelta(days=1)
|
|
|
|
elif pattern_type == 'yearly':
|
|
try:
|
|
next_date = current_date.replace(year=current_date.year + pattern.interval)
|
|
except ValueError:
|
|
# Leap-year edge-case; fallback to Feb-28 if Feb-29 does not exist in the target year.
|
|
next_date = current_date.replace(year=current_date.year + pattern.interval, day=28)
|
|
|
|
# Stop recurrence if beyond end_date
|
|
if pattern.end_date and next_date and next_date > pattern.end_date:
|
|
return None
|
|
|
|
return next_date |