intervall

This commit is contained in:
Bastian Wagner
2026-08-15 21:44:13 +02:00
parent f7b04337ce
commit ff5dab6f0d
10 changed files with 438 additions and 17 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -10,9 +10,6 @@
<dt>Application version</dt>
<dd>{{ app_version }}</dd>
<dt>Sync interval (minutes)</dt>
<dd>{{ sync_interval_minutes }}</dd>
<dt>Last scheduler tick</dt>
<dd>{{ last_tick or "-" }}</dd>
@@ -32,6 +29,31 @@
<button type="submit">Sync all now</button>
</form>
<h2>Sync scheduling</h2>
<div class="card">
<form method="post" action="/system/scheduler-settings" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="day_start_hour">Day starts at (hour, 0-23)</label>
<input type="number" id="day_start_hour" name="day_start_hour" min="0" max="23"
value="{{ scheduler_settings.day_start_hour }}" required>
<label for="day_interval_minutes">Sync interval during the day (minutes)</label>
<input type="number" id="day_interval_minutes" name="day_interval_minutes" min="1"
value="{{ scheduler_settings.day_interval_minutes }}" required>
<label for="night_start_hour">Night starts at (hour, 0-23)</label>
<input type="number" id="night_start_hour" name="night_start_hour" min="0" max="23"
value="{{ scheduler_settings.night_start_hour }}" required>
<label for="night_interval_minutes">Sync interval during the night (minutes)</label>
<input type="number" id="night_interval_minutes" name="night_interval_minutes" min="1"
value="{{ scheduler_settings.night_interval_minutes }}" required>
<button type="submit">Save</button>
</form>
</div>
<h2>System log</h2>
<div class="card">
{% if log_entries %}

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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