mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-21 06:39:09 -04:00
Compare commits
12 Commits
claude/imp
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 65c4f1d23e | |||
| 75a67294ea | |||
| 58b25f2e89 | |||
| 568bf8dd38 | |||
| 82851eb287 | |||
| b7bce0bcb9 | |||
| 583af965ad | |||
| ec80bf1c5f | |||
| bd490334f5 | |||
| e893ea0f57 | |||
| 18f149b831 | |||
| 69f5b87e5f |
@@ -24,6 +24,7 @@
|
|||||||
fastapi
|
fastapi
|
||||||
fastapi-cli
|
fastapi-cli
|
||||||
httpx
|
httpx
|
||||||
|
python-multipart
|
||||||
mypy
|
mypy
|
||||||
polars
|
polars
|
||||||
psycopg
|
psycopg
|
||||||
|
|||||||
@@ -7,7 +7,25 @@ requires-python = "~=3.13.0"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
# these dependencies are a best effort and aren't guaranteed to work
|
# these dependencies are a best effort and aren't guaranteed to work
|
||||||
dependencies = ["apprise", "apscheduler", "httpx", "polars", "pydantic", "pyyaml", "requests", "typer"]
|
# for up-to-date dependencies, see overlays/default.nix
|
||||||
|
dependencies = [
|
||||||
|
"alembic",
|
||||||
|
"apprise",
|
||||||
|
"apscheduler",
|
||||||
|
"httpx",
|
||||||
|
"python-multipart",
|
||||||
|
"polars",
|
||||||
|
"psycopg[binary]",
|
||||||
|
"pydantic",
|
||||||
|
"pyyaml",
|
||||||
|
"requests",
|
||||||
|
"sqlalchemy",
|
||||||
|
"typer",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
database = "python.database_cli:app"
|
||||||
|
van-inventory = "python.van_inventory.main:serve"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
# A generic, single database configuration.
|
|
||||||
|
|
||||||
[alembic]
|
|
||||||
# path to migration scripts
|
|
||||||
# Use forward slashes (/) also on windows to provide an os agnostic path
|
|
||||||
script_location = python/alembic
|
|
||||||
|
|
||||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
||||||
# Uncomment the line below if you want the files to be prepended with date and time
|
|
||||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
|
||||||
# for all available tokens
|
|
||||||
file_template = %%(year)d_%%(month).2d_%%(day).2d-%%(slug)s_%%(rev)s
|
|
||||||
|
|
||||||
# sys.path path, will be prepended to sys.path if present.
|
|
||||||
# defaults to the current working directory.
|
|
||||||
prepend_sys_path = .
|
|
||||||
|
|
||||||
# timezone to use when rendering the date within the migration file
|
|
||||||
# as well as the filename.
|
|
||||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
|
||||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
|
||||||
# string value is passed to ZoneInfo()
|
|
||||||
# leave blank for localtime
|
|
||||||
# timezone =
|
|
||||||
|
|
||||||
# max length of characters to apply to the "slug" field
|
|
||||||
# truncate_slug_length = 40
|
|
||||||
|
|
||||||
# set to 'true' to run the environment during
|
|
||||||
# the 'revision' command, regardless of autogenerate
|
|
||||||
# revision_environment = false
|
|
||||||
|
|
||||||
# set to 'true' to allow .pyc and .pyo files without
|
|
||||||
# a source .py file to be detected as revisions in the
|
|
||||||
# versions/ directory
|
|
||||||
# sourceless = false
|
|
||||||
|
|
||||||
# version location specification; This defaults
|
|
||||||
# to alembic/versions. When using multiple version
|
|
||||||
# directories, initial revisions must be specified with --version-path.
|
|
||||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
|
||||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
|
||||||
|
|
||||||
# version path separator; As mentioned above, this is the character used to split
|
|
||||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
|
||||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
|
||||||
# Valid values for version_path_separator are:
|
|
||||||
#
|
|
||||||
# version_path_separator = :
|
|
||||||
# version_path_separator = ;
|
|
||||||
# version_path_separator = space
|
|
||||||
# version_path_separator = newline
|
|
||||||
#
|
|
||||||
# Use os.pathsep. Default configuration used for new projects.
|
|
||||||
version_path_separator = os
|
|
||||||
|
|
||||||
# set to 'true' to search source files recursively
|
|
||||||
# in each "version_locations" directory
|
|
||||||
# new in Alembic version 1.10
|
|
||||||
# recursive_version_locations = false
|
|
||||||
|
|
||||||
# the output encoding used when revision files
|
|
||||||
# are written from script.py.mako
|
|
||||||
# output_encoding = utf-8
|
|
||||||
|
|
||||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
||||||
|
|
||||||
revision_environment = true
|
|
||||||
|
|
||||||
[post_write_hooks]
|
|
||||||
|
|
||||||
hooks = dynamic_schema,ruff
|
|
||||||
dynamic_schema.type = dynamic_schema
|
|
||||||
|
|
||||||
ruff.type = ruff
|
|
||||||
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARNING
|
|
||||||
handlers = console
|
|
||||||
qualname =
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARNING
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
datefmt = %H:%M:%S
|
|
||||||
@@ -9,20 +9,24 @@ from typing import TYPE_CHECKING, Any, Literal
|
|||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from alembic.script import write_hooks
|
from alembic.script import write_hooks
|
||||||
|
from sqlalchemy.schema import CreateSchema
|
||||||
|
|
||||||
from python.common import bash_wrapper
|
from python.common import bash_wrapper
|
||||||
from python.orm import RichieBase
|
from python.orm.common import get_postgres_engine
|
||||||
from python.orm.base import get_postgres_engine
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import MutableMapping
|
from collections.abc import MutableMapping
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
# access to the values within the .ini file in use.
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
|
base_class: type[DeclarativeBase] = config.attributes.get("base")
|
||||||
|
if base_class is None:
|
||||||
|
error = "No base class provided. Use the database CLI to run alembic commands."
|
||||||
|
raise RuntimeError(error)
|
||||||
|
|
||||||
target_metadata = RichieBase.metadata
|
target_metadata = base_class.metadata
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level="DEBUG",
|
level="DEBUG",
|
||||||
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
||||||
@@ -35,8 +39,9 @@ logging.basicConfig(
|
|||||||
def dynamic_schema(filename: str, _options: dict[Any, Any]) -> None:
|
def dynamic_schema(filename: str, _options: dict[Any, Any]) -> None:
|
||||||
"""Dynamic schema."""
|
"""Dynamic schema."""
|
||||||
original_file = Path(filename).read_text()
|
original_file = Path(filename).read_text()
|
||||||
dynamic_schema_file_part1 = original_file.replace(f"schema='{RichieBase.schema_name}'", "schema=schema")
|
schema_name = base_class.schema_name
|
||||||
dynamic_schema_file = dynamic_schema_file_part1.replace(f"'{RichieBase.schema_name}.", "f'{schema}.")
|
dynamic_schema_file_part1 = original_file.replace(f"schema='{schema_name}'", "schema=schema")
|
||||||
|
dynamic_schema_file = dynamic_schema_file_part1.replace(f"'{schema_name}.", "f'{schema}.")
|
||||||
Path(filename).write_text(dynamic_schema_file)
|
Path(filename).write_text(dynamic_schema_file)
|
||||||
|
|
||||||
|
|
||||||
@@ -52,12 +57,12 @@ def include_name(
|
|||||||
type_: Literal["schema", "table", "column", "index", "unique_constraint", "foreign_key_constraint"],
|
type_: Literal["schema", "table", "column", "index", "unique_constraint", "foreign_key_constraint"],
|
||||||
_parent_names: MutableMapping[Literal["schema_name", "table_name", "schema_qualified_table_name"], str | None],
|
_parent_names: MutableMapping[Literal["schema_name", "table_name", "schema_qualified_table_name"], str | None],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""This filter table to be included in the migration.
|
"""Filter tables to be included in the migration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): The name of the table.
|
name (str): The name of the table.
|
||||||
type_ (str): The type of the table.
|
type_ (str): The type of the table.
|
||||||
parent_names (list[str]): The names of the parent tables.
|
_parent_names (MutableMapping): The names of the parent tables.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the table should be included, False otherwise.
|
bool: True if the table should be included, False otherwise.
|
||||||
@@ -75,19 +80,30 @@ def run_migrations_online() -> None:
|
|||||||
and associate a connection with the context.
|
and associate a connection with the context.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
connectable = get_postgres_engine()
|
env_prefix = config.attributes.get("env_prefix", "POSTGRES")
|
||||||
|
connectable = get_postgres_engine(name=env_prefix)
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
with connectable.connect() as connection:
|
||||||
|
schema = base_class.schema_name
|
||||||
|
if not connectable.dialect.has_schema(connection, schema):
|
||||||
|
answer = input(f"Schema {schema!r} does not exist. Create it? [y/N] ")
|
||||||
|
if answer.lower() != "y":
|
||||||
|
error = f"Schema {schema!r} does not exist. Exiting."
|
||||||
|
raise SystemExit(error)
|
||||||
|
connection.execute(CreateSchema(schema))
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
context.configure(
|
context.configure(
|
||||||
connection=connection,
|
connection=connection,
|
||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
include_schemas=True,
|
include_schemas=True,
|
||||||
version_table_schema=RichieBase.schema_name,
|
version_table_schema=schema,
|
||||||
include_name=include_name,
|
include_name=include_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
run_migrations_online()
|
run_migrations_online()
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""add congress tracker tables.
|
||||||
|
|
||||||
|
Revision ID: 3f71565e38de
|
||||||
|
Revises: edd7dd61a3d2
|
||||||
|
Create Date: 2026-02-12 16:36:09.457303
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from python.orm import RichieBase
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "3f71565e38de"
|
||||||
|
down_revision: str | None = "edd7dd61a3d2"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
schema = RichieBase.schema_name
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"bill",
|
||||||
|
sa.Column("congress", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("bill_type", sa.String(), nullable=False),
|
||||||
|
sa.Column("number", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("title", sa.String(), nullable=True),
|
||||||
|
sa.Column("title_short", sa.String(), nullable=True),
|
||||||
|
sa.Column("official_title", sa.String(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(), nullable=True),
|
||||||
|
sa.Column("status_at", sa.Date(), nullable=True),
|
||||||
|
sa.Column("sponsor_bioguide_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("subjects_top_term", sa.String(), nullable=True),
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_bill")),
|
||||||
|
sa.UniqueConstraint("congress", "bill_type", "number", name="uq_bill_congress_type_number"),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
op.create_index("ix_bill_congress", "bill", ["congress"], unique=False, schema=schema)
|
||||||
|
op.create_table(
|
||||||
|
"legislator",
|
||||||
|
sa.Column("bioguide_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column("thomas_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("lis_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("govtrack_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("opensecrets_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("fec_ids", sa.String(), nullable=True),
|
||||||
|
sa.Column("first_name", sa.String(), nullable=False),
|
||||||
|
sa.Column("last_name", sa.String(), nullable=False),
|
||||||
|
sa.Column("official_full_name", sa.String(), nullable=True),
|
||||||
|
sa.Column("nickname", sa.String(), nullable=True),
|
||||||
|
sa.Column("birthday", sa.Date(), nullable=True),
|
||||||
|
sa.Column("gender", sa.String(), nullable=True),
|
||||||
|
sa.Column("current_party", sa.String(), nullable=True),
|
||||||
|
sa.Column("current_state", sa.String(), nullable=True),
|
||||||
|
sa.Column("current_district", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("current_chamber", sa.String(), nullable=True),
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_legislator")),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_legislator_bioguide_id"), "legislator", ["bioguide_id"], unique=True, schema=schema)
|
||||||
|
op.create_table(
|
||||||
|
"vote",
|
||||||
|
sa.Column("congress", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("chamber", sa.String(), nullable=False),
|
||||||
|
sa.Column("session", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("number", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("vote_type", sa.String(), nullable=True),
|
||||||
|
sa.Column("question", sa.String(), nullable=True),
|
||||||
|
sa.Column("result", sa.String(), nullable=True),
|
||||||
|
sa.Column("result_text", sa.String(), nullable=True),
|
||||||
|
sa.Column("vote_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("yea_count", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("nay_count", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("not_voting_count", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("present_count", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("bill_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["bill_id"], [f"{schema}.bill.id"], name=op.f("fk_vote_bill_id_bill")),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_vote")),
|
||||||
|
sa.UniqueConstraint("congress", "chamber", "session", "number", name="uq_vote_congress_chamber_session_number"),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
op.create_index("ix_vote_congress_chamber", "vote", ["congress", "chamber"], unique=False, schema=schema)
|
||||||
|
op.create_index("ix_vote_date", "vote", ["vote_date"], unique=False, schema=schema)
|
||||||
|
op.create_table(
|
||||||
|
"vote_record",
|
||||||
|
sa.Column("vote_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("legislator_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("position", sa.String(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["legislator_id"],
|
||||||
|
[f"{schema}.legislator.id"],
|
||||||
|
name=op.f("fk_vote_record_legislator_id_legislator"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["vote_id"], [f"{schema}.vote.id"], name=op.f("fk_vote_record_vote_id_vote"), ondelete="CASCADE"
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("vote_id", "legislator_id", name=op.f("pk_vote_record")),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table("vote_record", schema=schema)
|
||||||
|
op.drop_index("ix_vote_date", table_name="vote", schema=schema)
|
||||||
|
op.drop_index("ix_vote_congress_chamber", table_name="vote", schema=schema)
|
||||||
|
op.drop_table("vote", schema=schema)
|
||||||
|
op.drop_index(op.f("ix_legislator_bioguide_id"), table_name="legislator", schema=schema)
|
||||||
|
op.drop_table("legislator", schema=schema)
|
||||||
|
op.drop_index("ix_bill_congress", table_name="bill", schema=schema)
|
||||||
|
op.drop_table("bill", schema=schema)
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
from python.orm import RichieBase
|
from python.orm import ${config.attributes["base"].__name__}
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
@@ -24,7 +24,7 @@ down_revision: str | None = ${repr(down_revision)}
|
|||||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
schema=RichieBase.schema_name
|
schema=${config.attributes["base"].__name__}.schema_name
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade."""
|
"""Upgrade."""
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""starting van invintory.
|
||||||
|
|
||||||
|
Revision ID: 15e733499804
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-03-08 00:18:20.759720
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from python.orm import VanInventoryBase
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "15e733499804"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
schema = VanInventoryBase.schema_name
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"items",
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("quantity", sa.Float(), nullable=False),
|
||||||
|
sa.Column("unit", sa.String(), nullable=False),
|
||||||
|
sa.Column("category", sa.String(), nullable=True),
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_items")),
|
||||||
|
sa.UniqueConstraint("name", name=op.f("uq_items_name")),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"meals",
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("instructions", sa.String(), nullable=True),
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_meals")),
|
||||||
|
sa.UniqueConstraint("name", name=op.f("uq_meals_name")),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"meal_ingredients",
|
||||||
|
sa.Column("meal_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("item_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("quantity_needed", sa.Float(), nullable=False),
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["item_id"], [f"{schema}.items.id"], name=op.f("fk_meal_ingredients_item_id_items")),
|
||||||
|
sa.ForeignKeyConstraint(["meal_id"], [f"{schema}.meals.id"], name=op.f("fk_meal_ingredients_meal_id_meals")),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_meal_ingredients")),
|
||||||
|
sa.UniqueConstraint("meal_id", "item_id", name=op.f("uq_meal_ingredients_meal_id")),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table("meal_ingredients", schema=schema)
|
||||||
|
op.drop_table("meals", schema=schema)
|
||||||
|
op.drop_table("items", schema=schema)
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -16,7 +16,7 @@ from fastapi import FastAPI
|
|||||||
|
|
||||||
from python.api.routers import contact_router, create_frontend_router
|
from python.api.routers import contact_router, create_frontend_router
|
||||||
from python.common import configure_logger
|
from python.common import configure_logger
|
||||||
from python.orm.base import get_postgres_engine
|
from python.orm.common import get_postgres_engine
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from python.api.dependencies import DbSession
|
from python.api.dependencies import DbSession
|
||||||
from python.orm.contact import Contact, ContactRelationship, Need, RelationshipType
|
from python.orm.richie.contact import Contact, ContactRelationship, Need, RelationshipType
|
||||||
|
|
||||||
|
|
||||||
class NeedBase(BaseModel):
|
class NeedBase(BaseModel):
|
||||||
|
|||||||
114
python/database_cli.py
Normal file
114
python/database_cli.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""CLI wrapper around alembic for multi-database support.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
database <db_name> <command> [args...]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
database van_inventory upgrade head
|
||||||
|
database van_inventory downgrade head-1
|
||||||
|
database van_inventory revision --autogenerate -m "add meals table"
|
||||||
|
database van_inventory check
|
||||||
|
database richie check
|
||||||
|
database richie upgrade head
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from alembic.config import CommandLine, Config
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DatabaseConfig:
|
||||||
|
"""Configuration for a database."""
|
||||||
|
|
||||||
|
env_prefix: str
|
||||||
|
version_location: str
|
||||||
|
base_module: str
|
||||||
|
base_class_name: str
|
||||||
|
models_module: str
|
||||||
|
script_location: str = "python/alembic"
|
||||||
|
file_template: str = "%%(year)d_%%(month).2d_%%(day).2d-%%(slug)s_%%(rev)s"
|
||||||
|
|
||||||
|
def get_base(self) -> type[DeclarativeBase]:
|
||||||
|
"""Import and return the Base class."""
|
||||||
|
module = import_module(self.base_module)
|
||||||
|
return getattr(module, self.base_class_name)
|
||||||
|
|
||||||
|
def import_models(self) -> None:
|
||||||
|
"""Import ORM models so alembic autogenerate can detect them."""
|
||||||
|
import_module(self.models_module)
|
||||||
|
|
||||||
|
def alembic_config(self) -> Config:
|
||||||
|
"""Build an alembic Config for this database."""
|
||||||
|
# Runtime import needed — Config is in TYPE_CHECKING for the return type annotation
|
||||||
|
from alembic.config import Config as AlembicConfig # noqa: PLC0415
|
||||||
|
|
||||||
|
cfg = AlembicConfig()
|
||||||
|
cfg.set_main_option("script_location", self.script_location)
|
||||||
|
cfg.set_main_option("file_template", self.file_template)
|
||||||
|
cfg.set_main_option("prepend_sys_path", ".")
|
||||||
|
cfg.set_main_option("version_path_separator", "os")
|
||||||
|
cfg.set_main_option("version_locations", self.version_location)
|
||||||
|
cfg.set_main_option("revision_environment", "true")
|
||||||
|
cfg.set_section_option("post_write_hooks", "hooks", "dynamic_schema,ruff")
|
||||||
|
cfg.set_section_option("post_write_hooks", "dynamic_schema.type", "dynamic_schema")
|
||||||
|
cfg.set_section_option("post_write_hooks", "ruff.type", "ruff")
|
||||||
|
cfg.attributes["base"] = self.get_base()
|
||||||
|
cfg.attributes["env_prefix"] = self.env_prefix
|
||||||
|
self.import_models()
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
DATABASES: dict[str, DatabaseConfig] = {
|
||||||
|
"richie": DatabaseConfig(
|
||||||
|
env_prefix="RICHIE",
|
||||||
|
version_location="python/alembic/richie/versions",
|
||||||
|
base_module="python.orm.richie.base",
|
||||||
|
base_class_name="RichieBase",
|
||||||
|
models_module="python.orm.richie.contact",
|
||||||
|
),
|
||||||
|
"van_inventory": DatabaseConfig(
|
||||||
|
env_prefix="VAN_INVENTORY",
|
||||||
|
version_location="python/alembic/van_inventory/versions",
|
||||||
|
base_module="python.orm.van_inventory.base",
|
||||||
|
base_class_name="VanInventoryBase",
|
||||||
|
models_module="python.orm.van_inventory.models",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app = typer.Typer(help="Multi-database alembic wrapper.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(
|
||||||
|
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
||||||
|
)
|
||||||
|
def main(
|
||||||
|
ctx: typer.Context,
|
||||||
|
db_name: Annotated[str, typer.Argument(help=f"Database name. Options: {', '.join(DATABASES)}")],
|
||||||
|
command: Annotated[str, typer.Argument(help="Alembic command (upgrade, downgrade, revision, check, etc.)")],
|
||||||
|
) -> None:
|
||||||
|
"""Run an alembic command against the specified database."""
|
||||||
|
db_config = DATABASES.get(db_name)
|
||||||
|
if not db_config:
|
||||||
|
typer.echo(f"Unknown database: {db_name!r}. Available: {', '.join(DATABASES)}", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
alembic_cfg = db_config.alembic_config()
|
||||||
|
|
||||||
|
cmd_line = CommandLine()
|
||||||
|
options = cmd_line.parser.parse_args([command, *ctx.args])
|
||||||
|
cmd_line.run_cmd(alembic_cfg, options)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
|
|
||||||
@@ -1,22 +1,9 @@
|
|||||||
"""ORM package exports."""
|
"""ORM package exports."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from python.orm.richie.base import RichieBase
|
||||||
|
from python.orm.van_inventory.base import VanInventoryBase
|
||||||
from python.orm.base import RichieBase, TableBase
|
|
||||||
from python.orm.contact import (
|
|
||||||
Contact,
|
|
||||||
ContactNeed,
|
|
||||||
ContactRelationship,
|
|
||||||
Need,
|
|
||||||
RelationshipType,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Contact",
|
|
||||||
"ContactNeed",
|
|
||||||
"ContactRelationship",
|
|
||||||
"Need",
|
|
||||||
"RelationshipType",
|
|
||||||
"RichieBase",
|
"RichieBase",
|
||||||
"TableBase",
|
"VanInventoryBase",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
"""Base ORM definitions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from os import getenv
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, MetaData, create_engine, func
|
|
||||||
from sqlalchemy.engine import URL, Engine
|
|
||||||
from sqlalchemy.ext.declarative import AbstractConcreteBase
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
||||||
|
|
||||||
|
|
||||||
class RichieBase(DeclarativeBase):
|
|
||||||
"""Base class for all ORM models."""
|
|
||||||
|
|
||||||
schema_name = "main"
|
|
||||||
|
|
||||||
metadata = MetaData(
|
|
||||||
schema=schema_name,
|
|
||||||
naming_convention={
|
|
||||||
"ix": "ix_%(table_name)s_%(column_0_name)s",
|
|
||||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
||||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
||||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
||||||
"pk": "pk_%(table_name)s",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TableBase(AbstractConcreteBase, RichieBase):
|
|
||||||
"""Abstract concrete base for tables with IDs and timestamps."""
|
|
||||||
|
|
||||||
__abstract__ = True
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
created: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
server_default=func.now(),
|
|
||||||
)
|
|
||||||
updated: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
server_default=func.now(),
|
|
||||||
onupdate=func.now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_connection_info() -> tuple[str, str, str, str, str | None]:
|
|
||||||
"""Get connection info from environment variables."""
|
|
||||||
database = getenv("POSTGRES_DB")
|
|
||||||
host = getenv("POSTGRES_HOST")
|
|
||||||
port = getenv("POSTGRES_PORT")
|
|
||||||
username = getenv("POSTGRES_USER")
|
|
||||||
password = getenv("POSTGRES_PASSWORD")
|
|
||||||
|
|
||||||
if None in (database, host, port, username):
|
|
||||||
error = f"Missing environment variables for Postgres connection.\n{database=}\n{host=}\n{port=}\n{username=}\n"
|
|
||||||
raise ValueError(error)
|
|
||||||
return cast("tuple[str, str, str, str, str | None]", (database, host, port, username, password))
|
|
||||||
|
|
||||||
|
|
||||||
def get_postgres_engine(*, pool_pre_ping: bool = True) -> Engine:
|
|
||||||
"""Create a SQLAlchemy engine from environment variables."""
|
|
||||||
database, host, port, username, password = get_connection_info()
|
|
||||||
|
|
||||||
url = URL.create(
|
|
||||||
drivername="postgresql+psycopg",
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
host=host,
|
|
||||||
port=int(port),
|
|
||||||
database=database,
|
|
||||||
)
|
|
||||||
|
|
||||||
return create_engine(
|
|
||||||
url=url,
|
|
||||||
pool_pre_ping=pool_pre_ping,
|
|
||||||
pool_recycle=1800,
|
|
||||||
)
|
|
||||||
51
python/orm/common.py
Normal file
51
python/orm/common.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Shared ORM definitions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from os import getenv
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.engine import URL, Engine
|
||||||
|
|
||||||
|
NAMING_CONVENTION = {
|
||||||
|
"ix": "ix_%(table_name)s_%(column_0_name)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection_info(name: str) -> tuple[str, str, str, str, str | None]:
|
||||||
|
"""Get connection info from environment variables."""
|
||||||
|
database = getenv(f"{name}_DB")
|
||||||
|
host = getenv(f"{name}_HOST")
|
||||||
|
port = getenv(f"{name}_PORT")
|
||||||
|
username = getenv(f"{name}_USER")
|
||||||
|
password = getenv(f"{name}_PASSWORD")
|
||||||
|
|
||||||
|
if None in (database, host, port, username):
|
||||||
|
error = f"Missing environment variables for Postgres connection.\n{database=}\n{host=}\n{port=}\n{username=}\n"
|
||||||
|
raise ValueError(error)
|
||||||
|
return cast("tuple[str, str, str, str, str | None]", (database, host, port, username, password))
|
||||||
|
|
||||||
|
|
||||||
|
def get_postgres_engine(*, name: str = "POSTGRES", pool_pre_ping: bool = True) -> Engine:
|
||||||
|
"""Create a SQLAlchemy engine from environment variables."""
|
||||||
|
database, host, port, username, password = get_connection_info(name)
|
||||||
|
|
||||||
|
url = URL.create(
|
||||||
|
drivername="postgresql+psycopg",
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
host=host,
|
||||||
|
port=int(port),
|
||||||
|
database=database,
|
||||||
|
)
|
||||||
|
|
||||||
|
return create_engine(
|
||||||
|
url=url,
|
||||||
|
pool_pre_ping=pool_pre_ping,
|
||||||
|
pool_recycle=1800,
|
||||||
|
)
|
||||||
27
python/orm/richie/__init__.py
Normal file
27
python/orm/richie/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Richie database ORM exports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from python.orm.richie.base import RichieBase, TableBase
|
||||||
|
from python.orm.richie.congress import Bill, Legislator, Vote, VoteRecord
|
||||||
|
from python.orm.richie.contact import (
|
||||||
|
Contact,
|
||||||
|
ContactNeed,
|
||||||
|
ContactRelationship,
|
||||||
|
Need,
|
||||||
|
RelationshipType,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Bill",
|
||||||
|
"Contact",
|
||||||
|
"ContactNeed",
|
||||||
|
"ContactRelationship",
|
||||||
|
"Legislator",
|
||||||
|
"Need",
|
||||||
|
"RelationshipType",
|
||||||
|
"RichieBase",
|
||||||
|
"TableBase",
|
||||||
|
"Vote",
|
||||||
|
"VoteRecord",
|
||||||
|
]
|
||||||
39
python/orm/richie/base.py
Normal file
39
python/orm/richie/base.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Richie database ORM base."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, MetaData, func
|
||||||
|
from sqlalchemy.ext.declarative import AbstractConcreteBase
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
from python.orm.common import NAMING_CONVENTION
|
||||||
|
|
||||||
|
|
||||||
|
class RichieBase(DeclarativeBase):
|
||||||
|
"""Base class for richie database ORM models."""
|
||||||
|
|
||||||
|
schema_name = "main"
|
||||||
|
|
||||||
|
metadata = MetaData(
|
||||||
|
schema=schema_name,
|
||||||
|
naming_convention=NAMING_CONVENTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TableBase(AbstractConcreteBase, RichieBase):
|
||||||
|
"""Abstract concrete base for richie tables with IDs and timestamps."""
|
||||||
|
|
||||||
|
__abstract__ = True
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
created: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
150
python/orm/richie/congress.py
Normal file
150
python/orm/richie/congress.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""Congress Tracker database models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Index, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from python.orm.richie.base import RichieBase, TableBase
|
||||||
|
|
||||||
|
|
||||||
|
class Legislator(TableBase):
|
||||||
|
"""Legislator model - members of Congress."""
|
||||||
|
|
||||||
|
__tablename__ = "legislator"
|
||||||
|
|
||||||
|
# Natural key - bioguide ID is the authoritative identifier
|
||||||
|
bioguide_id: Mapped[str] = mapped_column(Text, unique=True, index=True)
|
||||||
|
|
||||||
|
# Other IDs for cross-referencing
|
||||||
|
thomas_id: Mapped[str | None]
|
||||||
|
lis_id: Mapped[str | None]
|
||||||
|
govtrack_id: Mapped[int | None]
|
||||||
|
opensecrets_id: Mapped[str | None]
|
||||||
|
fec_ids: Mapped[str | None] # JSON array stored as string
|
||||||
|
|
||||||
|
# Name info
|
||||||
|
first_name: Mapped[str]
|
||||||
|
last_name: Mapped[str]
|
||||||
|
official_full_name: Mapped[str | None]
|
||||||
|
nickname: Mapped[str | None]
|
||||||
|
|
||||||
|
# Bio
|
||||||
|
birthday: Mapped[date | None]
|
||||||
|
gender: Mapped[str | None] # M/F
|
||||||
|
|
||||||
|
# Current term info (denormalized for query efficiency)
|
||||||
|
current_party: Mapped[str | None]
|
||||||
|
current_state: Mapped[str | None]
|
||||||
|
current_district: Mapped[int | None] # House only
|
||||||
|
current_chamber: Mapped[str | None] # rep/sen
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
vote_records: Mapped[list[VoteRecord]] = relationship(
|
||||||
|
"VoteRecord",
|
||||||
|
back_populates="legislator",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Bill(TableBase):
|
||||||
|
"""Bill model - legislation introduced in Congress."""
|
||||||
|
|
||||||
|
__tablename__ = "bill"
|
||||||
|
|
||||||
|
# Composite natural key: congress + bill_type + number
|
||||||
|
congress: Mapped[int]
|
||||||
|
bill_type: Mapped[str] # hr, s, hres, sres, hjres, sjres
|
||||||
|
number: Mapped[int]
|
||||||
|
|
||||||
|
# Bill info
|
||||||
|
title: Mapped[str | None]
|
||||||
|
title_short: Mapped[str | None]
|
||||||
|
official_title: Mapped[str | None]
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status: Mapped[str | None]
|
||||||
|
status_at: Mapped[date | None]
|
||||||
|
|
||||||
|
# Sponsor
|
||||||
|
sponsor_bioguide_id: Mapped[str | None]
|
||||||
|
|
||||||
|
# Subjects
|
||||||
|
subjects_top_term: Mapped[str | None]
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
votes: Mapped[list[Vote]] = relationship(
|
||||||
|
"Vote",
|
||||||
|
back_populates="bill",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("congress", "bill_type", "number", name="uq_bill_congress_type_number"),
|
||||||
|
Index("ix_bill_congress", "congress"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Vote(TableBase):
|
||||||
|
"""Vote model - roll call votes in Congress."""
|
||||||
|
|
||||||
|
__tablename__ = "vote"
|
||||||
|
|
||||||
|
# Composite natural key: congress + chamber + session + number
|
||||||
|
congress: Mapped[int]
|
||||||
|
chamber: Mapped[str] # house/senate
|
||||||
|
session: Mapped[int]
|
||||||
|
number: Mapped[int]
|
||||||
|
|
||||||
|
# Vote details
|
||||||
|
vote_type: Mapped[str | None]
|
||||||
|
question: Mapped[str | None]
|
||||||
|
result: Mapped[str | None]
|
||||||
|
result_text: Mapped[str | None]
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
vote_date: Mapped[date]
|
||||||
|
|
||||||
|
# Vote counts (denormalized for efficiency)
|
||||||
|
yea_count: Mapped[int | None]
|
||||||
|
nay_count: Mapped[int | None]
|
||||||
|
not_voting_count: Mapped[int | None]
|
||||||
|
present_count: Mapped[int | None]
|
||||||
|
|
||||||
|
# Related bill (optional - not all votes are on bills)
|
||||||
|
bill_id: Mapped[int | None] = mapped_column(ForeignKey("main.bill.id"))
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
bill: Mapped[Bill | None] = relationship("Bill", back_populates="votes")
|
||||||
|
vote_records: Mapped[list[VoteRecord]] = relationship(
|
||||||
|
"VoteRecord",
|
||||||
|
back_populates="vote",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("congress", "chamber", "session", "number", name="uq_vote_congress_chamber_session_number"),
|
||||||
|
Index("ix_vote_date", "vote_date"),
|
||||||
|
Index("ix_vote_congress_chamber", "congress", "chamber"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VoteRecord(RichieBase):
|
||||||
|
"""Association table: Vote <-> Legislator with position."""
|
||||||
|
|
||||||
|
__tablename__ = "vote_record"
|
||||||
|
|
||||||
|
vote_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("main.vote.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
legislator_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("main.legislator.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
position: Mapped[str] # Yea, Nay, Not Voting, Present
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
vote: Mapped[Vote] = relationship("Vote", back_populates="vote_records")
|
||||||
|
legislator: Mapped[Legislator] = relationship("Legislator", back_populates="vote_records")
|
||||||
@@ -7,7 +7,7 @@ from enum import Enum
|
|||||||
from sqlalchemy import ForeignKey, String
|
from sqlalchemy import ForeignKey, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from python.orm.base import RichieBase, TableBase
|
from python.orm.richie.base import RichieBase, TableBase
|
||||||
|
|
||||||
|
|
||||||
class RelationshipType(str, Enum):
|
class RelationshipType(str, Enum):
|
||||||
1
python/orm/van_inventory/__init__.py
Normal file
1
python/orm/van_inventory/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Van inventory database ORM exports."""
|
||||||
39
python/orm/van_inventory/base.py
Normal file
39
python/orm/van_inventory/base.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Van inventory database ORM base."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, MetaData, func
|
||||||
|
from sqlalchemy.ext.declarative import AbstractConcreteBase
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
from python.orm.common import NAMING_CONVENTION
|
||||||
|
|
||||||
|
|
||||||
|
class VanInventoryBase(DeclarativeBase):
|
||||||
|
"""Base class for van_inventory database ORM models."""
|
||||||
|
|
||||||
|
schema_name = "main"
|
||||||
|
|
||||||
|
metadata = MetaData(
|
||||||
|
schema=schema_name,
|
||||||
|
naming_convention=NAMING_CONVENTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VanTableBase(AbstractConcreteBase, VanInventoryBase):
|
||||||
|
"""Abstract concrete base for van_inventory tables with IDs and timestamps."""
|
||||||
|
|
||||||
|
__abstract__ = True
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
created: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
46
python/orm/van_inventory/models.py
Normal file
46
python/orm/van_inventory/models.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Van inventory ORM models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from python.orm.van_inventory.base import VanTableBase
|
||||||
|
|
||||||
|
|
||||||
|
class Item(VanTableBase):
|
||||||
|
"""A food item in the van."""
|
||||||
|
|
||||||
|
__tablename__ = "items"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(unique=True)
|
||||||
|
quantity: Mapped[float] = mapped_column(default=0)
|
||||||
|
unit: Mapped[str]
|
||||||
|
category: Mapped[str | None]
|
||||||
|
|
||||||
|
meal_ingredients: Mapped[list[MealIngredient]] = relationship(back_populates="item")
|
||||||
|
|
||||||
|
|
||||||
|
class Meal(VanTableBase):
|
||||||
|
"""A meal that can be made from items in the van."""
|
||||||
|
|
||||||
|
__tablename__ = "meals"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(unique=True)
|
||||||
|
instructions: Mapped[str | None]
|
||||||
|
|
||||||
|
ingredients: Mapped[list[MealIngredient]] = relationship(back_populates="meal")
|
||||||
|
|
||||||
|
|
||||||
|
class MealIngredient(VanTableBase):
|
||||||
|
"""Links a meal to the items it requires, with quantities."""
|
||||||
|
|
||||||
|
__tablename__ = "meal_ingredients"
|
||||||
|
__table_args__ = (UniqueConstraint("meal_id", "item_id"),)
|
||||||
|
|
||||||
|
meal_id: Mapped[int] = mapped_column(ForeignKey("meals.id"))
|
||||||
|
item_id: Mapped[int] = mapped_column(ForeignKey("items.id"))
|
||||||
|
quantity_needed: Mapped[float]
|
||||||
|
|
||||||
|
meal: Mapped[Meal] = relationship(back_populates="ingredients")
|
||||||
|
item: Mapped[Item] = relationship(back_populates="meal_ingredients")
|
||||||
1
python/van_inventory/__init__.py
Normal file
1
python/van_inventory/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Van inventory FastAPI application."""
|
||||||
16
python/van_inventory/dependencies.py
Normal file
16
python/van_inventory/dependencies.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""FastAPI dependencies for van inventory."""
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
def get_db(request: Request) -> Iterator[Session]:
|
||||||
|
"""Get database session from app state."""
|
||||||
|
with Session(request.app.state.engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
56
python/van_inventory/main.py
Normal file
56
python/van_inventory/main.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""FastAPI app for van inventory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from python.common import configure_logger
|
||||||
|
from python.orm.common import get_postgres_engine
|
||||||
|
from python.van_inventory.routers import api_router, frontend_router
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""Create and configure the FastAPI application."""
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
app.state.engine = get_postgres_engine(name="VAN_INVENTORY")
|
||||||
|
yield
|
||||||
|
app.state.engine.dispose()
|
||||||
|
|
||||||
|
app = FastAPI(title="Van Inventory", lifespan=lifespan)
|
||||||
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
app.include_router(api_router)
|
||||||
|
app.include_router(frontend_router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def serve(
|
||||||
|
# Intentionally binds all interfaces — this is a LAN-only van server
|
||||||
|
host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")] = "0.0.0.0", # noqa: S104
|
||||||
|
port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 8001,
|
||||||
|
log_level: Annotated[str, typer.Option("--log-level", "-l", help="Log level")] = "INFO",
|
||||||
|
) -> None:
|
||||||
|
"""Start the Van Inventory server."""
|
||||||
|
configure_logger(log_level)
|
||||||
|
app = create_app()
|
||||||
|
uvicorn.run(app, host=host, port=port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
typer.run(serve)
|
||||||
6
python/van_inventory/routers/__init__.py
Normal file
6
python/van_inventory/routers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Van inventory API routers."""
|
||||||
|
|
||||||
|
from python.van_inventory.routers.api import router as api_router
|
||||||
|
from python.van_inventory.routers.frontend import router as frontend_router
|
||||||
|
|
||||||
|
__all__ = ["api_router", "frontend_router"]
|
||||||
314
python/van_inventory/routers/api.py
Normal file
314
python/van_inventory/routers/api.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""Van inventory API router."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from python.orm.van_inventory.models import Item, Meal, MealIngredient
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from python.van_inventory.dependencies import DbSession
|
||||||
|
|
||||||
|
|
||||||
|
# --- Schemas ---
|
||||||
|
|
||||||
|
|
||||||
|
class ItemCreate(BaseModel):
|
||||||
|
"""Schema for creating an item."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
quantity: float = Field(default=0, ge=0)
|
||||||
|
unit: str
|
||||||
|
category: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUpdate(BaseModel):
|
||||||
|
"""Schema for updating an item."""
|
||||||
|
|
||||||
|
name: str | None = None
|
||||||
|
quantity: float | None = Field(default=None, ge=0)
|
||||||
|
unit: str | None = None
|
||||||
|
category: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemResponse(BaseModel):
|
||||||
|
"""Schema for item response."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
quantity: float
|
||||||
|
unit: str
|
||||||
|
category: str | None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientCreate(BaseModel):
|
||||||
|
"""Schema for adding an ingredient to a meal."""
|
||||||
|
|
||||||
|
item_id: int
|
||||||
|
quantity_needed: float = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class MealCreate(BaseModel):
|
||||||
|
"""Schema for creating a meal."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
instructions: str | None = None
|
||||||
|
ingredients: list[IngredientCreate] = []
|
||||||
|
|
||||||
|
|
||||||
|
class MealUpdate(BaseModel):
|
||||||
|
"""Schema for updating a meal."""
|
||||||
|
|
||||||
|
name: str | None = None
|
||||||
|
instructions: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientResponse(BaseModel):
|
||||||
|
"""Schema for ingredient response."""
|
||||||
|
|
||||||
|
item_id: int
|
||||||
|
item_name: str
|
||||||
|
quantity_needed: float
|
||||||
|
unit: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class MealResponse(BaseModel):
|
||||||
|
"""Schema for meal response."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
instructions: str | None
|
||||||
|
ingredients: list[IngredientResponse] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_meal(cls, meal: Meal) -> MealResponse:
|
||||||
|
"""Build a MealResponse from an ORM Meal with loaded ingredients."""
|
||||||
|
return cls(
|
||||||
|
id=meal.id,
|
||||||
|
name=meal.name,
|
||||||
|
instructions=meal.instructions,
|
||||||
|
ingredients=[
|
||||||
|
IngredientResponse(
|
||||||
|
item_id=mi.item_id,
|
||||||
|
item_name=mi.item.name,
|
||||||
|
quantity_needed=mi.quantity_needed,
|
||||||
|
unit=mi.item.unit,
|
||||||
|
)
|
||||||
|
for mi in meal.ingredients
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingItem(BaseModel):
|
||||||
|
"""An item needed for a meal that is short on stock."""
|
||||||
|
|
||||||
|
item_name: str
|
||||||
|
unit: str
|
||||||
|
needed: float
|
||||||
|
have: float
|
||||||
|
short: float
|
||||||
|
|
||||||
|
|
||||||
|
class MealAvailability(BaseModel):
|
||||||
|
"""Availability status for a meal."""
|
||||||
|
|
||||||
|
meal_id: int
|
||||||
|
meal_name: str
|
||||||
|
can_make: bool
|
||||||
|
missing: list[ShoppingItem] = []
|
||||||
|
|
||||||
|
|
||||||
|
# --- Routes ---
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["van_inventory"])
|
||||||
|
|
||||||
|
|
||||||
|
# Items
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items", response_model=ItemResponse)
|
||||||
|
def create_item(item: ItemCreate, db: DbSession) -> Item:
|
||||||
|
"""Create a new inventory item."""
|
||||||
|
db_item = Item(**item.model_dump())
|
||||||
|
db.add(db_item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_item)
|
||||||
|
return db_item
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items", response_model=list[ItemResponse])
|
||||||
|
def list_items(db: DbSession) -> list[Item]:
|
||||||
|
"""List all inventory items."""
|
||||||
|
return list(db.scalars(select(Item).order_by(Item.name)).all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/{item_id}", response_model=ItemResponse)
|
||||||
|
def get_item(item_id: int, db: DbSession) -> Item:
|
||||||
|
"""Get an item by ID."""
|
||||||
|
item = db.get(Item, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/items/{item_id}", response_model=ItemResponse)
|
||||||
|
def update_item(item_id: int, item: ItemUpdate, db: DbSession) -> Item:
|
||||||
|
"""Update an item by ID."""
|
||||||
|
db_item = db.get(Item, item_id)
|
||||||
|
if not db_item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
for key, value in item.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(db_item, key, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_item)
|
||||||
|
return db_item
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/items/{item_id}")
|
||||||
|
def delete_item(item_id: int, db: DbSession) -> dict[str, bool]:
|
||||||
|
"""Delete an item by ID."""
|
||||||
|
item = db.get(Item, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
db.delete(item)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
# Meals
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meals", response_model=MealResponse)
|
||||||
|
def create_meal(meal: MealCreate, db: DbSession) -> MealResponse:
|
||||||
|
"""Create a new meal with optional ingredients."""
|
||||||
|
for ing in meal.ingredients:
|
||||||
|
if not db.get(Item, ing.item_id):
|
||||||
|
raise HTTPException(status_code=422, detail=f"Item {ing.item_id} not found")
|
||||||
|
db_meal = Meal(name=meal.name, instructions=meal.instructions)
|
||||||
|
db.add(db_meal)
|
||||||
|
db.flush()
|
||||||
|
for ing in meal.ingredients:
|
||||||
|
db.add(MealIngredient(meal_id=db_meal.id, item_id=ing.item_id, quantity_needed=ing.quantity_needed))
|
||||||
|
db.commit()
|
||||||
|
db_meal = db.scalar(
|
||||||
|
select(Meal)
|
||||||
|
.where(Meal.id == db_meal.id)
|
||||||
|
.options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
||||||
|
)
|
||||||
|
return MealResponse.from_meal(db_meal)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meals", response_model=list[MealResponse])
|
||||||
|
def list_meals(db: DbSession) -> list[MealResponse]:
|
||||||
|
"""List all meals with ingredients."""
|
||||||
|
meals = list(
|
||||||
|
db.scalars(
|
||||||
|
select(Meal).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item)).order_by(Meal.name)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
return [MealResponse.from_meal(m) for m in meals]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meals/availability", response_model=list[MealAvailability])
|
||||||
|
def check_all_meals(db: DbSession) -> list[MealAvailability]:
|
||||||
|
"""Check which meals can be made with current inventory."""
|
||||||
|
meals = list(
|
||||||
|
db.scalars(select(Meal).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))).all()
|
||||||
|
)
|
||||||
|
return [_check_meal(m) for m in meals]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meals/{meal_id}", response_model=MealResponse)
|
||||||
|
def get_meal(meal_id: int, db: DbSession) -> MealResponse:
|
||||||
|
"""Get a meal by ID with ingredients."""
|
||||||
|
meal = db.scalar(
|
||||||
|
select(Meal).where(Meal.id == meal_id).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
||||||
|
)
|
||||||
|
if not meal:
|
||||||
|
raise HTTPException(status_code=404, detail="Meal not found")
|
||||||
|
return MealResponse.from_meal(meal)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/meals/{meal_id}")
|
||||||
|
def delete_meal(meal_id: int, db: DbSession) -> dict[str, bool]:
|
||||||
|
"""Delete a meal by ID."""
|
||||||
|
meal = db.get(Meal, meal_id)
|
||||||
|
if not meal:
|
||||||
|
raise HTTPException(status_code=404, detail="Meal not found")
|
||||||
|
db.delete(meal)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meals/{meal_id}/ingredients", response_model=MealResponse)
|
||||||
|
def add_ingredient(meal_id: int, ingredient: IngredientCreate, db: DbSession) -> MealResponse:
|
||||||
|
"""Add an ingredient to a meal."""
|
||||||
|
meal = db.get(Meal, meal_id)
|
||||||
|
if not meal:
|
||||||
|
raise HTTPException(status_code=404, detail="Meal not found")
|
||||||
|
if not db.get(Item, ingredient.item_id):
|
||||||
|
raise HTTPException(status_code=422, detail="Item not found")
|
||||||
|
existing = db.scalar(
|
||||||
|
select(MealIngredient).where(MealIngredient.meal_id == meal_id, MealIngredient.item_id == ingredient.item_id)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="Ingredient already exists for this meal")
|
||||||
|
db.add(MealIngredient(meal_id=meal_id, item_id=ingredient.item_id, quantity_needed=ingredient.quantity_needed))
|
||||||
|
db.commit()
|
||||||
|
meal = db.scalar(
|
||||||
|
select(Meal).where(Meal.id == meal_id).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
||||||
|
)
|
||||||
|
return MealResponse.from_meal(meal)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/meals/{meal_id}/ingredients/{item_id}")
|
||||||
|
def remove_ingredient(meal_id: int, item_id: int, db: DbSession) -> dict[str, bool]:
|
||||||
|
"""Remove an ingredient from a meal."""
|
||||||
|
mi = db.scalar(select(MealIngredient).where(MealIngredient.meal_id == meal_id, MealIngredient.item_id == item_id))
|
||||||
|
if not mi:
|
||||||
|
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||||
|
db.delete(mi)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meals/{meal_id}/availability", response_model=MealAvailability)
|
||||||
|
def check_meal(meal_id: int, db: DbSession) -> MealAvailability:
|
||||||
|
"""Check if a specific meal can be made and what's missing."""
|
||||||
|
meal = db.scalar(
|
||||||
|
select(Meal).where(Meal.id == meal_id).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
||||||
|
)
|
||||||
|
if not meal:
|
||||||
|
raise HTTPException(status_code=404, detail="Meal not found")
|
||||||
|
return _check_meal(meal)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_meal(meal: Meal) -> MealAvailability:
|
||||||
|
missing = [
|
||||||
|
ShoppingItem(
|
||||||
|
item_name=mi.item.name,
|
||||||
|
unit=mi.item.unit,
|
||||||
|
needed=mi.quantity_needed,
|
||||||
|
have=mi.item.quantity,
|
||||||
|
short=mi.quantity_needed - mi.item.quantity,
|
||||||
|
)
|
||||||
|
for mi in meal.ingredients
|
||||||
|
if mi.item.quantity < mi.quantity_needed
|
||||||
|
]
|
||||||
|
return MealAvailability(
|
||||||
|
meal_id=meal.id,
|
||||||
|
meal_name=meal.name,
|
||||||
|
can_make=len(missing) == 0,
|
||||||
|
missing=missing,
|
||||||
|
)
|
||||||
198
python/van_inventory/routers/frontend.py
Normal file
198
python/van_inventory/routers/frontend.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""HTMX frontend routes for van inventory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from python.orm.van_inventory.models import Item, Meal, MealIngredient
|
||||||
|
|
||||||
|
# FastAPI needs DbSession at runtime to resolve the Depends() annotation
|
||||||
|
from python.van_inventory.dependencies import DbSession # noqa: TC001
|
||||||
|
from python.van_inventory.routers.api import _check_meal
|
||||||
|
|
||||||
|
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||||
|
templates = Jinja2Templates(directory=TEMPLATE_DIR)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["frontend"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Items ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
def items_page(request: Request, db: DbSession) -> HTMLResponse:
|
||||||
|
"""Render the inventory page."""
|
||||||
|
items = list(db.scalars(select(Item).order_by(Item.name)).all())
|
||||||
|
return templates.TemplateResponse(request, "items.html", {"items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items", response_class=HTMLResponse)
|
||||||
|
def htmx_create_item(
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
name: Annotated[str, Form()],
|
||||||
|
quantity: Annotated[float, Form()] = 0,
|
||||||
|
unit: Annotated[str, Form()] = "",
|
||||||
|
category: Annotated[str | None, Form()] = None,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Create an item and return updated item rows."""
|
||||||
|
if quantity < 0:
|
||||||
|
raise HTTPException(status_code=422, detail="Quantity must not be negative")
|
||||||
|
db.add(Item(name=name, quantity=quantity, unit=unit, category=category or None))
|
||||||
|
db.commit()
|
||||||
|
items = list(db.scalars(select(Item).order_by(Item.name)).all())
|
||||||
|
return templates.TemplateResponse(request, "partials/item_rows.html", {"items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/items/{item_id}", response_class=HTMLResponse)
|
||||||
|
def htmx_update_item(
|
||||||
|
request: Request,
|
||||||
|
item_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
quantity: Annotated[float, Form()],
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Update an item's quantity and return updated item rows."""
|
||||||
|
if quantity < 0:
|
||||||
|
raise HTTPException(status_code=422, detail="Quantity must not be negative")
|
||||||
|
item = db.get(Item, item_id)
|
||||||
|
if item:
|
||||||
|
item.quantity = quantity
|
||||||
|
db.commit()
|
||||||
|
items = list(db.scalars(select(Item).order_by(Item.name)).all())
|
||||||
|
return templates.TemplateResponse(request, "partials/item_rows.html", {"items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/items/{item_id}", response_class=HTMLResponse)
|
||||||
|
def htmx_delete_item(request: Request, item_id: int, db: DbSession) -> HTMLResponse:
|
||||||
|
"""Delete an item and return updated item rows."""
|
||||||
|
item = db.get(Item, item_id)
|
||||||
|
if item:
|
||||||
|
db.delete(item)
|
||||||
|
db.commit()
|
||||||
|
items = list(db.scalars(select(Item).order_by(Item.name)).all())
|
||||||
|
return templates.TemplateResponse(request, "partials/item_rows.html", {"items": items})
|
||||||
|
|
||||||
|
|
||||||
|
# --- Meals ---
|
||||||
|
|
||||||
|
|
||||||
|
def _load_meals(db: DbSession) -> list[Meal]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(Meal).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item)).order_by(Meal.name)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meals", response_class=HTMLResponse)
|
||||||
|
def meals_page(request: Request, db: DbSession) -> HTMLResponse:
|
||||||
|
"""Render the meals page."""
|
||||||
|
meals = _load_meals(db)
|
||||||
|
return templates.TemplateResponse(request, "meals.html", {"meals": meals})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meals", response_class=HTMLResponse)
|
||||||
|
def htmx_create_meal(
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
name: Annotated[str, Form()],
|
||||||
|
instructions: Annotated[str | None, Form()] = None,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Create a meal and return updated meal rows."""
|
||||||
|
db.add(Meal(name=name, instructions=instructions or None))
|
||||||
|
db.commit()
|
||||||
|
meals = _load_meals(db)
|
||||||
|
return templates.TemplateResponse(request, "partials/meal_rows.html", {"meals": meals})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/meals/{meal_id}", response_class=HTMLResponse)
|
||||||
|
def htmx_delete_meal(request: Request, meal_id: int, db: DbSession) -> HTMLResponse:
|
||||||
|
"""Delete a meal and return updated meal rows."""
|
||||||
|
meal = db.get(Meal, meal_id)
|
||||||
|
if meal:
|
||||||
|
db.delete(meal)
|
||||||
|
db.commit()
|
||||||
|
meals = _load_meals(db)
|
||||||
|
return templates.TemplateResponse(request, "partials/meal_rows.html", {"meals": meals})
|
||||||
|
|
||||||
|
|
||||||
|
# --- Meal detail ---
|
||||||
|
|
||||||
|
|
||||||
|
def _load_meal(db: DbSession, meal_id: int) -> Meal | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(Meal).where(Meal.id == meal_id).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meals/{meal_id}", response_class=HTMLResponse)
|
||||||
|
def meal_detail_page(request: Request, meal_id: int, db: DbSession) -> HTMLResponse:
|
||||||
|
"""Render the meal detail page."""
|
||||||
|
meal = _load_meal(db, meal_id)
|
||||||
|
if not meal:
|
||||||
|
raise HTTPException(status_code=404, detail="Meal not found")
|
||||||
|
items = list(db.scalars(select(Item).order_by(Item.name)).all())
|
||||||
|
return templates.TemplateResponse(request, "meal_detail.html", {"meal": meal, "items": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meals/{meal_id}/ingredients", response_class=HTMLResponse)
|
||||||
|
def htmx_add_ingredient(
|
||||||
|
request: Request,
|
||||||
|
meal_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
item_id: Annotated[int, Form()],
|
||||||
|
quantity_needed: Annotated[float, Form()],
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Add an ingredient to a meal and return updated ingredient rows."""
|
||||||
|
if quantity_needed <= 0:
|
||||||
|
raise HTTPException(status_code=422, detail="Quantity must be positive")
|
||||||
|
meal = db.get(Meal, meal_id)
|
||||||
|
if not meal:
|
||||||
|
raise HTTPException(status_code=404, detail="Meal not found")
|
||||||
|
if not db.get(Item, item_id):
|
||||||
|
raise HTTPException(status_code=422, detail="Item not found")
|
||||||
|
existing = db.scalar(
|
||||||
|
select(MealIngredient).where(MealIngredient.meal_id == meal_id, MealIngredient.item_id == item_id)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="Ingredient already exists for this meal")
|
||||||
|
db.add(MealIngredient(meal_id=meal_id, item_id=item_id, quantity_needed=quantity_needed))
|
||||||
|
db.commit()
|
||||||
|
meal = _load_meal(db, meal_id)
|
||||||
|
return templates.TemplateResponse(request, "partials/ingredient_rows.html", {"meal": meal})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/meals/{meal_id}/ingredients/{item_id}", response_class=HTMLResponse)
|
||||||
|
def htmx_remove_ingredient(
|
||||||
|
request: Request,
|
||||||
|
meal_id: int,
|
||||||
|
item_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Remove an ingredient from a meal and return updated ingredient rows."""
|
||||||
|
mi = db.scalar(select(MealIngredient).where(MealIngredient.meal_id == meal_id, MealIngredient.item_id == item_id))
|
||||||
|
if mi:
|
||||||
|
db.delete(mi)
|
||||||
|
db.commit()
|
||||||
|
meal = _load_meal(db, meal_id)
|
||||||
|
return templates.TemplateResponse(request, "partials/ingredient_rows.html", {"meal": meal})
|
||||||
|
|
||||||
|
|
||||||
|
# --- Availability ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/availability", response_class=HTMLResponse)
|
||||||
|
def availability_page(request: Request, db: DbSession) -> HTMLResponse:
|
||||||
|
"""Render the meal availability page."""
|
||||||
|
meals = list(
|
||||||
|
db.scalars(select(Meal).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))).all()
|
||||||
|
)
|
||||||
|
availability = [_check_meal(m) for m in meals]
|
||||||
|
return templates.TemplateResponse(request, "availability.html", {"availability": availability})
|
||||||
212
python/van_inventory/static/style.css
Normal file
212
python/van_inventory/static/style.css
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
:root {
|
||||||
|
--neon-pink: #ff2a6d;
|
||||||
|
--neon-cyan: #05d9e8;
|
||||||
|
--neon-yellow: #f9f002;
|
||||||
|
--neon-purple: #d300c5;
|
||||||
|
--bg-dark: #0a0a0f;
|
||||||
|
--bg-panel: #0d0d1a;
|
||||||
|
--bg-input: #111128;
|
||||||
|
--border: #1a1a3e;
|
||||||
|
--text: #c0c0d0;
|
||||||
|
--text-dim: #8e8ea0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scanline overlay */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(0, 0, 0, 0.08) 2px,
|
||||||
|
rgba(0, 0, 0, 0.08) 4px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
text-shadow: 0 0 10px rgba(5, 217, 232, 0.5), 0 0 40px rgba(5, 217, 232, 0.2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--neon-pink); text-decoration: none; transition: all 0.2s; }
|
||||||
|
a:hover {
|
||||||
|
text-shadow: 0 0 8px rgba(255, 42, 109, 0.8), 0 0 20px rgba(255, 42, 109, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
nav::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--neon-pink), var(--neon-cyan), var(--neon-purple));
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
nav a:hover {
|
||||||
|
border-bottom-color: var(--neon-pink);
|
||||||
|
text-shadow: 0 0 8px rgba(255, 42, 109, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--neon-cyan);
|
||||||
|
text-shadow: 0 0 6px rgba(5, 217, 232, 0.3);
|
||||||
|
}
|
||||||
|
tr:hover td {
|
||||||
|
background: rgba(5, 217, 232, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: end;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 8px rgba(5, 217, 232, 0.3), inset 0 0 8px rgba(5, 217, 232, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1.2rem;
|
||||||
|
border: 1px solid var(--neon-pink);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--neon-pink);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: var(--neon-pink);
|
||||||
|
color: var(--bg-dark);
|
||||||
|
box-shadow: 0 0 15px rgba(255, 42, 109, 0.5), 0 0 30px rgba(255, 42, 109, 0.2);
|
||||||
|
}
|
||||||
|
button.danger {
|
||||||
|
border-color: var(--text-dim);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
button.danger:hover {
|
||||||
|
border-color: var(--neon-pink);
|
||||||
|
background: var(--neon-pink);
|
||||||
|
color: var(--bg-dark);
|
||||||
|
box-shadow: 0 0 15px rgba(255, 42, 109, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.badge.yes {
|
||||||
|
background: rgba(5, 217, 232, 0.1);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
border: 1px solid var(--neon-cyan);
|
||||||
|
text-shadow: 0 0 6px rgba(5, 217, 232, 0.5);
|
||||||
|
}
|
||||||
|
.badge.no {
|
||||||
|
background: rgba(255, 42, 109, 0.1);
|
||||||
|
color: var(--neon-pink);
|
||||||
|
border: 1px solid var(--neon-pink);
|
||||||
|
text-shadow: 0 0 6px rgba(255, 42, 109, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing-list { font-size: 0.85rem; color: var(--text-dim); }
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(5, 217, 232, 0.1);
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
border: 1px solid var(--neon-cyan);
|
||||||
|
}
|
||||||
30
python/van_inventory/templates/availability.html
Normal file
30
python/van_inventory/templates/availability.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}What Can I Make? - Van{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>What Can I Make?</h1>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Meal</th><th>Status</th><th>Missing</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for meal in availability %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/meals/{{ meal.meal_id }}">{{ meal.meal_name }}</a></td>
|
||||||
|
<td>
|
||||||
|
{% if meal.can_make %}
|
||||||
|
<span class="badge yes">Ready</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge no">Missing items</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="missing-list">
|
||||||
|
{% for m in meal.missing %}
|
||||||
|
{{ m.item_name }}: need {{ m.short }} more {{ m.unit }}{% if not loop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
20
python/van_inventory/templates/base.html
Normal file
20
python/van_inventory/templates/base.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Van Inventory{% endblock %}</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Inventory</a>
|
||||||
|
<a href="/meals">Meals</a>
|
||||||
|
<a href="/availability">What Can I Make?</a>
|
||||||
|
</nav>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
python/van_inventory/templates/items.html
Normal file
17
python/van_inventory/templates/items.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Inventory - Van{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Van Inventory</h1>
|
||||||
|
|
||||||
|
<form hx-post="/items" hx-target="#item-list" hx-swap="innerHTML" hx-on::after-request="if(event.detail.successful) this.reset()">
|
||||||
|
<label>Name <input type="text" name="name" required></label>
|
||||||
|
<label>Qty <input type="number" name="quantity" step="any" value="0" min="0" required></label>
|
||||||
|
<label>Unit <input type="text" name="unit" required placeholder="lbs, cans, etc"></label>
|
||||||
|
<label>Category <input type="text" name="category" placeholder="optional"></label>
|
||||||
|
<button type="submit">Add Item</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="item-list">
|
||||||
|
{% include "partials/item_rows.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
24
python/van_inventory/templates/meal_detail.html
Normal file
24
python/van_inventory/templates/meal_detail.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ meal.name }} - Van{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ meal.name }}</h1>
|
||||||
|
{% if meal.instructions %}<p>{{ meal.instructions }}</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Ingredients</h2>
|
||||||
|
<form hx-post="/meals/{{ meal.id }}/ingredients" hx-target="#ingredient-list" hx-swap="innerHTML" hx-on::after-request="if(event.detail.successful) this.reset()">
|
||||||
|
<label>Item
|
||||||
|
<select name="item_id" required>
|
||||||
|
<option value="">--</option>
|
||||||
|
{% for item in items %}
|
||||||
|
<option value="{{ item.id }}">{{ item.name }} ({{ item.unit }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Qty needed <input type="number" name="quantity_needed" step="any" min="0.01" required></label>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="ingredient-list">
|
||||||
|
{% include "partials/ingredient_rows.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
15
python/van_inventory/templates/meals.html
Normal file
15
python/van_inventory/templates/meals.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Meals - Van{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Meals</h1>
|
||||||
|
|
||||||
|
<form hx-post="/meals" hx-target="#meal-list" hx-swap="innerHTML" hx-on::after-request="if(event.detail.successful) this.reset()">
|
||||||
|
<label>Name <input type="text" name="name" required></label>
|
||||||
|
<label>Instructions <input type="text" name="instructions" placeholder="optional"></label>
|
||||||
|
<button type="submit">Add Meal</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="meal-list">
|
||||||
|
{% include "partials/meal_rows.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
16
python/van_inventory/templates/partials/ingredient_rows.html
Normal file
16
python/van_inventory/templates/partials/ingredient_rows.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Item</th><th>Needed</th><th>Have</th><th>Unit</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for mi in meal.ingredients %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ mi.item.name }}</td>
|
||||||
|
<td>{{ mi.quantity_needed }}</td>
|
||||||
|
<td>{{ mi.item.quantity }}</td>
|
||||||
|
<td>{{ mi.item.unit }}</td>
|
||||||
|
<td><button class="danger" hx-delete="/meals/{{ meal.id }}/ingredients/{{ mi.item_id }}" hx-target="#ingredient-list" hx-swap="innerHTML" hx-confirm="Remove {{ mi.item.name }}?">X</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
21
python/van_inventory/templates/partials/item_rows.html
Normal file
21
python/van_inventory/templates/partials/item_rows.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Qty</th><th>Unit</th><th>Category</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td>
|
||||||
|
<form hx-patch="/items/{{ item.id }}" hx-target="#item-list" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||||
|
<input type="number" name="quantity" value="{{ item.quantity }}" step="any" min="0" style="width:5rem">
|
||||||
|
<button type="submit" style="padding:0.2rem 0.5rem; font-size:0.8rem;">Update</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.unit }}</td>
|
||||||
|
<td>{{ item.category or "" }}</td>
|
||||||
|
<td><button class="danger" hx-delete="/items/{{ item.id }}" hx-target="#item-list" hx-swap="innerHTML" hx-confirm="Delete {{ item.name }}?">X</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
15
python/van_inventory/templates/partials/meal_rows.html
Normal file
15
python/van_inventory/templates/partials/meal_rows.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Ingredients</th><th>Instructions</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for meal in meals %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/meals/{{ meal.id }}">{{ meal.name }}</a></td>
|
||||||
|
<td>{{ meal.ingredients | length }}</td>
|
||||||
|
<td>{{ (meal.instructions or "")[:50] }}</td>
|
||||||
|
<td><button class="danger" hx-delete="/meals/{{ meal.id }}" hx-target="#meal-list" hx-swap="innerHTML" hx-confirm="Delete {{ meal.name }}?">X</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
authentication = pkgs.lib.mkOverride 10 ''
|
authentication = pkgs.lib.mkOverride 10 ''
|
||||||
|
|
||||||
# admins
|
# admins
|
||||||
|
# These are required for the nixos postgresql setup
|
||||||
local all postgres trust
|
local all postgres trust
|
||||||
host all postgres 127.0.0.1/32 trust
|
host all postgres 127.0.0.1/32 trust
|
||||||
host all postgres ::1/128 trust
|
host all postgres ::1/128 trust
|
||||||
@@ -21,6 +22,8 @@
|
|||||||
host all richie 192.168.90.1/24 trust
|
host all richie 192.168.90.1/24 trust
|
||||||
host all richie 192.168.99.1/24 trust
|
host all richie 192.168.99.1/24 trust
|
||||||
|
|
||||||
|
local vaninventory vaninventory trust
|
||||||
|
|
||||||
#type database DBuser origin-address auth-method
|
#type database DBuser origin-address auth-method
|
||||||
local hass hass trust
|
local hass hass trust
|
||||||
|
|
||||||
@@ -62,6 +65,13 @@
|
|||||||
replication = true;
|
replication = true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
name = "vaninventory";
|
||||||
|
ensureDBOwnership = true;
|
||||||
|
ensureClauses = {
|
||||||
|
login = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
{
|
{
|
||||||
name = "hass";
|
name = "hass";
|
||||||
ensureDBOwnership = true;
|
ensureDBOwnership = true;
|
||||||
@@ -76,6 +86,7 @@
|
|||||||
ensureDatabases = [
|
ensureDatabases = [
|
||||||
"hass"
|
"hass"
|
||||||
"richie"
|
"richie"
|
||||||
|
"vaninventory"
|
||||||
];
|
];
|
||||||
# Thank you NotAShelf
|
# Thank you NotAShelf
|
||||||
# https://github.com/NotAShelf/nyx/blob/d407b4d6e5ab7f60350af61a3d73a62a5e9ac660/modules/core/roles/server/system/services/databases/postgresql.nix#L74
|
# https://github.com/NotAShelf/nyx/blob/d407b4d6e5ab7f60350af61a3d73a62a5e9ac660/modules/core/roles/server/system/services/databases/postgresql.nix#L74
|
||||||
|
|||||||
48
systems/brain/services/van_inventory.nix
Normal file
48
systems/brain/services/van_inventory.nix
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
inputs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
networking.firewall.allowedTCPPorts = [ 8001 ];
|
||||||
|
|
||||||
|
users.users.vaninventory = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "vaninventory";
|
||||||
|
};
|
||||||
|
users.groups.vaninventory = { };
|
||||||
|
|
||||||
|
systemd.services.van_inventory = {
|
||||||
|
description = "Van Inventory API";
|
||||||
|
after = [
|
||||||
|
"network.target"
|
||||||
|
"postgresql.service"
|
||||||
|
];
|
||||||
|
requires = [ "postgresql.service" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
PYTHONPATH = "${inputs.self}/";
|
||||||
|
VAN_INVENTORY_DB = "vaninventory";
|
||||||
|
VAN_INVENTORY_USER = "vaninventory";
|
||||||
|
VAN_INVENTORY_HOST = "/run/postgresql";
|
||||||
|
VAN_INVENTORY_PORT = "5432";
|
||||||
|
};
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = "van-inventory";
|
||||||
|
Group = "van-inventory";
|
||||||
|
ExecStart = "${pkgs.my_python}/bin/python -m python.van_inventory.main --host 0.0.0.0 --port 8001";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "5s";
|
||||||
|
StandardOutput = "journal";
|
||||||
|
StandardError = "journal";
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = "read-only";
|
||||||
|
PrivateTmp = true;
|
||||||
|
ReadOnlyPaths = [ "${inputs.self}" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user