mail notification
This commit is contained in:
@@ -15,6 +15,12 @@ class Settings(BaseSettings):
|
||||
database_url: str | None = Field(default=None, alias="DATABASE_URL")
|
||||
sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES")
|
||||
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")
|
||||
def derive_paths(self) -> "Settings":
|
||||
|
||||
@@ -53,6 +53,8 @@ class SyncUser(Base):
|
||||
mywhoosh_password_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)
|
||||
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)
|
||||
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.orm import Session, sessionmaker
|
||||
|
||||
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:
|
||||
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:
|
||||
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.garmin.uploader import GarminUploader
|
||||
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
|
||||
@@ -33,6 +34,15 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
def garmin_factory(email, password, 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(
|
||||
session_factory=app.state.session_factory,
|
||||
credential_cipher=cipher,
|
||||
@@ -40,6 +50,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
mywhoosh_factory=mywhoosh_factory,
|
||||
garmin_factory=garmin_factory,
|
||||
fit_converter=convert_fit_device,
|
||||
notifier=notifier,
|
||||
)
|
||||
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],
|
||||
garmin_factory: Callable[[str, str, Path], Any],
|
||||
fit_converter: Callable[[Path, Path], Any],
|
||||
notifier: Any = None,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.credential_cipher = credential_cipher
|
||||
@@ -71,9 +72,24 @@ class SyncManager:
|
||||
self.mywhoosh_factory = mywhoosh_factory
|
||||
self.garmin_factory = garmin_factory
|
||||
self.fit_converter = fit_converter
|
||||
self.notifier = notifier
|
||||
self._locks: dict[int, 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 with self._locks_guard:
|
||||
return self._locks.setdefault(user_id, asyncio.Lock())
|
||||
@@ -102,6 +118,12 @@ class SyncManager:
|
||||
if user is None:
|
||||
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)
|
||||
run = sync_run_repo.start(user_id)
|
||||
|
||||
@@ -366,6 +388,8 @@ class SyncManager:
|
||||
summary_error=summary_error,
|
||||
)
|
||||
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(
|
||||
user_id=user.id,
|
||||
status=status.value,
|
||||
|
||||
@@ -9,3 +9,5 @@ class UserFormData:
|
||||
garmin_email: str
|
||||
garmin_password: str
|
||||
enabled: bool
|
||||
notify_email_enabled: bool = False
|
||||
notification_email: str = ""
|
||||
|
||||
@@ -96,6 +96,8 @@ def create_user(
|
||||
garmin_email: str = Form(""),
|
||||
garmin_password: str = Form(""),
|
||||
enabled: str | None = Form(None),
|
||||
notify_email_enabled: str | None = Form(None),
|
||||
notification_email: str = Form(""),
|
||||
):
|
||||
require_admin(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
@@ -110,7 +112,11 @@ def create_user(
|
||||
garmin_email=garmin_email,
|
||||
garmin_password=garmin_password,
|
||||
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)
|
||||
with request.app.state.session_factory() as session:
|
||||
repository = UserRepository(session)
|
||||
@@ -121,6 +127,8 @@ def create_user(
|
||||
mywhoosh_password_enc=cipher.encrypt(form.mywhoosh_password),
|
||||
garmin_email_enc=cipher.encrypt(form.garmin_email.strip()),
|
||||
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
|
||||
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
||||
@@ -175,6 +183,8 @@ def update_user(
|
||||
garmin_email: str = Form(""),
|
||||
garmin_password: str = Form(""),
|
||||
enabled: str | None = Form(None),
|
||||
notify_email_enabled: str | None = Form(None),
|
||||
notification_email: str = Form(""),
|
||||
):
|
||||
require_admin(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
@@ -187,7 +197,11 @@ def update_user(
|
||||
garmin_email=garmin_email,
|
||||
garmin_password=garmin_password,
|
||||
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)
|
||||
with request.app.state.session_factory() as session:
|
||||
repository = UserRepository(session)
|
||||
@@ -197,6 +211,8 @@ def update_user(
|
||||
"enabled": form.enabled,
|
||||
"mywhoosh_email_enc": cipher.encrypt(form.mywhoosh_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:
|
||||
values["mywhoosh_password_enc"] = cipher.encrypt(form.mywhoosh_password)
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
Enabled
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user