intervall
This commit is contained in:
@@ -89,6 +89,20 @@ class SystemLogEntry(Base):
|
|||||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("sync_users.id", ondelete="SET NULL"))
|
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):
|
class SyncRun(Base):
|
||||||
__tablename__ = "sync_runs"
|
__tablename__ = "sync_runs"
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,18 @@ from sqlalchemy import and_, or_, select
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
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)
|
@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:
|
class SyncRunRepository:
|
||||||
def __init__(self, session: Session) -> None:
|
def __init__(self, session: Session) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|||||||
11
app/main.py
11
app/main.py
@@ -13,7 +13,7 @@ from app.mywhoosh.client import MyWhooshClient
|
|||||||
from app.notifications.emailer import EmailNotifier
|
from app.notifications.emailer import EmailNotifier
|
||||||
from app.security.credentials import CredentialCipher
|
from app.security.credentials import CredentialCipher
|
||||||
from app.sync.manager import SyncManager
|
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.account import router as account_router
|
||||||
from app.web.operations import router as operations_router
|
from app.web.operations import router as operations_router
|
||||||
from app.web.routes import router as web_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
|
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
|
app.state.scheduler = scheduler
|
||||||
await scheduler.start()
|
await scheduler.start()
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,80 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from app.db.repositories import SchedulerSettingsRepository
|
||||||
from app.sync.manager import SyncAlreadyRunning
|
from app.sync.manager import SyncAlreadyRunning
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
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.manager = manager
|
||||||
self.interval_seconds = interval_seconds
|
self.interval_seconds = interval_seconds
|
||||||
|
self.interval_provider = interval_provider
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
self._stop = asyncio.Event()
|
self._stop = asyncio.Event()
|
||||||
self.last_tick = None
|
self.last_tick = None
|
||||||
self.next_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)
|
self.last_tick = datetime.now(timezone.utc)
|
||||||
try:
|
try:
|
||||||
results = await self.manager.sync_all_enabled()
|
results = await self.manager.sync_all_enabled()
|
||||||
@@ -36,13 +94,14 @@ class SyncScheduler:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("sync_all_enabled failed during scheduled tick")
|
logger.exception("sync_all_enabled failed during scheduled tick")
|
||||||
finally:
|
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:
|
async def _run(self) -> None:
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
await self.run_once()
|
interval = self._current_interval()
|
||||||
|
await self.run_once(interval)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds)
|
await asyncio.wait_for(self._stop.wait(), timeout=interval)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from app.auth.admin import require_admin
|
from app.auth.admin import require_admin
|
||||||
from app.auth.csrf import ensure_csrf_token, validate_csrf
|
from app.auth.csrf import ensure_csrf_token, validate_csrf
|
||||||
from app.db.models import Activity
|
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.sync.manager import SyncAlreadyRunning
|
||||||
from app.web.routes import templates
|
from app.web.routes import templates
|
||||||
|
|
||||||
@@ -104,13 +104,45 @@ def system_page(request: Request):
|
|||||||
user_count = len(UserRepository(session).list_all())
|
user_count = len(UserRepository(session).list_all())
|
||||||
activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0
|
activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0
|
||||||
log_entries = SystemLogRepository(session).list_recent(limit=50)
|
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", {
|
return templates.TemplateResponse(request, "system.html", {
|
||||||
"csrf_token": ensure_csrf_token(request),
|
"csrf_token": ensure_csrf_token(request),
|
||||||
"app_version": APP_VERSION,
|
"app_version": APP_VERSION,
|
||||||
"sync_interval_minutes": settings.sync_interval_minutes,
|
|
||||||
"last_tick": scheduler.last_tick,
|
"last_tick": scheduler.last_tick,
|
||||||
"next_tick": scheduler.next_tick,
|
"next_tick": scheduler.next_tick,
|
||||||
"user_count": user_count,
|
"user_count": user_count,
|
||||||
"activity_count": activity_count,
|
"activity_count": activity_count,
|
||||||
"log_entries": log_entries,
|
"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)
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
<dt>Application version</dt>
|
<dt>Application version</dt>
|
||||||
<dd>{{ app_version }}</dd>
|
<dd>{{ app_version }}</dd>
|
||||||
|
|
||||||
<dt>Sync interval (minutes)</dt>
|
|
||||||
<dd>{{ sync_interval_minutes }}</dd>
|
|
||||||
|
|
||||||
<dt>Last scheduler tick</dt>
|
<dt>Last scheduler tick</dt>
|
||||||
<dd>{{ last_tick or "-" }}</dd>
|
<dd>{{ last_tick or "-" }}</dd>
|
||||||
|
|
||||||
@@ -32,6 +29,31 @@
|
|||||||
<button type="submit">Sync all now</button>
|
<button type="submit">Sync all now</button>
|
||||||
</form>
|
</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>
|
<h2>System log</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{% if log_entries %}
|
{% if log_entries %}
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ from sqlalchemy.pool import StaticPool
|
|||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.db.models import Activity, Base, HealthState
|
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
|
from app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +50,11 @@ def sync_run_repository(db_session: Session) -> SyncRunRepository:
|
|||||||
return SyncRunRepository(db_session)
|
return SyncRunRepository(db_session)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scheduler_settings_repository(db_session: Session) -> SchedulerSettingsRepository:
|
||||||
|
return SchedulerSettingsRepository(db_session)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def system_log_repository(db_session: Session) -> SystemLogRepository:
|
def system_log_repository(db_session: Session) -> SystemLogRepository:
|
||||||
return SystemLogRepository(db_session)
|
return SystemLogRepository(db_session)
|
||||||
|
|||||||
@@ -93,3 +93,39 @@ def test_system_log_respects_limit(system_log_repository) -> None:
|
|||||||
entries = system_log_repository.list_recent(limit=2)
|
entries = system_log_repository.list_recent(limit=2)
|
||||||
|
|
||||||
assert len(entries) == 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
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import pytest
|
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:
|
class FakeSyncManager:
|
||||||
@@ -40,3 +46,91 @@ async def test_scheduler_stop_cancels_the_loop() -> None:
|
|||||||
calls_after_stop = fake.calls
|
calls_after_stop = fake.calls
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
assert fake.calls == calls_after_stop
|
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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi.testclient import TestClient
|
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:
|
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 response.status_code == 200
|
||||||
assert "email_notification" in response.text
|
assert "email_notification" in response.text
|
||||||
assert "SMTP timeout" 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
|
||||||
|
|||||||
Reference in New Issue
Block a user