Add materialized recurring events backend
- Migration 007: parent_event_id (self-ref FK CASCADE), is_recurring, original_start columns on calendar_events - CalendarEvent model: three new Mapped[] columns for recurrence tracking - RecurrenceRule Pydantic model: typed schema for every_n_days, weekly, monthly_nth_weekday, monthly_date - CalendarEventCreate/Update: accept structured RecurrenceRule (router serializes to JSON string for DB) - CalendarEventUpdate: edit_scope field (this | this_and_future) - CalendarEventResponse: exposes parent_event_id, is_recurring, original_start - recurrence.py service: generates unsaved child ORM objects from parent rule up to 365-day horizon - GET /: excludes parent template rows (children are displayed instead) - POST /: creates parent + bulk children when recurrence_rule provided - PUT /: scope=this detaches occurrence; scope=this_and_future deletes future siblings and regenerates - DELETE /: scope=this deletes one; scope=this_and_future deletes future siblings; no scope deletes all (CASCADE) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5b056cf674
commit
d89758fedf
62
backend/alembic/versions/007_add_recurrence_fields.py
Normal file
62
backend/alembic/versions/007_add_recurrence_fields.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""Add recurrence fields to calendar_events
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-02-22 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '007'
|
||||
down_revision: Union[str, None] = '006'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# parent_event_id: self-referential FK, nullable, ON DELETE CASCADE
|
||||
op.add_column(
|
||||
'calendar_events',
|
||||
sa.Column('parent_event_id', sa.Integer(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'fk_calendar_events_parent_event_id',
|
||||
'calendar_events',
|
||||
'calendar_events',
|
||||
['parent_event_id'],
|
||||
['id'],
|
||||
ondelete='CASCADE',
|
||||
)
|
||||
|
||||
# is_recurring: tracks whether this row is part of a recurring series
|
||||
op.add_column(
|
||||
'calendar_events',
|
||||
sa.Column(
|
||||
'is_recurring',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default='false',
|
||||
)
|
||||
)
|
||||
|
||||
# original_start: the originally computed occurrence datetime (naive, no TZ)
|
||||
op.add_column(
|
||||
'calendar_events',
|
||||
sa.Column('original_start', sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('calendar_events', 'original_start')
|
||||
op.drop_column('calendar_events', 'is_recurring')
|
||||
op.drop_constraint(
|
||||
'fk_calendar_events_parent_event_id',
|
||||
'calendar_events',
|
||||
type_='foreignkey',
|
||||
)
|
||||
op.drop_column('calendar_events', 'parent_event_id')
|
||||
@ -19,6 +19,19 @@ class CalendarEvent(Base):
|
||||
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
is_starred: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
calendar_id: Mapped[int] = mapped_column(Integer, ForeignKey("calendars.id"), nullable=False)
|
||||
|
||||
# Recurrence fields
|
||||
# parent_event_id: set on child events; NULL on the parent template row
|
||||
parent_event_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("calendar_events.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
# is_recurring: True on both the parent template and all generated children
|
||||
is_recurring: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# original_start: the originally computed occurrence datetime (children only)
|
||||
original_start: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@ -1,17 +1,24 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import Optional, List, Any
|
||||
from typing import Optional, List, Any, Literal
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.person import Person
|
||||
from app.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse
|
||||
from app.schemas.calendar_event import (
|
||||
CalendarEventCreate,
|
||||
CalendarEventUpdate,
|
||||
CalendarEventResponse,
|
||||
)
|
||||
from app.routers.auth import get_current_session
|
||||
from app.models.settings import Settings
|
||||
from app.services.recurrence import generate_occurrences
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -33,6 +40,9 @@ def _event_to_dict(event: CalendarEvent) -> dict:
|
||||
"calendar_name": event.calendar.name if event.calendar else "",
|
||||
"calendar_color": event.calendar.color if event.calendar else "",
|
||||
"is_virtual": False,
|
||||
"parent_event_id": event.parent_event_id,
|
||||
"is_recurring": event.is_recurring,
|
||||
"original_start": event.original_start,
|
||||
"created_at": event.created_at,
|
||||
"updated_at": event.updated_at,
|
||||
}
|
||||
@ -56,7 +66,6 @@ def _birthday_events_for_range(
|
||||
continue
|
||||
|
||||
bday: date = person.birthday
|
||||
# Generate for each year the birthday falls in the range
|
||||
for year in range(range_start.year, range_end.year + 1):
|
||||
try:
|
||||
bday_this_year = bday.replace(year=year)
|
||||
@ -86,6 +95,9 @@ def _birthday_events_for_range(
|
||||
"calendar_name": "Birthdays",
|
||||
"calendar_color": bday_calendar_color,
|
||||
"is_virtual": True,
|
||||
"parent_event_id": None,
|
||||
"is_recurring": False,
|
||||
"original_start": None,
|
||||
"created_at": start_dt,
|
||||
"updated_at": start_dt,
|
||||
})
|
||||
@ -107,14 +119,29 @@ async def get_events(
|
||||
start: Optional[date] = Query(None),
|
||||
end: Optional[date] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_session),
|
||||
) -> List[Any]:
|
||||
"""Get all calendar events with optional date range filtering, including virtual birthday events."""
|
||||
"""
|
||||
Get all calendar events with optional date range filtering.
|
||||
|
||||
Parent template rows (is_recurring=True, parent_event_id IS NULL,
|
||||
recurrence_rule IS NOT NULL) are excluded — their materialised children
|
||||
are what get displayed on the calendar.
|
||||
"""
|
||||
query = (
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
)
|
||||
|
||||
# Exclude parent template rows — they are not directly rendered
|
||||
query = query.where(
|
||||
~(
|
||||
(CalendarEvent.is_recurring == True)
|
||||
& (CalendarEvent.parent_event_id == None)
|
||||
& (CalendarEvent.recurrence_rule != None)
|
||||
)
|
||||
)
|
||||
|
||||
if start:
|
||||
query = query.where(CalendarEvent.end_datetime >= start)
|
||||
if end:
|
||||
@ -127,7 +154,7 @@ async def get_events(
|
||||
|
||||
response: List[dict] = [_event_to_dict(e) for e in events]
|
||||
|
||||
# Fetch Birthdays calendar; only generate virtual events if it exists and is visible
|
||||
# Fetch Birthdays calendar; only generate virtual events if visible
|
||||
bday_result = await db.execute(
|
||||
select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True)
|
||||
)
|
||||
@ -153,7 +180,7 @@ async def get_events(
|
||||
async def create_event(
|
||||
event: CalendarEventCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_session),
|
||||
):
|
||||
if event.end_datetime < event.start_datetime:
|
||||
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
|
||||
@ -164,11 +191,45 @@ async def create_event(
|
||||
if not data.get("calendar_id"):
|
||||
data["calendar_id"] = await _get_default_calendar_id(db)
|
||||
|
||||
new_event = CalendarEvent(**data)
|
||||
# Serialize RecurrenceRule object to JSON string for DB storage
|
||||
rule_obj = data.pop("recurrence_rule", None)
|
||||
rule_json: Optional[str] = json.dumps(rule_obj) if rule_obj else None
|
||||
|
||||
if rule_json:
|
||||
# Parent template: is_recurring=True, no parent_event_id
|
||||
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True)
|
||||
db.add(parent)
|
||||
await db.flush() # assign parent.id before generating children
|
||||
|
||||
children = generate_occurrences(parent)
|
||||
for child in children:
|
||||
db.add(child)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Return the first child so the caller sees a concrete occurrence
|
||||
# (The parent template is intentionally hidden from list views)
|
||||
if children:
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(CalendarEvent.id == children[0].id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
# Fallback: no children generated (e.g. bad rule), return parent
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(CalendarEvent.id == parent.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
else:
|
||||
new_event = CalendarEvent(**data, recurrence_rule=None)
|
||||
db.add(new_event)
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with relationship eagerly loaded
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
@ -181,7 +242,7 @@ async def create_event(
|
||||
async def get_event(
|
||||
event_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_session),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
@ -201,7 +262,7 @@ async def update_event(
|
||||
event_id: int,
|
||||
event_update: CalendarEventUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_session),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
@ -215,31 +276,85 @@ async def update_event(
|
||||
|
||||
update_data = event_update.model_dump(exclude_unset=True)
|
||||
|
||||
start = update_data.get("start_datetime", event.start_datetime)
|
||||
end = update_data.get("end_datetime", event.end_datetime)
|
||||
# Extract scope before applying fields to the model
|
||||
scope: Optional[str] = update_data.pop("edit_scope", None)
|
||||
|
||||
if end < start:
|
||||
# Serialize RecurrenceRule → JSON string if present in update payload
|
||||
rule_obj = update_data.pop("recurrence_rule", None)
|
||||
if rule_obj is not None:
|
||||
update_data["recurrence_rule"] = json.dumps(rule_obj) if rule_obj else None
|
||||
|
||||
start = update_data.get("start_datetime", event.start_datetime)
|
||||
end_dt = update_data.get("end_datetime", event.end_datetime)
|
||||
if end_dt < start:
|
||||
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
|
||||
|
||||
if scope == "this":
|
||||
# Update only this occurrence and detach it from the series
|
||||
for key, value in update_data.items():
|
||||
setattr(event, key, value)
|
||||
# Detach from parent so it's an independent event going forward
|
||||
event.parent_event_id = None
|
||||
event.is_recurring = False
|
||||
await db.commit()
|
||||
|
||||
elif scope == "this_and_future":
|
||||
# Delete all future siblings (same parent, original_start >= this event's original_start)
|
||||
parent_id = event.parent_event_id
|
||||
this_original_start = event.original_start or event.start_datetime
|
||||
|
||||
if parent_id is not None:
|
||||
await db.execute(
|
||||
delete(CalendarEvent).where(
|
||||
CalendarEvent.parent_event_id == parent_id,
|
||||
CalendarEvent.original_start >= this_original_start,
|
||||
)
|
||||
)
|
||||
|
||||
# Update this event with the new data, making it a new parent
|
||||
for key, value in update_data.items():
|
||||
setattr(event, key, value)
|
||||
event.parent_event_id = None
|
||||
event.is_recurring = True
|
||||
event.original_start = None
|
||||
|
||||
# If a new recurrence_rule was provided, regenerate children from this point
|
||||
if event.recurrence_rule:
|
||||
await db.flush()
|
||||
children = generate_occurrences(event)
|
||||
for child in children:
|
||||
db.add(child)
|
||||
else:
|
||||
# Not part of a series — plain update
|
||||
for key, value in update_data.items():
|
||||
setattr(event, key, value)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch to ensure relationship is fresh after commit
|
||||
else:
|
||||
# No scope — plain update (non-recurring events or full-series metadata)
|
||||
for key, value in update_data.items():
|
||||
setattr(event, key, value)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(CalendarEvent.id == event_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
updated = result.scalar_one_or_none()
|
||||
if not updated:
|
||||
# Event was deleted as part of this_and_future; 404 is appropriate
|
||||
raise HTTPException(status_code=404, detail="Event no longer exists after update")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/{event_id}", status_code=204)
|
||||
async def delete_event(
|
||||
event_id: int,
|
||||
scope: Optional[Literal["this", "this_and_future"]] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_session),
|
||||
):
|
||||
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
|
||||
event = result.scalar_one_or_none()
|
||||
@ -247,6 +362,29 @@ async def delete_event(
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
|
||||
if scope == "this":
|
||||
# Delete just this one occurrence
|
||||
await db.delete(event)
|
||||
|
||||
elif scope == "this_and_future":
|
||||
parent_id = event.parent_event_id
|
||||
this_original_start = event.original_start or event.start_datetime
|
||||
|
||||
if parent_id is not None:
|
||||
# Delete this + all future siblings
|
||||
await db.execute(
|
||||
delete(CalendarEvent).where(
|
||||
CalendarEvent.parent_event_id == parent_id,
|
||||
CalendarEvent.original_start >= this_original_start,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# This event IS the parent — delete it and all children (CASCADE handles children)
|
||||
await db.delete(event)
|
||||
|
||||
else:
|
||||
# Plain delete (non-recurring; or delete entire series if parent)
|
||||
await db.delete(event)
|
||||
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
@ -1,6 +1,19 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
|
||||
class RecurrenceRule(BaseModel):
|
||||
"""Structured recurrence rule — serialized to/from JSON string in the DB column."""
|
||||
type: Literal["every_n_days", "weekly", "monthly_nth_weekday", "monthly_date"]
|
||||
# every_n_days
|
||||
interval: Optional[int] = None
|
||||
# weekly / monthly_nth_weekday
|
||||
weekday: Optional[int] = None # 0=Mon … 6=Sun
|
||||
# monthly_nth_weekday
|
||||
week: Optional[int] = None # 1-4
|
||||
# monthly_date
|
||||
day: Optional[int] = None # 1-31
|
||||
|
||||
|
||||
class CalendarEventCreate(BaseModel):
|
||||
@ -11,7 +24,7 @@ class CalendarEventCreate(BaseModel):
|
||||
all_day: bool = False
|
||||
color: Optional[str] = None
|
||||
location_id: Optional[int] = None
|
||||
recurrence_rule: Optional[str] = None
|
||||
recurrence_rule: Optional[RecurrenceRule] = None # structured; router serializes to JSON string
|
||||
is_starred: bool = False
|
||||
calendar_id: Optional[int] = None # If None, server assigns default calendar
|
||||
|
||||
@ -24,9 +37,11 @@ class CalendarEventUpdate(BaseModel):
|
||||
all_day: Optional[bool] = None
|
||||
color: Optional[str] = None
|
||||
location_id: Optional[int] = None
|
||||
recurrence_rule: Optional[str] = None
|
||||
recurrence_rule: Optional[RecurrenceRule] = None # structured; router serializes to JSON string
|
||||
is_starred: Optional[bool] = None
|
||||
calendar_id: Optional[int] = None
|
||||
# Controls which occurrences an edit applies to; absent = non-recurring or whole-series
|
||||
edit_scope: Optional[Literal["this", "this_and_future"]] = None
|
||||
|
||||
|
||||
class CalendarEventResponse(BaseModel):
|
||||
@ -38,12 +53,16 @@ class CalendarEventResponse(BaseModel):
|
||||
all_day: bool
|
||||
color: Optional[str]
|
||||
location_id: Optional[int]
|
||||
recurrence_rule: Optional[str]
|
||||
recurrence_rule: Optional[str] # raw JSON string from DB; frontend parses
|
||||
is_starred: bool
|
||||
calendar_id: int
|
||||
calendar_name: str
|
||||
calendar_color: str
|
||||
is_virtual: bool = False
|
||||
# Recurrence fields
|
||||
parent_event_id: Optional[int] = None
|
||||
is_recurring: bool = False
|
||||
original_start: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
176
backend/app/services/recurrence.py
Normal file
176
backend/app/services/recurrence.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""
|
||||
Recurrence service — generates materialized child CalendarEvent ORM objects
|
||||
from a parent event's recurrence_rule JSON string.
|
||||
|
||||
All datetimes are naive (no timezone) to match TIMESTAMP WITHOUT TIME ZONE in DB.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
|
||||
|
||||
def _nth_weekday_of_month(year: int, month: int, weekday: int, week: int) -> Optional[datetime]:
|
||||
"""
|
||||
Return the date of the Nth (1-4) occurrence of a given weekday (0=Mon…6=Sun)
|
||||
in a month, or None if it doesn't exist (e.g. 5th Monday in a short month).
|
||||
"""
|
||||
# Find the first occurrence of the weekday in the month
|
||||
first_day = datetime(year, month, 1)
|
||||
# days_ahead: how many days until the target weekday
|
||||
days_ahead = (weekday - first_day.weekday()) % 7
|
||||
first_occurrence = first_day + timedelta(days=days_ahead)
|
||||
# Advance by (week - 1) full weeks
|
||||
target = first_occurrence + timedelta(weeks=week - 1)
|
||||
if target.month != month:
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def generate_occurrences(
|
||||
parent: CalendarEvent,
|
||||
horizon_days: int = 365,
|
||||
) -> list[CalendarEvent]:
|
||||
"""
|
||||
Parse parent.recurrence_rule (JSON string) and materialise child CalendarEvent
|
||||
objects up to horizon_days from parent.start_datetime.
|
||||
|
||||
Supported rule types:
|
||||
- every_n_days : {"type": "every_n_days", "interval": N}
|
||||
- weekly : {"type": "weekly", "weekday": 0-6} (0=Mon, 6=Sun)
|
||||
- monthly_nth_weekday: {"type": "monthly_nth_weekday", "week": 1-4, "weekday": 0-6}
|
||||
- monthly_date : {"type": "monthly_date", "day": 1-31}
|
||||
|
||||
Returns unsaved ORM objects — the caller must add them to the session.
|
||||
"""
|
||||
if not parent.recurrence_rule:
|
||||
return []
|
||||
|
||||
try:
|
||||
rule: dict = json.loads(parent.recurrence_rule)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
rule_type: str = rule.get("type", "")
|
||||
parent_start: datetime = parent.start_datetime
|
||||
parent_end: datetime = parent.end_datetime
|
||||
duration: timedelta = parent_end - parent_start
|
||||
horizon: datetime = parent_start + timedelta(days=horizon_days)
|
||||
|
||||
# Fields to copy verbatim from parent to each child
|
||||
shared_fields = dict(
|
||||
title=parent.title,
|
||||
description=parent.description,
|
||||
all_day=parent.all_day,
|
||||
color=parent.color,
|
||||
location_id=parent.location_id,
|
||||
is_starred=parent.is_starred,
|
||||
calendar_id=parent.calendar_id,
|
||||
# Children do not carry the recurrence_rule; parent is the template
|
||||
recurrence_rule=None,
|
||||
parent_event_id=parent.id,
|
||||
is_recurring=True,
|
||||
)
|
||||
|
||||
occurrences: list[CalendarEvent] = []
|
||||
|
||||
def _make_child(occ_start: datetime) -> CalendarEvent:
|
||||
occ_end = occ_start + duration
|
||||
return CalendarEvent(
|
||||
**shared_fields,
|
||||
start_datetime=occ_start,
|
||||
end_datetime=occ_end,
|
||||
original_start=occ_start,
|
||||
)
|
||||
|
||||
if rule_type == "every_n_days":
|
||||
interval: int = int(rule.get("interval", 1))
|
||||
if interval < 1:
|
||||
interval = 1
|
||||
current = parent_start + timedelta(days=interval)
|
||||
while current < horizon:
|
||||
occurrences.append(_make_child(current))
|
||||
current += timedelta(days=interval)
|
||||
|
||||
elif rule_type == "weekly":
|
||||
weekday: int = int(rule.get("weekday", parent_start.weekday()))
|
||||
# Start from the week after the parent
|
||||
days_ahead = (weekday - parent_start.weekday()) % 7
|
||||
if days_ahead == 0:
|
||||
days_ahead = 7 # skip the parent's own week
|
||||
current = parent_start + timedelta(days=days_ahead)
|
||||
while current < horizon:
|
||||
occurrences.append(_make_child(current))
|
||||
current += timedelta(weeks=1)
|
||||
|
||||
elif rule_type == "monthly_nth_weekday":
|
||||
week: int = int(rule.get("week", 1))
|
||||
weekday = int(rule.get("weekday", parent_start.weekday()))
|
||||
# Advance month by month
|
||||
year, month = parent_start.year, parent_start.month
|
||||
# Move to the next month from the parent
|
||||
month += 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
while True:
|
||||
target = _nth_weekday_of_month(year, month, weekday, week)
|
||||
if target is None:
|
||||
# Skip months where Nth weekday doesn't exist
|
||||
pass
|
||||
else:
|
||||
# Preserve the time-of-day from the parent
|
||||
occ_start = target.replace(
|
||||
hour=parent_start.hour,
|
||||
minute=parent_start.minute,
|
||||
second=parent_start.second,
|
||||
microsecond=0,
|
||||
)
|
||||
if occ_start >= horizon:
|
||||
break
|
||||
occurrences.append(_make_child(occ_start))
|
||||
month += 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
# Safety: if we've passed the horizon year by 2, stop
|
||||
if year > horizon.year + 1:
|
||||
break
|
||||
|
||||
elif rule_type == "monthly_date":
|
||||
day: int = int(rule.get("day", parent_start.day))
|
||||
year, month = parent_start.year, parent_start.month
|
||||
month += 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
while True:
|
||||
# Some months don't have day 29-31
|
||||
try:
|
||||
occ_start = datetime(
|
||||
year, month, day,
|
||||
parent_start.hour,
|
||||
parent_start.minute,
|
||||
parent_start.second,
|
||||
)
|
||||
except ValueError:
|
||||
# Day out of range for this month — skip
|
||||
month += 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
if year > horizon.year + 1:
|
||||
break
|
||||
continue
|
||||
if occ_start >= horizon:
|
||||
break
|
||||
occurrences.append(_make_child(occ_start))
|
||||
month += 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
if year > horizon.year + 1:
|
||||
break
|
||||
|
||||
return occurrences
|
||||
Loading…
x
Reference in New Issue
Block a user