Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pecha_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
NOTIFICATION_DEFAULT_TITLE="WebBuddhist",
NOTIFICATION_DEFAULT_BODY="Time for your daily practice.",

# Verse of the day retention (days); scheduler deletes older rows daily
VERSE_OF_DAY_EXPIRY_DAYS=7,

)

TIME_FORMAT_PATTERN = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
Expand Down
38 changes: 26 additions & 12 deletions pecha_api/db/mongo_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ..texts.texts_models import TableOfContent
from ..texts.groups.groups_models import Group
from ..config import get
from ..scheduler import setup_scheduler, shutdown_scheduler

mongodb_client = None
mongodb = None
Expand All @@ -26,16 +27,29 @@ async def lifespan(api: FastAPI):
mongodb = mongodb_client[get("MONGO_DATABASE_NAME")]
api.mongodb = mongodb # Attach the database instance to the FastAPI app

# Initialize collections and indexes if necessary
try:
await init_beanie(database=mongodb,document_models=[Collection, Term, Topic, Text, Segment, TableOfContent, Group])
logging.info("Beanie initialized with the 'terms' collection.")

except Exception as e:
logging.error(f"Error during collection initialization: {e}")
raise

yield

if mongodb_client:
mongodb_client.close()
# Initialize collections and indexes if necessary
try:
await init_beanie(
database=mongodb,
document_models=[
Collection,
Term,
Topic,
Text,
Segment,
TableOfContent,
Group,
],
)
logging.info("Beanie initialized with the 'terms' collection.")
except Exception as e:
logging.error(f"Error during collection initialization: {e}")
raise

setup_scheduler()
yield
finally:
shutdown_scheduler()
if mongodb_client:
mongodb_client.close()
39 changes: 39 additions & 0 deletions pecha_api/scheduler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import logging

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger

from pecha_api.config import get_int
from pecha_api.verse_of_day.verse_of_day_service import cleanup_expired_verses_of_day

logger = logging.getLogger(__name__)

scheduler = AsyncIOScheduler()


def setup_scheduler() -> None:
expiry_days = get_int("VERSE_OF_DAY_EXPIRY_DAYS")
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if expiry_days < 1:
raise ValueError(
f"VERSE_OF_DAY_EXPIRY_DAYS must be a positive integer, got {expiry_days}"
)
scheduler.add_job(
cleanup_expired_verses_of_day,
CronTrigger(hour=0, minute=0),
args=[expiry_days],
id="cleanup_expired_verses_of_day",
name="Cleanup expired verses of the day",
replace_existing=True,
)
if not scheduler.running:
scheduler.start()
logger.info(
"Scheduler started: cleaning verses of the day older than %s day(s) daily at midnight",
expiry_days,
)


def shutdown_scheduler() -> None:
if scheduler.running:
scheduler.shutdown(wait=False)
logger.info("Scheduler shut down")
10 changes: 10 additions & 0 deletions pecha_api/verse_of_day/verse_of_day_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,13 @@ def delete_verse_of_day(db: Session, verse_id: UUID) -> bool:
db.delete(verse)
db.commit()
return True


def delete_verses_of_day_older_than(db: Session, cutoff_date: date) -> int:
deleted_count = (
db.query(VerseOfDay)
.filter(VerseOfDay.date < cutoff_date)
.delete(synchronize_session=False)
)
db.commit()
return deleted_count
19 changes: 17 additions & 2 deletions pecha_api/verse_of_day/verse_of_day_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Optional, Dict, List
from uuid import UUID
from datetime import date
from datetime import date, datetime, timedelta, timezone
import logging

from pecha_api.timezone_utils import get_date_in_timezone
Expand All @@ -18,7 +18,8 @@
get_group_metadata_by_group_id,
update_verse_of_day,
delete_verse_metadata_by_verse_id,
delete_verse_of_day
delete_verse_of_day,
delete_verses_of_day_older_than,
)
from .verse_of_day_response_models import (
VerseOfDayPublicDTO,
Expand Down Expand Up @@ -367,3 +368,17 @@ def delete_verse_of_day_service(verse_id: UUID) -> None:
)

delete_verse_of_day(db, verse_id)


def cleanup_expired_verses_of_day(expiry_days: int) -> int:
if expiry_days < 1:
raise ValueError(f"expiry_days must be a positive integer, got {expiry_days}")
cutoff_date = datetime.now(timezone.utc).date() - timedelta(days=expiry_days)
with SessionLocal() as db:
deleted_count = delete_verses_of_day_older_than(db, cutoff_date)
logger.info(
"Deleted %s verse(s) of the day older than %s",
deleted_count,
cutoff_date,
)
return deleted_count
58 changes: 57 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ mailtrap = "^2.3.0"
google-genai = "^2.0"

scalar-fastapi = "^1.8.2"
apscheduler = "^3.11.3"
[tool.poetry.scripts]
start = "uvicorn:main"

