diff --git a/app/db/models.py b/app/db/models.py index 6c66abb..5105e46 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -89,6 +89,20 @@ class SystemLogEntry(Base): user_id: Mapped[int | None] = mapped_column(ForeignKey("sync_users.id", ondelete="SET NULL")) +class SchedulerSettings(Base): + """Singleton row (id is always 1) holding the admin-configurable day/night + sync interval -- editable at runtime via the UI, unlike SYNC_INTERVAL_MINUTES + which only seeds this row's initial values on first startup.""" + + __tablename__ = "scheduler_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + day_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=6) + night_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=22) + day_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5) + night_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5) + + class SyncRun(Base): __tablename__ = "sync_runs" diff --git a/app/db/repositories.py b/app/db/repositories.py index 2535299..eaba7b7 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -5,7 +5,18 @@ from sqlalchemy import and_, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from app.db.models import Activity, ActivityStatus, SyncRun, SyncRunStatus, SystemLogEntry, SyncUser, utcnow +from app.db.models import ( + Activity, + ActivityStatus, + SchedulerSettings, + SyncRun, + SyncRunStatus, + SystemLogEntry, + SyncUser, + utcnow, +) + +_SCHEDULER_SETTINGS_ID = 1 @dataclass(frozen=True) @@ -211,6 +222,29 @@ class SystemLogRepository: ) +class SchedulerSettingsRepository: + def __init__(self, session: Session) -> None: + self.session = session + + def get_or_create(self, *, default_minutes: int = 5) -> SchedulerSettings: + row = self.session.get(SchedulerSettings, _SCHEDULER_SETTINGS_ID) + if row is None: + row = SchedulerSettings( + id=_SCHEDULER_SETTINGS_ID, + day_interval_minutes=default_minutes, + night_interval_minutes=default_minutes, + ) + self.session.add(row) + self.session.commit() + return row + + def update(self, row: SchedulerSettings, **values) -> SchedulerSettings: + for key, value in values.items(): + setattr(row, key, value) + self.session.commit() + return row + + class SyncRunRepository: def __init__(self, session: Session) -> None: self.session = session diff --git a/app/main.py b/app/main.py index 8288275..1684078 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from app.mywhoosh.client import MyWhooshClient from app.notifications.emailer import EmailNotifier from app.security.credentials import CredentialCipher from app.sync.manager import SyncManager -from app.sync.scheduler import SyncScheduler +from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler from app.web.account import router as account_router from app.web.operations import router as operations_router from app.web.routes import router as web_router @@ -55,7 +55,14 @@ def create_app(settings: Settings | None = None) -> FastAPI: ) app.state.sync_manager = sync_manager - scheduler = SyncScheduler(sync_manager, interval_seconds=resolved.sync_interval_minutes * 60) + interval_provider = DayNightIntervalProvider( + app.state.session_factory, default_minutes=resolved.sync_interval_minutes + ) + scheduler = SyncScheduler( + sync_manager, + interval_seconds=resolved.sync_interval_minutes * 60, + interval_provider=interval_provider, + ) app.state.scheduler = scheduler await scheduler.start() diff --git a/app/sync/scheduler.py b/app/sync/scheduler.py index b87882e..1459857 100644 --- a/app/sync/scheduler.py +++ b/app/sync/scheduler.py @@ -1,22 +1,80 @@ import asyncio import logging from datetime import datetime, timedelta, timezone +from typing import Callable +from app.db.repositories import SchedulerSettingsRepository from app.sync.manager import SyncAlreadyRunning logger = logging.getLogger(__name__) +def is_daytime(hour: int, day_start_hour: int, night_start_hour: int) -> bool: + """True if `hour` falls within [day_start_hour, night_start_hour), + handling a day period that wraps past midnight (e.g. day_start=20, + night_start=6). Equal start hours are treated as "always day".""" + if day_start_hour == night_start_hour: + return True + if day_start_hour < night_start_hour: + return day_start_hour <= hour < night_start_hour + return hour >= day_start_hour or hour < night_start_hour + + +class DayNightIntervalProvider: + """Reads the admin-configurable day/night interval from the DB on every + call, so a change made via the UI takes effect on the scheduler's very + next tick without an app restart.""" + + def __init__(self, session_factory, *, default_minutes: int, now: Callable[[], datetime] | None = None) -> None: + self.session_factory = session_factory + self.default_minutes = default_minutes + self._now = now or datetime.now + + def __call__(self) -> float: + with self.session_factory() as session: + row = SchedulerSettingsRepository(session).get_or_create(default_minutes=self.default_minutes) + day_start_hour = row.day_start_hour + night_start_hour = row.night_start_hour + day_interval_minutes = row.day_interval_minutes + night_interval_minutes = row.night_interval_minutes + + hour = self._now().hour + minutes = ( + day_interval_minutes + if is_daytime(hour, day_start_hour, night_start_hour) + else night_interval_minutes + ) + return minutes * 60 + + class SyncScheduler: - def __init__(self, manager, *, interval_seconds: float) -> None: + def __init__( + self, + manager, + *, + interval_seconds: float, + interval_provider: Callable[[], float] | None = None, + ) -> None: self.manager = manager self.interval_seconds = interval_seconds + self.interval_provider = interval_provider self._task: asyncio.Task | None = None self._stop = asyncio.Event() self.last_tick = None self.next_tick = None - async def run_once(self) -> None: + def _current_interval(self) -> float: + if self.interval_provider is None: + return self.interval_seconds + try: + return float(self.interval_provider()) + except Exception: + logger.warning("interval_provider failed, falling back to default interval", exc_info=True) + return self.interval_seconds + + async def run_once(self, interval: float | None = None) -> None: + if interval is None: + interval = self._current_interval() self.last_tick = datetime.now(timezone.utc) try: results = await self.manager.sync_all_enabled() @@ -36,13 +94,14 @@ class SyncScheduler: except Exception: logger.exception("sync_all_enabled failed during scheduled tick") finally: - self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=self.interval_seconds) + self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=interval) async def _run(self) -> None: while not self._stop.is_set(): - await self.run_once() + interval = self._current_interval() + await self.run_once(interval) try: - await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds) + await asyncio.wait_for(self._stop.wait(), timeout=interval) except TimeoutError: pass diff --git a/app/web/operations.py b/app/web/operations.py index 507ba2c..5099aaf 100644 --- a/app/web/operations.py +++ b/app/web/operations.py @@ -1,11 +1,11 @@ from fastapi import APIRouter, Form, HTTPException, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy import func, select from app.auth.admin import require_admin from app.auth.csrf import ensure_csrf_token, validate_csrf from app.db.models import Activity -from app.db.repositories import ActivityRepository, SystemLogRepository, UserRepository +from app.db.repositories import ActivityRepository, SchedulerSettingsRepository, SystemLogRepository, UserRepository from app.sync.manager import SyncAlreadyRunning from app.web.routes import templates @@ -104,13 +104,45 @@ def system_page(request: Request): user_count = len(UserRepository(session).list_all()) activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0 log_entries = SystemLogRepository(session).list_recent(limit=50) + scheduler_settings = SchedulerSettingsRepository(session).get_or_create( + default_minutes=settings.sync_interval_minutes + ) return templates.TemplateResponse(request, "system.html", { "csrf_token": ensure_csrf_token(request), "app_version": APP_VERSION, - "sync_interval_minutes": settings.sync_interval_minutes, "last_tick": scheduler.last_tick, "next_tick": scheduler.next_tick, "user_count": user_count, "activity_count": activity_count, "log_entries": log_entries, + "scheduler_settings": scheduler_settings, }) + + +@router.post("/system/scheduler-settings") +def update_scheduler_settings( + request: Request, + csrf_token: str = Form(...), + day_start_hour: int = Form(...), + night_start_hour: int = Form(...), + day_interval_minutes: int = Form(...), + night_interval_minutes: int = Form(...), +): + require_admin(request) + validate_csrf(request, csrf_token) + if not (0 <= day_start_hour <= 23) or not (0 <= night_start_hour <= 23): + raise HTTPException(status_code=400, detail="Hours must be between 0 and 23") + if day_interval_minutes < 1 or night_interval_minutes < 1: + raise HTTPException(status_code=400, detail="Intervals must be at least 1 minute") + settings = request.app.state.settings + with request.app.state.session_factory() as session: + repository = SchedulerSettingsRepository(session) + row = repository.get_or_create(default_minutes=settings.sync_interval_minutes) + repository.update( + row, + day_start_hour=day_start_hour, + night_start_hour=night_start_hour, + day_interval_minutes=day_interval_minutes, + night_interval_minutes=night_interval_minutes, + ) + return RedirectResponse("/system", status_code=303) diff --git a/app/web/templates/system.html b/app/web/templates/system.html index 94442ff..a0b8bd4 100644 --- a/app/web/templates/system.html +++ b/app/web/templates/system.html @@ -10,9 +10,6 @@
Application version
{{ app_version }}
-
Sync interval (minutes)
-
{{ sync_interval_minutes }}
-
Last scheduler tick
{{ last_tick or "-" }}
@@ -32,6 +29,31 @@ +

