supercontest.models.models
The data schema for the application.
There are one-to-one, one-to-many, many-to-one, and many-to-many relationships below.
Columns have relationships, not tables, of course. For tables that are “simply” associated with
others, you can make the statements above in a “simple” sense - eg Season to Week is a
one-to-many relationship. But more specificity is required for tables the have relationships
across multiple columns - eg games and (2) contestants.
In the SQL layer, all FKs manifest as actual columns and all association tables manifest as actual tables (of course). All integer PKs have associated sequences and column defaults (to nextval) in postgres.
In the ORM layer, the relationships are all queryable as if they were real columns (of course). I have made every relationship bidirectional.
Three contestant columns are computed by the database, not by this module: opponent_score,
margin and coverage_id. Triggers set them on every write, whoever the writer is; see
supercontest.models.coverage_triggers for the formula. They are stored, not virtual, and
the ORM reads them back after each flush rather than computing them. All three – and
score itself – are NULL until the game starts: an unstarted game has no score.
Lines, margins and points are numeric in the database – exact, so the
coverage formula’s “exactly zero is a push” is an exact comparison – and
floats in Python (asdecimal=False). Every value is a whole or half point,
which a float holds exactly, so nothing is lost either way.
In general, this module flows from children -> parents, to make dependency readable + ordered.
Module Attributes
How every constraint and index is named, so a revision can name what it drops and find it in every database. |
|
The many-many relationship and its association table between User and League. |
Classes
A signed-in session: a SHA-256 of the cookie's value, and whose it is. |
|
The declarative base every model maps from; its |
|
One side of a game. |
|
One scheduled broadcast that has gone out: a job's mail for one week, at most once. |
|
A user's pick of one contestant. |
|
A season, |
|
A user. |
|
One user's week, totalled: what every season and all-time view is built from. |
- class supercontest.models.models.AccessToken(**kwargs)
Bases:
Base,SQLAlchemyBaseAccessTokenTable[int]A signed-in session: a SHA-256 of the cookie’s value, and whose it is.
fastapi-users’ own table (
token,created_at), keyed to a user whose deletion deletes it too.tokenholds the hash, never the token: seesupercontest.api.auth.HashedDatabaseStrategy. One row per sign-in, deleted at sign-out, so a session can be ended one at a time – and all at once, on a password change.- created_at: Mapped[datetime]
- token: Mapped[str]
- user_id: Mapped[int]
- class supercontest.models.models.Base(**kwargs: Any)
Bases:
AsyncAttrs,DeclarativeBaseThe declarative base every model maps from; its
metadatais the whole schema.AsyncAttrsis for the rare deliberate lazy read:await game.awaitable_attrs.week. Every relationship below islazy="raise", so any other unloaded read is an error.- metadata: ClassVar[MetaData] = MetaData()
Every table, constraint and index, named by
NAMING_CONVENTION.
- registry: ClassVar[registry] = <sqlalchemy.orm.decl_api.registry object>
Refers to the
_orm.registryin use where new_orm.Mapperobjects will be associated.
- class supercontest.models.models.Contestant(**kwargs)
Bases:
BaseOne side of a game.
opponent_score,marginandcoverage_idare the database’s, not the caller’s:supercontest.models.coverage_triggerscomputes them on every write, and whatever a caller passes for them is replaced.scoretoo is the database’s to clear: NULL while the game is unstarted, with the three derived columns, and never NULL once it has started. A write toscoreor to the game’slinealso rewrites rows the ORM did not write – the opponent, or both sides – so a session that changes either and reads margins afterwards refreshes the game’s contestants first.- coverage_id: Mapped[int | None]
- game_id: Mapped[int]
- get_opponent() Contestant
Don’t need it as an ID FK col, or a relationship, because then there are complications when creating this object (its opponent ID doesn’t exist yet and self-relationships can get messy) - so this convenience function just provides it when called.
- Returns:
The Contestant object for the opponent.
- Raises:
ValueError – If there’s not exactly 2 opponents in the game associated with this contestant.
- id: Mapped[int]
- location_id: Mapped[int]
- margin: Mapped[float | None]
- opponent_score: Mapped[int | None]
- prediction: Mapped[Prediction]
- prediction_id: Mapped[int]
- score: Mapped[int | None]
- side: Mapped[int]
- team_id: Mapped[int]
- class supercontest.models.models.Coverage(**kwargs)
Bases:
Base- contestants: Mapped[list[Contestant]]
- id: Mapped[int]
- name: Mapped[str]
- points: Mapped[float]
- class supercontest.models.models.EmailSend(**kwargs)
Bases:
BaseOne scheduled broadcast that has gone out: a job’s mail for one week, at most once.
The relay that triggers a job retries it when the request outlasts its wait, while the first attempt may still be sending. The job claims its row before it sends – the primary key is the claim – and a job whose row is already there sends nothing.
- job: Mapped[str]
- recipients: Mapped[int]
How many addresses it went to.
- season: Mapped[int]
- sent_at: Mapped[datetime]
- week: Mapped[int]
- class supercontest.models.models.Game(**kwargs)
Bases:
Base- contestants: Mapped[list[Contestant]]
- datetime: Mapped[datetime]
- get_contestants(by: Literal['prediction', 'location', 'coverage'], fail: bool = True) tuple[Contestant, Contestant]
Returns the Contestant objects in the matchup, by various classifications.
- Parameters:
by – “prediction” or “location” or “coverage”
fail – If True, will error if pickem/neutral/push. Else, will return in order of contestants[0], contestants[1]
- Returns:
(favorite, underdog) or (home, visitor) or (coverer, noncoverer)
- Raises:
ValueError – If
fail=Trueand no favorite/home/coverer in this game.
- id: Mapped[int]
- line: Mapped[float]
- status_id: Mapped[int]
- week_id: Mapped[int]
- class supercontest.models.models.League(**kwargs)
Bases:
Base- id: Mapped[int]
- name: Mapped[str]
- season_id: Mapped[int]
- class supercontest.models.models.Location(**kwargs)
Bases:
Base- contestants: Mapped[list[Contestant]]
- id: Mapped[int]
- name: Mapped[str]
- supercontest.models.models.NAMING_CONVENTION: dict[str, str] = {'ck': 'ck_%(table_name)s_%(constraint_name)s', 'fk': 'fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s', 'ix': 'ix_%(table_name)s_%(column_0_N_name)s', 'pk': 'pk_%(table_name)s', 'uq': 'uq_%(table_name)s_%(column_0_N_name)s'}
How every constraint and index is named, so a revision can name what it drops and find it in every database. A check constraint must be given its own short name, which the convention prefixes. An expression index has no column to name it by, so it is named by hand.
- class supercontest.models.models.Pick(**kwargs)
Bases:
BaseA user’s pick of one contestant.
game_idis the contestant’s game, stored so the database can refuse a second pick in one game – a duplicate or the other side – from every writer, withUNIQUE (user_id, game_id). A unique index cannot be passed by two concurrent inserts, as a trigger’s read can. The foreign key is the pair(contestant_id, game_id), sogame_idcan never name another game than its contestant’s. A writer sets both.- contestant: Mapped[Contestant]
- contestant_id: Mapped[int]
- created_at: Mapped[datetime | None]
- game_id: Mapped[int]
- id: Mapped[int]
- updated_at: Mapped[datetime | None]
- user_id: Mapped[int]
- class supercontest.models.models.Prediction(**kwargs)
Bases:
Base- contestants: Mapped[list[Contestant]]
- id: Mapped[int]
- name: Mapped[str]
- class supercontest.models.models.Season(**kwargs)
Bases:
BaseA season,
season_start <= now < season_end: half-open, like its weeks.Both bounds are instants. Each is a Pacific midnight – the league’s day – stored, like every time in the schema, as a
timestamptz.- id: Mapped[int]
- season: Mapped[int]
- season_end: Mapped[datetime]
- season_start: Mapped[datetime]
- class supercontest.models.models.Status(**kwargs)
Bases:
Base- abbv: Mapped[str]
- espn_abbv: Mapped[str]
- finished: Mapped[bool]
- id: Mapped[int]
- inprogress: Mapped[bool]
- name: Mapped[str]
- unstarted: Mapped[bool]
- class supercontest.models.models.Team(**kwargs)
Bases:
Base- abbv: Mapped[str]
- city: Mapped[str]
- contestants: Mapped[list[Contestant]]
- id: Mapped[int]
- name: Mapped[str]
- class supercontest.models.models.User(**kwargs)
Bases:
Base,SQLAlchemyBaseUserTable[int]A user. The five auth columns are fastapi-users’ own table’s.
email,hashed_password,is_active,is_superuserandis_verifiedcome fromSQLAlchemyBaseUserTable: the library owns their names, types, lengths and defaults. What is declared here is the id, the contest’s own columns, and one change to the library’s email column: it is unique CASE-INSENSITIVELY. Every lookup – the library’sget_by_emailand ours – compareslower(email), so the unique index is on that expression, which also serves the lookups; the library’s plain unique index would allowA@x.combesidea@x.comand serve neither.- created_at: Mapped[datetime | None]
- email: Mapped[str]
- email_all_picks: Mapped[bool]
- email_when_picks_closing: Mapped[bool]
- email_when_picks_open: Mapped[bool]
- first_name: Mapped[str | None]
- hashed_password: Mapped[str]
- id: Mapped[int]
- is_active: Mapped[bool]
- is_superuser: Mapped[bool]
- is_verified: Mapped[bool]
- last_name: Mapped[str | None]
- class supercontest.models.models.Week(**kwargs)
Bases:
Base- id: Mapped[int]
- season_id: Mapped[int]
- week: Mapped[int]
- week_end: Mapped[datetime]
- week_start: Mapped[datetime]
- class supercontest.models.models.WeekTotal(**kwargs)
Bases:
BaseOne user’s week, totalled: what every season and all-time view is built from.
Maintained by the database, like a contestant’s coverage: triggers in
supercontest.models.week_totalsrecompute a row whenever a pick, a contestant’s coverage or margin, or a game’s status or week changes, from whoever the writer is. Nothing in the app writes it. Each recompute is the full aggregate of that user’s week, not a running sum, so it cannot drift.The counts follow the results engine’s own buckets: a pick is unstarted, in progress (covering, pushing, noncovering) or finished (cover, push, noncover), by its game’s status and its contestant’s coverage. Points are summed separately for finished and in-progress games; the margin sum is of finished picks, for the all-time average.
- cover: Mapped[int]
- covering: Mapped[int]
- margin_finished: Mapped[float]
- noncover: Mapped[int]
- noncovering: Mapped[int]
- points_finished: Mapped[float]
- points_inprogress: Mapped[float]
- push: Mapped[int]
- pushing: Mapped[int]
- unstarted: Mapped[int]
- updated_at: Mapped[datetime]
- user_id: Mapped[int]
- week_id: Mapped[int]
- supercontest.models.models.league_user = Table('league_user_association', MetaData(), Column('league_id', Integer(), ForeignKey('leagues.id'), table=<league_user_association>, primary_key=True, nullable=False), Column('user_id', Integer(), ForeignKey('users.id'), table=<league_user_association>, primary_key=True, nullable=False), schema=None)
The many-many relationship and its association table between User and League.