CLAUDE.md lists CR-005 among the change requests that get "silently half-built if
you treat them as frontend-only". This is the server half and the wizard half
together: a new table, four routes, an Alembic revision, and step 11.
CODES, NOT DISPLAY STRINGS, because CR-018 rolls cost up by these values and a
rollup keyed on a label breaks the day somebody fixes a typo in it. Two columns
carry that: `code` is a node's own slug, derived once at import and never
recomputed; `path` is the full slug path, unique per project, and is what a work
package will store. Renaming a value changes `name` alone - the probe renames a
floor and demands its path comes back byte-identical, with its children's paths
intact.
DEACTIVATE, NEVER DELETE. There is no DELETE route, and the probe checks for its
absence (405) rather than trusting that nobody added one. Deactivating hides a
value from new work packages and cascades DOWN, because a floor nobody can pick
must not keep offering its sectors. Reactivating walks UP only - a sector may
have been switched off for its own reasons, and silently resurrecting it would
undo a decision nobody made twice. That asymmetry is deliberate and is pinned by
a named check so it does not get "fixed" into a surprise.
Import reports rather than merges. Rejected rows come back with the SOURCE line
number and a reason; duplicates are listed as duplicates, separated into "already
in this project" and "already on line N of this import". Reusing a parent is not
a duplicate - B1/L2/1P and B1/L2/2P share a building and a floor by design, and
only the full path repeating counts. Re-importing a deactivated value brings the
same row back rather than creating a second one; the probe checks the id.
One parser, on the server. A CSV is read in the browser and posted as text
exactly as a paste is, so "what does a blank column mean" has one answer.
Comma, semicolon and tab all work - a paste out of a spreadsheet is tab
separated and a saved CSV is not, and which one somebody has is a question the
machine can answer.
No guessed floor names. IMPLEMENTATION.md section 8 says the B100 list has not
been supplied. The seeded sample has "Sample" inside every string, and the probe
greps html/ and server/ for a location-shaped assignment containing any of the
review's real names.
server/models.py LocationNode
server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
server/app.py GET/POST/PATCH + import, parser, slug
html/work-package-suite.html step 11, an 11th rail button
html/work-package-suite-app.js the step's logic; LAST_STEP replaces 10
html/work-package-suite-styles.css the list, the report
html/theme-light.css .field-error, now declared once
tests/locations_check.py new - 58 checks
tests/stepper_check.py STEP_COUNT 10 -> 11
Done when
[x] CSV upload and paste both work and report rejected rows with reasons
[x] duplicates are detected and reported rather than silently merged
[x] values are editable after import - rename, add, deactivate
[x] deactivating hides it from new work packages; an existing package
referencing it still resolves, because the row is retained
[x] values are stored as codes suitable for grouping
[x] no guessed real-world floor names exist anywhere in the code
Two decisions worth disagreeing with
Step 11, appended, not step 2, inserted. Locations belong beside Project by
subject. Renumbering 2-10 would touch every sop-step-N id, every
collectStepData case, every gate key and the analytics history - a large
silent-mismatch surface for an ordering change. The count now lives in one
place (LAST_STEP), so reordering later is cheap.
Any project member may edit the list, not only a Project Admin. It matches how
the SOP baseline itself is authored: the Project Admin gate is on CHANGING a
completed SOP, not on writing one. If the location list should be tighter than
the SOP it belongs to, that is a product call.
Verified one at a time
locations_check 58/58 new
stepper_check 70/70 (11 steps)
browser_check 71/71
a11y 22/22 sop now rings 38 focusable elements
url_state 23/23
autosave 34/34
aggregates 16/16
pipeline 43/43
launcher 58/58
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
alembic upgrade / downgrade / upgrade all clean on a throwaway SQLite
file, and the migrated schema matches Base.metadata.create_all
column for column - dev auto-creates and production migrates,
so a divergence between the two is invisible until it ships
.field-error was declared in two page sheets by the end of T5.2 and would have
been three by T5.8, so it moved to theme-light.css. No colour literal added
anywhere: still 0 across all page sheets and inline blocks.
Question for the PR, per CLAUDE.md: the levels are fixed at building / floor /
sector. Micron's floors behave like buildings, which this handles by letting a
project use whichever levels it needs - but a job that wants a fourth level, or
different names for the three, cannot say so. Whether that is worth a
per-project level vocabulary is a product question; the schema would take it
without a migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
382 lines
21 KiB
Python
382 lines
21 KiB
Python
"""ORM models for the Work Package Suite.
|
|
|
|
Three tables:
|
|
- sops one row per project SOP (the configuration baseline)
|
|
- work_packages one row per IWP, optionally linked to a SOP
|
|
- comments feedback / review comments from any page
|
|
|
|
The full client document for a SOP or WP is kept verbatim in a JSON `data`
|
|
column, with the most-queried fields promoted to real columns for listing and
|
|
filtering. IDs are short strings (client- or server-generated) so the browser
|
|
can upsert without round-tripping a sequence.
|
|
|
|
NO relationship() DECLARATIONS, ON PURPOSE — and one consequence to know about.
|
|
Every link here is a plain column plus a ForeignKey; nothing is navigable as
|
|
`project.work_packages`. Queries are explicit selects, which suits an API that
|
|
mostly reads one scoped list at a time and never wants a lazy load firing inside
|
|
a response.
|
|
|
|
The consequence: SQLAlchemy's unit of work derives FLUSH ORDER from relationships,
|
|
not from ForeignKey metadata. With none declared it has no dependency edge to
|
|
follow, so if you add a parent and its child in the SAME flush it may emit the
|
|
child's INSERT first and the database will reject it. Both engines enforce foreign
|
|
keys (Postgres always; SQLite since db.py sets `PRAGMA foreign_keys=ON`), so this
|
|
is a real error, not a dev-only quirk. Call `db.flush()` after adding the parent —
|
|
see `create_user` in app.py, which creates an account and its ProjectMember rows
|
|
together.
|
|
"""
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
from .db import Base
|
|
|
|
|
|
def utcnow() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
class Project(Base):
|
|
"""A construction project — the top-level container. SOPs and Work Packages
|
|
belong to a project so the suite can be used for many jobs at once."""
|
|
__tablename__ = "projects"
|
|
|
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
|
name: Mapped[str] = mapped_column(String(300), default="")
|
|
number: Mapped[str] = mapped_column(String(100), default="", index=True)
|
|
client: Mapped[str] = mapped_column(String(300), default="")
|
|
division: Mapped[str] = mapped_column(String(200), default="")
|
|
site: Mapped[str] = mapped_column(String(300), default="")
|
|
sample: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
# Archived projects are hidden from every picker, switcher and search but kept
|
|
# for the record — a finished job still has to be readable years later. Unlike
|
|
# an archived work package they are also FROZEN read-only: the API refuses any
|
|
# write to the project or to anything under it until an admin unarchives it.
|
|
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
|
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
created_by: Mapped[str] = mapped_column(String(200), default="")
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
|
|
|
def summary(self) -> dict:
|
|
return {
|
|
"id": self.id, "name": self.name, "number": self.number,
|
|
"client": self.client, "division": self.division, "site": self.site,
|
|
"sample": self.sample,
|
|
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
|
|
"created_by": self.created_by,
|
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
|
}
|
|
|
|
def to_dict(self) -> dict:
|
|
return {**self.summary(), "data": self.data or {}}
|
|
|
|
|
|
class Sop(Base):
|
|
__tablename__ = "sops"
|
|
|
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
|
project_id: Mapped[Optional[str]] = mapped_column(
|
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
|
)
|
|
name: Mapped[str] = mapped_column(String(300), default="")
|
|
number: Mapped[str] = mapped_column(String(100), default="")
|
|
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
created_by: Mapped[str] = mapped_column(String(200), default="")
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
|
|
|
def summary(self) -> dict:
|
|
return {
|
|
"id": self.id, "project_id": self.project_id, "name": self.name,
|
|
"number": self.number, "complete": self.complete, "created_by": self.created_by,
|
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
|
}
|
|
|
|
def to_dict(self) -> dict:
|
|
return {**self.summary(), "data": self.data or {}}
|
|
|
|
|
|
class WorkPackage(Base):
|
|
__tablename__ = "work_packages"
|
|
|
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
|
project_id: Mapped[Optional[str]] = mapped_column(
|
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
|
)
|
|
sop_id: Mapped[Optional[str]] = mapped_column(
|
|
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
# parent_id links a discipline instance (WP01A) back to its master (WP01).
|
|
parent_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
|
number: Mapped[str] = mapped_column(String(120), default="")
|
|
subject: Mapped[str] = mapped_column(String(400), default="")
|
|
type: Mapped[str] = mapped_column(String(120), default="")
|
|
status: Mapped[str] = mapped_column(String(40), default="Draft")
|
|
# The accountable owner (a user id), for "My Work Packages" + assignment
|
|
# notifications. Free-text `data.assignees`/`distribution` still hold the wider list.
|
|
assignee_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
|
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
# Archived packages are hidden from the default lists/dashboard but kept for
|
|
# the record (years-long projects accumulate hundreds of closed WPs).
|
|
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
|
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
created_by: Mapped[str] = mapped_column(String(200), default="")
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
|
|
|
def summary(self) -> dict:
|
|
return {
|
|
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
|
|
"parent_id": self.parent_id, "number": self.number, "subject": self.subject,
|
|
"type": self.type, "status": self.status, "assignee_id": self.assignee_id,
|
|
"issued_at": _iso(self.issued_at),
|
|
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
|
|
"created_by": self.created_by,
|
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
|
}
|
|
|
|
def to_dict(self) -> dict:
|
|
return {**self.summary(), "data": self.data or {}}
|
|
|
|
|
|
class User(Base):
|
|
"""A login account. Passwords are never stored in the clear — only a bcrypt
|
|
hash (see server/auth.py). `username` is what people sign in with.
|
|
|
|
Two independent notions of "role", deliberately separate:
|
|
• role the PERMISSIONS role — what the account may do in the app.
|
|
'admin' | 'project_super_user' | 'project_admin' |
|
|
'project_user' (see auth.ROLES).
|
|
• project_role the person's JOB FUNCTION on the project (Project Manager,
|
|
Superintendent, QA/QC, …). Carries no permissions; it's what
|
|
the SOP team pickers and notification routing read.
|
|
"""
|
|
__tablename__ = "users"
|
|
|
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
|
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
|
email: Mapped[str] = mapped_column(String(200), default="")
|
|
full_name: Mapped[str] = mapped_column(String(200), default="")
|
|
password_hash: Mapped[str] = mapped_column(String(200), default="")
|
|
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
|
|
# Job function on the project — free text, offered from a suggested list.
|
|
project_role: Mapped[str] = mapped_column(String(120), default="")
|
|
# A PM or QA lead who belongs on every job shouldn't have to be ticked into each
|
|
# new project by hand, so flagged accounts get a ProjectMember row the moment a
|
|
# project is created. `auto_add_role` is the role they land with and shares
|
|
# ProjectMember.role's value space: '' = inherit the account's own role,
|
|
# otherwise 'project_admin' | 'project_user'.
|
|
auto_add_projects: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
auto_add_role: Mapped[str] = mapped_column(String(20), default="")
|
|
# Display preferences. Empty means "fall back to the app default, then to the
|
|
# browser". A stored value follows the person between devices, which matters on
|
|
# shared field tablets where the browser locale isn't theirs.
|
|
locale: Mapped[str] = mapped_column(String(20), default="") # BCP47, e.g. en-US
|
|
timezone: Mapped[str] = mapped_column(String(60), default="") # IANA, e.g. America/Chicago
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
|
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
|
|
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
|
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
# Bumped to invalidate all existing sessions for this user (e.g. on a password
|
|
# change). The value is embedded in the JWT and re-checked on every request.
|
|
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Public view of a user — NEVER includes the password hash."""
|
|
return {
|
|
"id": self.id, "username": self.username, "email": self.email,
|
|
"full_name": self.full_name, "role": self.role,
|
|
"project_role": self.project_role or "", "is_active": self.is_active,
|
|
"auto_add_projects": bool(self.auto_add_projects),
|
|
"auto_add_role": self.auto_add_role or "",
|
|
"locale": self.locale or "", "timezone": self.timezone or "",
|
|
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
|
|
}
|
|
|
|
|
|
class ProjectMember(Base):
|
|
"""Which users may access which projects, and what they may do there. A user
|
|
sees/operates on a project only if a row links them to it (admins bypass this
|
|
entirely). One row per (user, project) pair.
|
|
|
|
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
|
|
one job and a normal Project User on another, or a Project Super User (who
|
|
administers that job's user accounts) on one job only. Empty means "inherit the
|
|
account's own role" (User.role), which is how every existing row behaves.
|
|
Values: '' | 'project_super_user' | 'project_admin' | 'project_user'
|
|
(auth.PROJECT_SCOPED_ROLES) — never 'admin', which is app-wide by definition."""
|
|
__tablename__ = "project_members"
|
|
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
|
|
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
|
user_id: Mapped[str] = mapped_column(
|
|
String(40), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
project_id: Mapped[str] = mapped_column(
|
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
|
)
|
|
role: Mapped[str] = mapped_column(String(20), default="") # '' = inherit User.role
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
|
|
|
|
class LocationNode(Base):
|
|
"""One value in a project's Building / Floor / Sector taxonomy — CR-005.
|
|
|
|
The taxonomy differs per project. On Micron, floors within B100 behave like
|
|
separate buildings, so floor and sector are the unit of both execution and
|
|
cost tracking; on another job "building" may be the only level that means
|
|
anything. So it is configured once per SOP rather than hard-coded, and no
|
|
real-world floor name appears anywhere in this repository.
|
|
|
|
CODES, NOT DISPLAY STRINGS. `CR-018` rolls cost up by these, and a rollup
|
|
keyed on a label breaks the day somebody fixes a typo in it. Two columns
|
|
carry that:
|
|
|
|
code this node's own slug among its siblings, derived once from the name
|
|
it was imported with and then NEVER recomputed — renaming a node is
|
|
a display change, which is exactly what makes rename safe for the
|
|
work packages already pointing at it.
|
|
path the full slug path from the root, '/'-joined and unique per project
|
|
(`B100/L2/1P`). This is the grouping key and the value a work
|
|
package stores.
|
|
|
|
DEACTIVATE, NEVER DELETE. `active=False` hides a value from new work
|
|
packages; every existing package referencing it still resolves its label,
|
|
because the row is still there. Same rule as `CR-002`/`CR-016`: removal is
|
|
expressed as a toggle, and the data is retained.
|
|
"""
|
|
__tablename__ = "location_nodes"
|
|
__table_args__ = (
|
|
UniqueConstraint("project_id", "path", name="uq_location_path"),
|
|
)
|
|
|
|
LEVELS = ("building", "floor", "sector")
|
|
|
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
|
project_id: Mapped[str] = mapped_column(
|
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
|
)
|
|
# Self-reference by id. No ForeignKey to its own table for the same reason the
|
|
# rest of this file declares none — see the module docstring — and because a
|
|
# self-referential FK plus SQLite's deferred-constraint behaviour makes a bulk
|
|
# import fiddly for no gain. Orphans are prevented in the API, which is the
|
|
# only writer.
|
|
parent_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
|
level: Mapped[str] = mapped_column(String(20), default="building") # building | floor | sector
|
|
code: Mapped[str] = mapped_column(String(60), default="") # own slug
|
|
path: Mapped[str] = mapped_column(String(200), default="", index=True) # full slug path
|
|
name: Mapped[str] = mapped_column(String(200), default="") # display label
|
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
sort: Mapped[int] = mapped_column(Integer, default=0)
|
|
created_by: Mapped[str] = mapped_column(String(200), default="")
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id, "project_id": self.project_id, "parent_id": self.parent_id,
|
|
"level": self.level, "code": self.code, "path": self.path, "name": self.name,
|
|
"active": bool(self.active), "sort": self.sort,
|
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
|
}
|
|
|
|
|
|
class Comment(Base):
|
|
__tablename__ = "comments"
|
|
|
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
|
source: Mapped[str] = mapped_column(String(40), default="", index=True) # home_feedback | sop_step_comment | wp_review_comment
|
|
sop_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
|
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
|
step: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
author: Mapped[str] = mapped_column(String(200), default="")
|
|
text: Mapped[str] = mapped_column(Text, default="")
|
|
page: Mapped[str] = mapped_column(String(200), default="")
|
|
extra: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id, "source": self.source, "sop_id": self.sop_id, "wp_id": self.wp_id,
|
|
"step": self.step, "author": self.author, "text": self.text, "page": self.page,
|
|
"created_at": _iso(self.created_at),
|
|
}
|
|
|
|
|
|
class AuditLog(Base):
|
|
"""Append-only history: who changed what, when. Rows are written inside the
|
|
same transaction as the change they describe (see server/app.py: log_event),
|
|
so the trail can't drift from the data. `detail` holds a compact JSON summary
|
|
of the change, e.g. {"from": "Scheduled", "to": "Issued"}.
|
|
|
|
Not a ForeignKey to any entity on purpose — the log must survive the deletion
|
|
of the thing it describes (you still want "who deleted WP01, and when")."""
|
|
__tablename__ = "audit_log"
|
|
|
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
|
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
|
actor: Mapped[str] = mapped_column(String(200), default="") # username who made the change
|
|
action: Mapped[str] = mapped_column(String(60), default="", index=True) # created | updated | status_changed | issued | role_changed | ...
|
|
entity_type: Mapped[str] = mapped_column(String(40), default="", index=True) # wp | sop | project | user
|
|
entity_id: Mapped[str] = mapped_column(String(40), default="", index=True)
|
|
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
|
summary: Mapped[str] = mapped_column(String(400), default="") # human one-liner (e.g. the WP number/subject)
|
|
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id, "at": _iso(self.at), "actor": self.actor, "action": self.action,
|
|
"entity_type": self.entity_type, "entity_id": self.entity_id,
|
|
"project_id": self.project_id, "summary": self.summary, "detail": self.detail or {},
|
|
}
|
|
|
|
|
|
class AppSetting(Base):
|
|
"""Admin-editable application settings (feature flags, SMTP config, …) stored
|
|
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
|
|
the SMTP password are NOT stored here — they come from the environment."""
|
|
__tablename__ = "app_settings"
|
|
|
|
key: Mapped[str] = mapped_column(String(80), primary_key=True)
|
|
value: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
|
|
|
|
|
class Notification(Base):
|
|
"""Outbox for user notifications (an in-app record + an optional email). A row
|
|
is written when something notable happens (e.g. a WP assignment); the email
|
|
sender processes it only when email notifications are enabled AND SMTP is set —
|
|
otherwise it's recorded as 'skipped'. See server/notify.py."""
|
|
__tablename__ = "notifications"
|
|
|
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
|
user_id: Mapped[str] = mapped_column(String(40), index=True) # recipient
|
|
email: Mapped[str] = mapped_column(String(200), default="")
|
|
kind: Mapped[str] = mapped_column(String(40), default="", index=True) # wp_assigned | …
|
|
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
|
|
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
|
subject: Mapped[str] = mapped_column(String(300), default="")
|
|
body: Mapped[str] = mapped_column(Text, default="")
|
|
link: Mapped[str] = mapped_column(String(500), default="")
|
|
status: Mapped[str] = mapped_column(String(20), default="pending", index=True) # pending|sent|failed|skipped
|
|
error: Mapped[str] = mapped_column(String(400), default="")
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
|
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id, "user_id": self.user_id, "email": self.email, "kind": self.kind,
|
|
"wp_id": self.wp_id, "project_id": self.project_id, "subject": self.subject,
|
|
"status": self.status, "error": self.error,
|
|
"created_at": _iso(self.created_at), "sent_at": _iso(self.sent_at),
|
|
}
|
|
|
|
|
|
def _iso(dt: Optional[datetime]) -> Optional[str]:
|
|
return dt.isoformat() if dt else None
|