Sync scheduling

+
+
+ + + + + + + + + + + + + + + +
+
+

System log

{% if log_entries %} diff --git a/tests/conftest.py b/tests/conftest.py index 686b514..d32564b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,13 @@ from sqlalchemy.pool import StaticPool from app.config import Settings from app.db.models import Activity, Base, HealthState -from app.db.repositories import ActivityRepository, SyncRunRepository, SystemLogRepository, UserRepository +from app.db.repositories import ( + ActivityRepository, + SchedulerSettingsRepository, + SyncRunRepository, + SystemLogRepository, + UserRepository, +) from app.main import create_app @@ -44,6 +50,11 @@ def sync_run_repository(db_session: Session) -> SyncRunRepository: return SyncRunRepository(db_session) +@pytest.fixture +def scheduler_settings_repository(db_session: Session) -> SchedulerSettingsRepository: + return SchedulerSettingsRepository(db_session) + + @pytest.fixture def system_log_repository(db_session: Session) -> SystemLogRepository: return SystemLogRepository(db_session) diff --git a/tests/db/test_repositories.py b/tests/db/test_repositories.py index 9d4d67e..370049c 100644 --- a/tests/db/test_repositories.py +++ b/tests/db/test_repositories.py @@ -93,3 +93,39 @@ def test_system_log_respects_limit(system_log_repository) -> None: entries = system_log_repository.list_recent(limit=2) assert len(entries) == 2 + + +def test_scheduler_settings_get_or_create_seeds_defaults(scheduler_settings_repository) -> None: + row = scheduler_settings_repository.get_or_create(default_minutes=7) + + assert row.day_interval_minutes == 7 + assert row.night_interval_minutes == 7 + assert row.day_start_hour == 6 + assert row.night_start_hour == 22 + + +def test_scheduler_settings_get_or_create_is_idempotent_after_update(scheduler_settings_repository) -> None: + row = scheduler_settings_repository.get_or_create(default_minutes=5) + scheduler_settings_repository.update(row, day_interval_minutes=15) + + reloaded = scheduler_settings_repository.get_or_create(default_minutes=5) + + assert reloaded.day_interval_minutes == 15 + + +def test_scheduler_settings_update_persists_all_fields(scheduler_settings_repository) -> None: + row = scheduler_settings_repository.get_or_create(default_minutes=5) + + scheduler_settings_repository.update( + row, + day_start_hour=8, + night_start_hour=20, + day_interval_minutes=10, + night_interval_minutes=45, + ) + + reloaded = scheduler_settings_repository.get_or_create(default_minutes=5) + assert reloaded.day_start_hour == 8 + assert reloaded.night_start_hour == 20 + assert reloaded.day_interval_minutes == 10 + assert reloaded.night_interval_minutes == 45 diff --git a/tests/sync/test_scheduler.py b/tests/sync/test_scheduler.py index 9843ba9..546cfd3 100644 --- a/tests/sync/test_scheduler.py +++ b/tests/sync/test_scheduler.py @@ -1,8 +1,14 @@ import asyncio +from datetime import datetime import pytest +from sqlalchemy import create_engine +from sqlalchemy.pool import StaticPool -from app.sync.scheduler import SyncScheduler +from app.db.models import Base +from app.db.repositories import SchedulerSettingsRepository +from app.db.session import create_session_factory +from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler, is_daytime class FakeSyncManager: @@ -40,3 +46,91 @@ async def test_scheduler_stop_cancels_the_loop() -> None: calls_after_stop = fake.calls await asyncio.sleep(0.05) assert fake.calls == calls_after_stop + + +@pytest.mark.parametrize( + ("hour", "day_start", "night_start", "expected"), + [ + (6, 6, 22, True), + (21, 6, 22, True), + (5, 6, 22, False), + (22, 6, 22, False), + (0, 6, 22, False), + # wraps past midnight: day period is [20, 6) + (23, 20, 6, True), + (2, 20, 6, True), + (10, 20, 6, False), + (20, 20, 6, True), + (6, 20, 6, False), + # degenerate: identical start hours means always day + (13, 9, 9, True), + ], +) +def test_is_daytime(hour, day_start, night_start, expected) -> None: + assert is_daytime(hour, day_start, night_start) is expected + + +@pytest.fixture +def db_session_factory(): + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + factory = create_session_factory(engine) + try: + yield factory + finally: + engine.dispose() + + +@pytest.mark.asyncio +async def test_interval_provider_uses_day_interval_during_the_day(db_session_factory) -> None: + provider = DayNightIntervalProvider( + db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0) + ) + assert provider() == 5 * 60 + + +@pytest.mark.asyncio +async def test_interval_provider_uses_night_interval_at_night(db_session_factory) -> None: + with db_session_factory() as session: + repo = SchedulerSettingsRepository(session) + row = repo.get_or_create(default_minutes=5) + repo.update(row, night_interval_minutes=30) + + provider = DayNightIntervalProvider( + db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 23, 0) + ) + assert provider() == 30 * 60 + + +@pytest.mark.asyncio +async def test_scheduler_uses_interval_provider_for_next_tick(db_session_factory) -> None: + with db_session_factory() as session: + repo = SchedulerSettingsRepository(session) + row = repo.get_or_create(default_minutes=5) + repo.update(row, day_interval_minutes=1, night_interval_minutes=1) + + provider = DayNightIntervalProvider( + db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0) + ) + fake = FakeSyncManager() + scheduler = SyncScheduler(fake, interval_seconds=999, interval_provider=provider) + + await scheduler.run_once() + + expected_seconds = 60 # day_interval_minutes=1 -> 60s, not the unrelated interval_seconds=999 fallback + delta = (scheduler.next_tick - scheduler.last_tick).total_seconds() + assert abs(delta - expected_seconds) < 1 + + +@pytest.mark.asyncio +async def test_scheduler_falls_back_when_interval_provider_raises() -> None: + def broken_provider(): + raise RuntimeError("db unavailable") + + fake = FakeSyncManager() + scheduler = SyncScheduler(fake, interval_seconds=42, interval_provider=broken_provider) + + await scheduler.run_once() + + delta = (scheduler.next_tick - scheduler.last_tick).total_seconds() + assert abs(delta - 42) < 1 diff --git a/tests/web/test_operations.py b/tests/web/test_operations.py index 1e3f527..3ea464b 100644 --- a/tests/web/test_operations.py +++ b/tests/web/test_operations.py @@ -1,6 +1,6 @@ from fastapi.testclient import TestClient -from app.db.repositories import SystemLogRepository +from app.db.repositories import SchedulerSettingsRepository, SystemLogRepository def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manager) -> None: @@ -89,3 +89,115 @@ def test_system_page_shows_recorded_log_entries(app, authenticated_client) -> No assert response.status_code == 200 assert "email_notification" in response.text assert "SMTP timeout" in response.text + + +def _extract_csrf(html: str) -> str: + marker = 'name="csrf_token" value="' + start = html.index(marker) + len(marker) + end = html.index('"', start) + return html[start:end] + + +def test_scheduler_settings_page_prefills_defaults(app, authenticated_client) -> None: + app.state.scheduler = _FakeScheduler() + + response = authenticated_client.get("/system") + + assert response.status_code == 200 + assert 'id="day_start_hour"' in response.text + assert 'value="6"' in response.text + assert 'value="22"' in response.text + + +def test_update_scheduler_settings_persists_and_takes_effect_next_tick(app, authenticated_client) -> None: + app.state.scheduler = _FakeScheduler() + page = authenticated_client.get("/system") + csrf = _extract_csrf(page.text) + + response = authenticated_client.post( + "/system/scheduler-settings", + data={ + "csrf_token": csrf, + "day_start_hour": "8", + "night_start_hour": "20", + "day_interval_minutes": "3", + "night_interval_minutes": "45", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"] == "/system" + + with app.state.session_factory() as session: + row = SchedulerSettingsRepository(session).get_or_create(default_minutes=5) + assert row.day_start_hour == 8 + assert row.night_start_hour == 20 + assert row.day_interval_minutes == 3 + assert row.night_interval_minutes == 45 + + +def test_update_scheduler_settings_rejects_out_of_range_hour(app, authenticated_client) -> None: + app.state.scheduler = _FakeScheduler() + page = authenticated_client.get("/system") + csrf = _extract_csrf(page.text) + + response = authenticated_client.post( + "/system/scheduler-settings", + data={ + "csrf_token": csrf, + "day_start_hour": "24", + "night_start_hour": "22", + "day_interval_minutes": "5", + "night_interval_minutes": "5", + }, + ) + assert response.status_code == 400 + + +def test_update_scheduler_settings_rejects_non_positive_interval(app, authenticated_client) -> None: + app.state.scheduler = _FakeScheduler() + page = authenticated_client.get("/system") + csrf = _extract_csrf(page.text) + + response = authenticated_client.post( + "/system/scheduler-settings", + data={ + "csrf_token": csrf, + "day_start_hour": "6", + "night_start_hour": "22", + "day_interval_minutes": "0", + "night_interval_minutes": "5", + }, + ) + assert response.status_code == 400 + + +def test_update_scheduler_settings_requires_admin(client: TestClient) -> None: + response = client.post( + "/system/scheduler-settings", + data={ + "csrf_token": "whatever", + "day_start_hour": "6", + "night_start_hour": "22", + "day_interval_minutes": "5", + "night_interval_minutes": "5", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"] == "/login" + + +def test_update_scheduler_settings_rejects_invalid_csrf(app, authenticated_client) -> None: + app.state.scheduler = _FakeScheduler() + response = authenticated_client.post( + "/system/scheduler-settings", + data={ + "csrf_token": "invalid-token", + "day_start_hour": "6", + "night_start_hour": "22", + "day_interval_minutes": "5", + "night_interval_minutes": "5", + }, + ) + assert response.status_code == 403