mitlist/be/app/jobs/recurring_expenses.py
mohamad 0207c175ba feat: Enhance chore management with new update endpoint and structured logging
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.
2025-06-21 15:00:13 +02:00

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