Expand Down
65 changes: 64 additions & 1 deletion tests/db/test_mongo_database_lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ async def test_lifespan_closes_mongo_client_on_shutdown():
"MONGO_CONNECTION_STRING": "mongodb://localhost:27017",
"MONGO_DATABASE_NAME": "testdb",
}[key],
):
), patch("pecha_api.db.mongo_database.setup_scheduler") as mock_setup_scheduler, patch(
"pecha_api.db.mongo_database.shutdown_scheduler"
) as mock_shutdown_scheduler:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_db = MagicMock()
Expand All @@ -27,4 +29,65 @@ async def test_lifespan_closes_mongo_client_on_shutdown():
async with lifespan(api):
assert api.mongodb is mock_db

mock_setup_scheduler.assert_called_once()
mock_shutdown_scheduler.assert_called_once()
mock_client.close.assert_called_once()


@pytest.mark.asyncio
async def test_lifespan_cleans_up_when_beanie_init_fails():
api = MagicMock()

with patch("pecha_api.db.mongo_database.AsyncIOMotorClient") as mock_client_cls, patch(
"pecha_api.db.mongo_database.init_beanie",
new_callable=AsyncMock,
side_effect=RuntimeError("beanie failed"),
), patch(
"pecha_api.db.mongo_database.get",
side_effect=lambda key: {
"MONGO_CONNECTION_STRING": "mongodb://localhost:27017",
"MONGO_DATABASE_NAME": "testdb",
}[key],
), patch("pecha_api.db.mongo_database.setup_scheduler") as mock_setup_scheduler, patch(
"pecha_api.db.mongo_database.shutdown_scheduler"
) as mock_shutdown_scheduler:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.__getitem__.return_value = MagicMock()

with pytest.raises(RuntimeError, match="beanie failed"):
async with lifespan(api):
pass

mock_setup_scheduler.assert_not_called()
mock_shutdown_scheduler.assert_called_once()
mock_client.close.assert_called_once()


@pytest.mark.asyncio
async def test_lifespan_cleans_up_when_scheduler_setup_fails():
api = MagicMock()

with patch("pecha_api.db.mongo_database.AsyncIOMotorClient") as mock_client_cls, patch(
"pecha_api.db.mongo_database.init_beanie",
new_callable=AsyncMock,
), patch(
"pecha_api.db.mongo_database.get",
side_effect=lambda key: {
"MONGO_CONNECTION_STRING": "mongodb://localhost:27017",
"MONGO_DATABASE_NAME": "testdb",
}[key],
), patch(
"pecha_api.db.mongo_database.setup_scheduler",
side_effect=ValueError("invalid retention"),
), patch("pecha_api.db.mongo_database.shutdown_scheduler") as mock_shutdown_scheduler:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.__getitem__.return_value = MagicMock()

with pytest.raises(ValueError, match="invalid retention"):
async with lifespan(api):
pass

mock_shutdown_scheduler.assert_called_once()
mock_client.close.assert_called_once()
67 changes: 67 additions & 0 deletions tests/test_scheduler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from unittest.mock import MagicMock, patch

import pytest

from pecha_api.scheduler import setup_scheduler, shutdown_scheduler


def test_setup_scheduler_rejects_non_positive_retention():
with patch("pecha_api.scheduler.get_int", return_value=0), patch(
"pecha_api.scheduler.scheduler"
) as mock_scheduler:
mock_scheduler.running = False

with pytest.raises(ValueError, match="positive integer"):
setup_scheduler()

mock_scheduler.add_job.assert_not_called()
mock_scheduler.start.assert_not_called()


def test_setup_scheduler_rejects_negative_retention():
with patch("pecha_api.scheduler.get_int", return_value=-7), patch(
"pecha_api.scheduler.scheduler"
) as mock_scheduler:
mock_scheduler.running = False

with pytest.raises(ValueError, match="positive integer"):
setup_scheduler()

mock_scheduler.add_job.assert_not_called()


def test_setup_scheduler_registers_cleanup_job():
with patch("pecha_api.scheduler.get_int", return_value=7), patch(
"pecha_api.scheduler.scheduler"
) as mock_scheduler, patch(
"pecha_api.scheduler.CronTrigger"
) as mock_cron_trigger:
mock_scheduler.running = False
mock_trigger = MagicMock()
mock_cron_trigger.return_value = mock_trigger

setup_scheduler()

mock_scheduler.add_job.assert_called_once()
call_kwargs = mock_scheduler.add_job.call_args
assert call_kwargs.kwargs["args"] == [7]
assert call_kwargs.kwargs["id"] == "cleanup_expired_verses_of_day"
mock_scheduler.start.assert_called_once()


def test_shutdown_scheduler_when_running():
with patch("pecha_api.scheduler.scheduler") as mock_scheduler:
mock_scheduler.running = True

shutdown_scheduler()

mock_scheduler.shutdown.assert_called_once_with(wait=False)


def test_shutdown_scheduler_when_not_running():
with patch("pecha_api.scheduler.scheduler") as mock_scheduler:
mock_scheduler.running = False

shutdown_scheduler()

mock_scheduler.shutdown.assert_not_called()
Loading
Loading