mail notification
This commit is contained in:
@@ -7,3 +7,12 @@ DATA_DIR=/data
|
|||||||
DATABASE_URL=sqlite:////data/app.db
|
DATABASE_URL=sqlite:////data/app.db
|
||||||
# Set to true only when TLS terminates in front of this service.
|
# Set to true only when TLS terminates in front of this service.
|
||||||
SESSION_HTTPS_ONLY=false
|
SESSION_HTTPS_ONLY=false
|
||||||
|
# Optional: SMTP settings for "email me when action is required" user
|
||||||
|
# notifications. Leave SMTP_HOST unset to disable sending (notifications are
|
||||||
|
# silently skipped rather than failing a sync run).
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM_ADDRESS=
|
||||||
|
SMTP_USE_TLS=true
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ class Settings(BaseSettings):
|
|||||||
database_url: str | None = Field(default=None, alias="DATABASE_URL")
|
database_url: str | None = Field(default=None, alias="DATABASE_URL")
|
||||||
sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES")
|
sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES")
|
||||||
session_https_only: bool = Field(default=False, alias="SESSION_HTTPS_ONLY")
|
session_https_only: bool = Field(default=False, alias="SESSION_HTTPS_ONLY")
|
||||||
|
smtp_host: str | None = Field(default=None, alias="SMTP_HOST")
|
||||||
|
smtp_port: int = Field(default=587, alias="SMTP_PORT")
|
||||||
|
smtp_username: str | None = Field(default=None, alias="SMTP_USERNAME")
|
||||||
|
smtp_password: str | None = Field(default=None, alias="SMTP_PASSWORD")
|
||||||
|
smtp_from_address: str | None = Field(default=None, alias="SMTP_FROM_ADDRESS")
|
||||||
|
smtp_use_tls: bool = Field(default=True, alias="SMTP_USE_TLS")
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def derive_paths(self) -> "Settings":
|
def derive_paths(self) -> "Settings":
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ class SyncUser(Base):
|
|||||||
mywhoosh_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
mywhoosh_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
garmin_email_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
garmin_email_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
garmin_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
garmin_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
notify_email_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
notification_email: Mapped[str | None] = mapped_column(String(255))
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, inspect, text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from app.db.models import Base
|
from app.db.models import Base
|
||||||
|
|
||||||
|
# Columns added to existing tables after their initial release. create_all()
|
||||||
|
# only creates missing tables, never adds columns to tables that already
|
||||||
|
# exist, so a column added to a model here must also be listed below or an
|
||||||
|
# already-deployed database will never receive it and the app will crash
|
||||||
|
# reading/writing that column.
|
||||||
|
_ADDITIVE_COLUMNS: dict[str, list[tuple[str, str]]] = {
|
||||||
|
"sync_users": [
|
||||||
|
("notify_email_enabled", "BOOLEAN NOT NULL DEFAULT 0"),
|
||||||
|
("notification_email", "VARCHAR(255)"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_db_engine(database_url: str) -> Engine:
|
def create_db_engine(database_url: str) -> Engine:
|
||||||
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
||||||
@@ -16,3 +28,24 @@ def create_session_factory(engine: Engine) -> sessionmaker[Session]:
|
|||||||
|
|
||||||
def initialize_schema(engine: Engine) -> None:
|
def initialize_schema(engine: Engine) -> None:
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
|
_apply_additive_migrations(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_additive_migrations(engine: Engine) -> None:
|
||||||
|
if engine.dialect.name != "sqlite":
|
||||||
|
# ALTER TABLE ... ADD COLUMN syntax/type names below are only
|
||||||
|
# verified against sqlite, the only backend this app is deployed
|
||||||
|
# against; a fresh create_all() on another backend already has every
|
||||||
|
# current column, so skipping here only matters for a pre-existing
|
||||||
|
# non-sqlite database, which does not exist in practice.
|
||||||
|
return
|
||||||
|
inspector = inspect(engine)
|
||||||
|
existing_tables = set(inspector.get_table_names())
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for table, columns in _ADDITIVE_COLUMNS.items():
|
||||||
|
if table not in existing_tables:
|
||||||
|
continue
|
||||||
|
existing_columns = {col["name"] for col in inspector.get_columns(table)}
|
||||||
|
for name, ddl_type in columns:
|
||||||
|
if name not in existing_columns:
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {ddl_type}"))
|
||||||
|
|||||||
11
app/main.py
11
app/main.py
@@ -10,6 +10,7 @@ from app.db.session import create_db_engine, create_session_factory, initialize_
|
|||||||
from app.fit.rewriter import convert_fit_device
|
from app.fit.rewriter import convert_fit_device
|
||||||
from app.garmin.uploader import GarminUploader
|
from app.garmin.uploader import GarminUploader
|
||||||
from app.mywhoosh.client import MyWhooshClient
|
from app.mywhoosh.client import MyWhooshClient
|
||||||
|
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 SyncScheduler
|
||||||
@@ -33,6 +34,15 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
def garmin_factory(email, password, tokenstore):
|
def garmin_factory(email, password, tokenstore):
|
||||||
return GarminUploader(email=email, password=password, tokenstore=tokenstore)
|
return GarminUploader(email=email, password=password, tokenstore=tokenstore)
|
||||||
|
|
||||||
|
notifier = EmailNotifier(
|
||||||
|
host=resolved.smtp_host,
|
||||||
|
port=resolved.smtp_port,
|
||||||
|
username=resolved.smtp_username,
|
||||||
|
password=resolved.smtp_password,
|
||||||
|
from_address=resolved.smtp_from_address,
|
||||||
|
use_tls=resolved.smtp_use_tls,
|
||||||
|
)
|
||||||
|
|
||||||
sync_manager = SyncManager(
|
sync_manager = SyncManager(
|
||||||
session_factory=app.state.session_factory,
|
session_factory=app.state.session_factory,
|
||||||
credential_cipher=cipher,
|
credential_cipher=cipher,
|
||||||
@@ -40,6 +50,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
mywhoosh_factory=mywhoosh_factory,
|
mywhoosh_factory=mywhoosh_factory,
|
||||||
garmin_factory=garmin_factory,
|
garmin_factory=garmin_factory,
|
||||||
fit_converter=convert_fit_device,
|
fit_converter=convert_fit_device,
|
||||||
|
notifier=notifier,
|
||||||
)
|
)
|
||||||
app.state.sync_manager = sync_manager
|
app.state.sync_manager = sync_manager
|
||||||
|
|
||||||
|
|||||||
0
app/notifications/__init__.py
Normal file
0
app/notifications/__init__.py
Normal file
42
app/notifications/emailer.py
Normal file
42
app/notifications/emailer.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotifier:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
host: str | None,
|
||||||
|
port: int,
|
||||||
|
username: str | None,
|
||||||
|
password: str | None,
|
||||||
|
from_address: str | None,
|
||||||
|
use_tls: bool,
|
||||||
|
) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.from_address = from_address
|
||||||
|
self.use_tls = use_tls
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self.host and self.from_address)
|
||||||
|
|
||||||
|
def send(self, *, to_address: str, subject: str, body: str) -> None:
|
||||||
|
if not self.configured:
|
||||||
|
return
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Subject"] = subject
|
||||||
|
message["From"] = self.from_address
|
||||||
|
message["To"] = to_address
|
||||||
|
message.set_content(body)
|
||||||
|
with smtplib.SMTP(self.host, self.port, timeout=10) as smtp:
|
||||||
|
if self.use_tls:
|
||||||
|
smtp.starttls()
|
||||||
|
if self.username and self.password:
|
||||||
|
smtp.login(self.username, self.password)
|
||||||
|
smtp.send_message(message)
|
||||||
@@ -64,6 +64,7 @@ class SyncManager:
|
|||||||
mywhoosh_factory: Callable[[MyWhooshTokenStore], Any],
|
mywhoosh_factory: Callable[[MyWhooshTokenStore], Any],
|
||||||
garmin_factory: Callable[[str, str, Path], Any],
|
garmin_factory: Callable[[str, str, Path], Any],
|
||||||
fit_converter: Callable[[Path, Path], Any],
|
fit_converter: Callable[[Path, Path], Any],
|
||||||
|
notifier: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_factory = session_factory
|
self.session_factory = session_factory
|
||||||
self.credential_cipher = credential_cipher
|
self.credential_cipher = credential_cipher
|
||||||
@@ -71,9 +72,24 @@ class SyncManager:
|
|||||||
self.mywhoosh_factory = mywhoosh_factory
|
self.mywhoosh_factory = mywhoosh_factory
|
||||||
self.garmin_factory = garmin_factory
|
self.garmin_factory = garmin_factory
|
||||||
self.fit_converter = fit_converter
|
self.fit_converter = fit_converter
|
||||||
|
self.notifier = notifier
|
||||||
self._locks: dict[int, asyncio.Lock] = {}
|
self._locks: dict[int, asyncio.Lock] = {}
|
||||||
self._locks_guard = asyncio.Lock()
|
self._locks_guard = asyncio.Lock()
|
||||||
|
|
||||||
|
def _notify_action_required(self, user: Any, message: str | None) -> None:
|
||||||
|
if self.notifier is None or not user.notify_email_enabled or not user.notification_email:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.notifier.send(
|
||||||
|
to_address=user.notification_email,
|
||||||
|
subject=f"MyWhoosh-Garmin Sync: action required for {user.name}",
|
||||||
|
body=message or "Your sync requires attention. Check the dashboard for details.",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"sync_user: failed to send action-required notification for user %s", user.id, exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
async def _lock_for(self, user_id: int) -> asyncio.Lock:
|
async def _lock_for(self, user_id: int) -> asyncio.Lock:
|
||||||
async with self._locks_guard:
|
async with self._locks_guard:
|
||||||
return self._locks.setdefault(user_id, asyncio.Lock())
|
return self._locks.setdefault(user_id, asyncio.Lock())
|
||||||
@@ -102,6 +118,12 @@ class SyncManager:
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise ValueError(f"user {user_id} not found")
|
raise ValueError(f"user {user_id} not found")
|
||||||
|
|
||||||
|
# Captured before this run mutates action_reason, so the
|
||||||
|
# end-of-run notification below only fires on a transition into
|
||||||
|
# (or between) action-required states -- never repeatedly for a
|
||||||
|
# cause that's already been reported and still unresolved.
|
||||||
|
previous_action_reason = user.action_reason
|
||||||
|
|
||||||
sync_run_repo = SyncRunRepository(session)
|
sync_run_repo = SyncRunRepository(session)
|
||||||
run = sync_run_repo.start(user_id)
|
run = sync_run_repo.start(user_id)
|
||||||
|
|
||||||
@@ -366,6 +388,8 @@ class SyncManager:
|
|||||||
summary_error=summary_error,
|
summary_error=summary_error,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
if user.action_reason is not None and user.action_reason != previous_action_reason:
|
||||||
|
self._notify_action_required(user, summary_error)
|
||||||
return SyncOutcome(
|
return SyncOutcome(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
status=status.value,
|
status=status.value,
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ class UserFormData:
|
|||||||
garmin_email: str
|
garmin_email: str
|
||||||
garmin_password: str
|
garmin_password: str
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
notify_email_enabled: bool = False
|
||||||
|
notification_email: str = ""
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ def create_user(
|
|||||||
garmin_email: str = Form(""),
|
garmin_email: str = Form(""),
|
||||||
garmin_password: str = Form(""),
|
garmin_password: str = Form(""),
|
||||||
enabled: str | None = Form(None),
|
enabled: str | None = Form(None),
|
||||||
|
notify_email_enabled: str | None = Form(None),
|
||||||
|
notification_email: str = Form(""),
|
||||||
):
|
):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
validate_csrf(request, csrf_token)
|
validate_csrf(request, csrf_token)
|
||||||
@@ -110,7 +112,11 @@ def create_user(
|
|||||||
garmin_email=garmin_email,
|
garmin_email=garmin_email,
|
||||||
garmin_password=garmin_password,
|
garmin_password=garmin_password,
|
||||||
enabled=enabled is not None,
|
enabled=enabled is not None,
|
||||||
|
notify_email_enabled=notify_email_enabled is not None,
|
||||||
|
notification_email=notification_email,
|
||||||
)
|
)
|
||||||
|
if form.notify_email_enabled:
|
||||||
|
_require_non_empty(form.notification_email, "notification_email")
|
||||||
cipher = _cipher(request)
|
cipher = _cipher(request)
|
||||||
with request.app.state.session_factory() as session:
|
with request.app.state.session_factory() as session:
|
||||||
repository = UserRepository(session)
|
repository = UserRepository(session)
|
||||||
@@ -121,6 +127,8 @@ def create_user(
|
|||||||
mywhoosh_password_enc=cipher.encrypt(form.mywhoosh_password),
|
mywhoosh_password_enc=cipher.encrypt(form.mywhoosh_password),
|
||||||
garmin_email_enc=cipher.encrypt(form.garmin_email.strip()),
|
garmin_email_enc=cipher.encrypt(form.garmin_email.strip()),
|
||||||
garmin_password_enc=cipher.encrypt(form.garmin_password),
|
garmin_password_enc=cipher.encrypt(form.garmin_password),
|
||||||
|
notify_email_enabled=form.notify_email_enabled,
|
||||||
|
notification_email=form.notification_email.strip() or None,
|
||||||
)
|
)
|
||||||
user_id = user.id
|
user_id = user.id
|
||||||
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
||||||
@@ -175,6 +183,8 @@ def update_user(
|
|||||||
garmin_email: str = Form(""),
|
garmin_email: str = Form(""),
|
||||||
garmin_password: str = Form(""),
|
garmin_password: str = Form(""),
|
||||||
enabled: str | None = Form(None),
|
enabled: str | None = Form(None),
|
||||||
|
notify_email_enabled: str | None = Form(None),
|
||||||
|
notification_email: str = Form(""),
|
||||||
):
|
):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
validate_csrf(request, csrf_token)
|
validate_csrf(request, csrf_token)
|
||||||
@@ -187,7 +197,11 @@ def update_user(
|
|||||||
garmin_email=garmin_email,
|
garmin_email=garmin_email,
|
||||||
garmin_password=garmin_password,
|
garmin_password=garmin_password,
|
||||||
enabled=enabled is not None,
|
enabled=enabled is not None,
|
||||||
|
notify_email_enabled=notify_email_enabled is not None,
|
||||||
|
notification_email=notification_email,
|
||||||
)
|
)
|
||||||
|
if form.notify_email_enabled:
|
||||||
|
_require_non_empty(form.notification_email, "notification_email")
|
||||||
cipher = _cipher(request)
|
cipher = _cipher(request)
|
||||||
with request.app.state.session_factory() as session:
|
with request.app.state.session_factory() as session:
|
||||||
repository = UserRepository(session)
|
repository = UserRepository(session)
|
||||||
@@ -197,6 +211,8 @@ def update_user(
|
|||||||
"enabled": form.enabled,
|
"enabled": form.enabled,
|
||||||
"mywhoosh_email_enc": cipher.encrypt(form.mywhoosh_email.strip()),
|
"mywhoosh_email_enc": cipher.encrypt(form.mywhoosh_email.strip()),
|
||||||
"garmin_email_enc": cipher.encrypt(form.garmin_email.strip()),
|
"garmin_email_enc": cipher.encrypt(form.garmin_email.strip()),
|
||||||
|
"notify_email_enabled": form.notify_email_enabled,
|
||||||
|
"notification_email": form.notification_email.strip() or None,
|
||||||
}
|
}
|
||||||
if form.mywhoosh_password:
|
if form.mywhoosh_password:
|
||||||
values["mywhoosh_password_enc"] = cipher.encrypt(form.mywhoosh_password)
|
values["mywhoosh_password_enc"] = cipher.encrypt(form.mywhoosh_password)
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
Enabled
|
Enabled
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label for="notify_email_enabled">
|
||||||
|
<input type="checkbox" id="notify_email_enabled" name="notify_email_enabled"
|
||||||
|
{% if user and user.notify_email_enabled %}checked{% endif %}>
|
||||||
|
Email me when this account needs attention
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="notification_email">Notification email</label>
|
||||||
|
<input type="email" id="notification_email" name="notification_email"
|
||||||
|
value="{{ user.notification_email if user and user.notification_email else '' }}">
|
||||||
|
|
||||||
<button type="submit">{% if user %}Save{% else %}Create{% endif %}</button>
|
<button type="submit">{% if user %}Save{% else %}Create{% endif %}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
109
tests/db/test_session.py
Normal file
109
tests/db/test_session.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.db.models import Base
|
||||||
|
from app.db.session import initialize_schema
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialize_schema_creates_fresh_database() -> None:
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
try:
|
||||||
|
initialize_schema(engine)
|
||||||
|
columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
|
||||||
|
assert "notify_email_enabled" in columns
|
||||||
|
assert "notification_email" in columns
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialize_schema_adds_missing_columns_without_dropping_existing_rows() -> None:
|
||||||
|
"""Regression test: a database created before notify_email_enabled/
|
||||||
|
notification_email existed must gain those columns in place, keeping
|
||||||
|
every already-stored user row intact -- create_all() alone would not add
|
||||||
|
columns to a table that already exists."""
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
try:
|
||||||
|
# Simulate the pre-existing production schema by creating every
|
||||||
|
# table via the current models, then dropping the two new columns
|
||||||
|
# back off sync_users the only way sqlite allows: rebuild the table.
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE sync_users RENAME TO sync_users_old"))
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE sync_users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL,
|
||||||
|
health_state VARCHAR NOT NULL,
|
||||||
|
mywhoosh_state VARCHAR(32) NOT NULL,
|
||||||
|
garmin_state VARCHAR(32) NOT NULL,
|
||||||
|
action_reason TEXT,
|
||||||
|
mywhoosh_email_enc TEXT NOT NULL,
|
||||||
|
mywhoosh_password_enc TEXT NOT NULL,
|
||||||
|
garmin_email_enc TEXT NOT NULL,
|
||||||
|
garmin_password_enc TEXT NOT NULL,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO sync_users (
|
||||||
|
id, name, enabled, health_state, mywhoosh_state, garmin_state,
|
||||||
|
action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
|
||||||
|
garmin_email_enc, garmin_password_enc, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT id, name, enabled, health_state, mywhoosh_state, garmin_state,
|
||||||
|
action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
|
||||||
|
garmin_email_enc, garmin_password_enc, created_at, updated_at
|
||||||
|
FROM sync_users_old
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(text("DROP TABLE sync_users_old"))
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO sync_users (
|
||||||
|
id, name, enabled, health_state, mywhoosh_state, garmin_state,
|
||||||
|
mywhoosh_email_enc, mywhoosh_password_enc, garmin_email_enc, garmin_password_enc
|
||||||
|
) VALUES (
|
||||||
|
1, 'Existing User', 1, 'healthy', 'connected', 'connected',
|
||||||
|
'enc-mw-email', 'enc-mw-pass', 'enc-garmin-email', 'enc-garmin-pass'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
columns_before = {col["name"] for col in inspect(engine).get_columns("sync_users")}
|
||||||
|
assert "notify_email_enabled" not in columns_before
|
||||||
|
|
||||||
|
initialize_schema(engine)
|
||||||
|
|
||||||
|
columns_after = {col["name"] for col in inspect(engine).get_columns("sync_users")}
|
||||||
|
assert "notify_email_enabled" in columns_after
|
||||||
|
assert "notification_email" in columns_after
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
row = conn.execute(text("SELECT name, notify_email_enabled, notification_email FROM sync_users")).one()
|
||||||
|
assert row.name == "Existing User"
|
||||||
|
assert row.notify_email_enabled == 0
|
||||||
|
assert row.notification_email is None
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialize_schema_is_idempotent() -> None:
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
try:
|
||||||
|
initialize_schema(engine)
|
||||||
|
initialize_schema(engine)
|
||||||
|
columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
|
||||||
|
assert "notify_email_enabled" in columns
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
69
tests/notifications/test_emailer.py
Normal file
69
tests/notifications/test_emailer.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from app.notifications.emailer import EmailNotifier
|
||||||
|
|
||||||
|
|
||||||
|
def _notifier(**overrides) -> EmailNotifier:
|
||||||
|
defaults = dict(
|
||||||
|
host="smtp.example.com",
|
||||||
|
port=587,
|
||||||
|
username="user@example.com",
|
||||||
|
password="secret",
|
||||||
|
from_address="sync@example.com",
|
||||||
|
use_tls=True,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return EmailNotifier(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfigured_notifier_is_not_configured() -> None:
|
||||||
|
notifier = _notifier(host=None)
|
||||||
|
assert notifier.configured is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured_notifier_is_configured() -> None:
|
||||||
|
assert _notifier().configured is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_skips_silently_when_not_configured() -> None:
|
||||||
|
notifier = _notifier(host=None)
|
||||||
|
with patch("app.notifications.emailer.smtplib.SMTP") as smtp_cls:
|
||||||
|
notifier.send(to_address="user@example.com", subject="s", body="b")
|
||||||
|
smtp_cls.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_uses_starttls_and_login_when_configured() -> None:
|
||||||
|
notifier = _notifier()
|
||||||
|
smtp_instance = MagicMock()
|
||||||
|
smtp_instance.__enter__.return_value = smtp_instance
|
||||||
|
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance) as smtp_cls:
|
||||||
|
notifier.send(to_address="rider@example.com", subject="Action required", body="Check the dashboard")
|
||||||
|
|
||||||
|
smtp_cls.assert_called_once_with("smtp.example.com", 587, timeout=10)
|
||||||
|
smtp_instance.starttls.assert_called_once()
|
||||||
|
smtp_instance.login.assert_called_once_with("user@example.com", "secret")
|
||||||
|
assert smtp_instance.send_message.call_count == 1
|
||||||
|
sent_message = smtp_instance.send_message.call_args[0][0]
|
||||||
|
assert sent_message["To"] == "rider@example.com"
|
||||||
|
assert sent_message["From"] == "sync@example.com"
|
||||||
|
assert sent_message["Subject"] == "Action required"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_skips_login_without_credentials() -> None:
|
||||||
|
notifier = _notifier(username=None, password=None)
|
||||||
|
smtp_instance = MagicMock()
|
||||||
|
smtp_instance.__enter__.return_value = smtp_instance
|
||||||
|
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
|
||||||
|
notifier.send(to_address="rider@example.com", subject="s", body="b")
|
||||||
|
|
||||||
|
smtp_instance.login.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_skips_starttls_when_disabled() -> None:
|
||||||
|
notifier = _notifier(use_tls=False)
|
||||||
|
smtp_instance = MagicMock()
|
||||||
|
smtp_instance.__enter__.return_value = smtp_instance
|
||||||
|
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
|
||||||
|
notifier.send(to_address="rider@example.com", subject="s", body="b")
|
||||||
|
|
||||||
|
smtp_instance.starttls.assert_not_called()
|
||||||
@@ -28,3 +28,11 @@ class FakeGarminUploader:
|
|||||||
if self.error is not None:
|
if self.error is not None:
|
||||||
raise self.error
|
raise self.error
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
|
class FakeNotifier:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sent: list[dict] = []
|
||||||
|
|
||||||
|
def send(self, *, to_address: str, subject: str, body: str) -> None:
|
||||||
|
self.sent.append({"to_address": to_address, "subject": subject, "body": body})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.mywhoosh.client import MyWhooshDeviceConflictError
|
|||||||
from app.mywhoosh.models import MyWhooshActivity
|
from app.mywhoosh.models import MyWhooshActivity
|
||||||
from app.sync.manager import SyncManager
|
from app.sync.manager import SyncManager
|
||||||
from tests.sync.conftest import FakeFitConverter, _create_user
|
from tests.sync.conftest import FakeFitConverter, _create_user
|
||||||
from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient
|
from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient, FakeNotifier
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -229,6 +229,82 @@ async def test_device_conflict_sets_distinct_action_reason(seeded_user: SyncUser
|
|||||||
assert reloaded.health_state == HealthState.ACTION_REQUIRED
|
assert reloaded.health_state == HealthState.ACTION_REQUIRED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notifies_on_new_action_required_when_opted_in(session_factory, cipher, settings) -> None:
|
||||||
|
with session_factory() as session:
|
||||||
|
user = _create_user(session, cipher)
|
||||||
|
user.notify_email_enabled = True
|
||||||
|
user.notification_email = "alerts@example.com"
|
||||||
|
session.commit()
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
|
notifier = FakeNotifier()
|
||||||
|
manager = SyncManager(
|
||||||
|
session_factory=session_factory,
|
||||||
|
credential_cipher=cipher,
|
||||||
|
settings=settings,
|
||||||
|
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
|
||||||
|
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
|
||||||
|
fit_converter=FakeFitConverter(),
|
||||||
|
notifier=notifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
await manager.sync_user(user_id)
|
||||||
|
|
||||||
|
assert len(notifier.sent) == 1
|
||||||
|
assert notifier.sent[0]["to_address"] == "alerts@example.com"
|
||||||
|
assert "another device" in notifier.sent[0]["body"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_does_not_notify_when_not_opted_in(session_factory, cipher, settings) -> None:
|
||||||
|
user_id = None
|
||||||
|
with session_factory() as session:
|
||||||
|
user = _create_user(session, cipher)
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
|
notifier = FakeNotifier()
|
||||||
|
manager = SyncManager(
|
||||||
|
session_factory=session_factory,
|
||||||
|
credential_cipher=cipher,
|
||||||
|
settings=settings,
|
||||||
|
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
|
||||||
|
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
|
||||||
|
fit_converter=FakeFitConverter(),
|
||||||
|
notifier=notifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
await manager.sync_user(user_id)
|
||||||
|
|
||||||
|
assert notifier.sent == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_does_not_renotify_for_unresolved_unchanged_reason(session_factory, cipher, settings) -> None:
|
||||||
|
with session_factory() as session:
|
||||||
|
user = _create_user(session, cipher)
|
||||||
|
user.notify_email_enabled = True
|
||||||
|
user.notification_email = "alerts@example.com"
|
||||||
|
session.commit()
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
|
notifier = FakeNotifier()
|
||||||
|
manager = SyncManager(
|
||||||
|
session_factory=session_factory,
|
||||||
|
credential_cipher=cipher,
|
||||||
|
settings=settings,
|
||||||
|
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
|
||||||
|
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
|
||||||
|
fit_converter=FakeFitConverter(),
|
||||||
|
notifier=notifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
await manager.sync_user(user_id)
|
||||||
|
await manager.sync_user(user_id)
|
||||||
|
|
||||||
|
assert len(notifier.sent) == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager(
|
async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager(
|
||||||
session_factory, cipher, settings, seeded_user: SyncUser
|
session_factory, cipher, settings, seeded_user: SyncUser
|
||||||
|
|||||||
@@ -366,6 +366,106 @@ def test_update_user_rejects_empty_mywhoosh_email(client: TestClient) -> None:
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_without_notifications_requires_no_email(client: TestClient) -> None:
|
||||||
|
login(client)
|
||||||
|
page = client.get("/users/new")
|
||||||
|
csrf = extract_csrf(page.text)
|
||||||
|
response = client.post(
|
||||||
|
"/users",
|
||||||
|
data={"csrf_token": csrf, **_create_payload()},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
|
||||||
|
login(client)
|
||||||
|
page = client.get("/users/new")
|
||||||
|
csrf = extract_csrf(page.text)
|
||||||
|
response = client.post(
|
||||||
|
"/users",
|
||||||
|
data={"csrf_token": csrf, **_create_payload(notify_email_enabled="on")},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_persists_notification_preferences(client: TestClient) -> None:
|
||||||
|
login(client)
|
||||||
|
page = client.get("/users/new")
|
||||||
|
csrf = extract_csrf(page.text)
|
||||||
|
response = client.post(
|
||||||
|
"/users",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
**_create_payload(notify_email_enabled="on", notification_email="alerts@example.com"),
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
user_id = int(response.headers["location"].rsplit("/", 1)[-1])
|
||||||
|
|
||||||
|
with client.app.state.session_factory() as session:
|
||||||
|
user = UserRepository(session).get(user_id)
|
||||||
|
assert user is not None
|
||||||
|
assert user.notify_email_enabled is True
|
||||||
|
assert user.notification_email == "alerts@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_can_enable_notifications_without_reentering_passwords(client: TestClient) -> None:
|
||||||
|
login(client)
|
||||||
|
user_id = create_user_via_http(client)
|
||||||
|
|
||||||
|
edit_page = client.get(f"/users/{user_id}/edit")
|
||||||
|
csrf = extract_csrf(edit_page.text)
|
||||||
|
response = client.post(
|
||||||
|
f"/users/{user_id}",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"name": "Max",
|
||||||
|
"mywhoosh_email": "max@example.com",
|
||||||
|
"mywhoosh_password": "",
|
||||||
|
"garmin_email": "max-garmin@example.com",
|
||||||
|
"garmin_password": "",
|
||||||
|
"enabled": "on",
|
||||||
|
"notify_email_enabled": "on",
|
||||||
|
"notification_email": "alerts@example.com",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with client.app.state.session_factory() as session:
|
||||||
|
user = UserRepository(session).get(user_id)
|
||||||
|
assert user is not None
|
||||||
|
assert user.notify_email_enabled is True
|
||||||
|
assert user.notification_email == "alerts@example.com"
|
||||||
|
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
|
||||||
|
assert cipher.decrypt(user.mywhoosh_password_enc) == "mw-secret"
|
||||||
|
assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
|
||||||
|
login(client)
|
||||||
|
user_id = create_user_via_http(client)
|
||||||
|
edit_page = client.get(f"/users/{user_id}/edit")
|
||||||
|
csrf = extract_csrf(edit_page.text)
|
||||||
|
response = client.post(
|
||||||
|
f"/users/{user_id}",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf,
|
||||||
|
"name": "Max",
|
||||||
|
"mywhoosh_email": "max@example.com",
|
||||||
|
"mywhoosh_password": "",
|
||||||
|
"garmin_email": "max-garmin@example.com",
|
||||||
|
"garmin_password": "",
|
||||||
|
"enabled": "on",
|
||||||
|
"notify_email_enabled": "on",
|
||||||
|
"notification_email": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_update_user_rejects_empty_garmin_email(client: TestClient) -> None:
|
def test_update_user_rejects_empty_garmin_email(client: TestClient) -> None:
|
||||||
login(client)
|
login(client)
|
||||||
user_id = create_user_via_http(client)
|
user_id = create_user_via_http(client)
|
||||||
|
|||||||
Reference in New Issue
Block a user