diff --git a/.agents/skills/fastapi/SKILL.md b/.agents/skills/fastapi/SKILL.md new file mode 100644 index 00000000..e4268975 --- /dev/null +++ b/.agents/skills/fastapi/SKILL.md @@ -0,0 +1,321 @@ +--- +name: fastapi +description: FastAPI best practices and conventions. Use when working with FastAPI APIs, Pydantic models, dependencies, streaming responses including Server-Sent Events (SSE), and serving frontend apps. Keeps FastAPI code clean and up to date with the latest features and patterns. +--- + +# FastAPI + +Official FastAPI skill to write code with best practices, keeping up to date with new versions and features. + +## Quick Reference + +* Serve frontend apps: use `app.frontend()` or `router.frontend()` for built frontend assets; see [Serve Frontend Apps](#serve-frontend-apps). +* Server-Sent Events (SSE): use `response_class=EventSourceResponse` and `yield`; see [Streaming](#streaming-json-lines-sse-bytes) and [the streaming reference](references/streaming.md). +* JSON Lines and byte streaming: see [the streaming reference](references/streaming.md). +* Dependencies: use `Annotated[..., Depends(...)]`; see [Dependency Injection](#dependency-injection) and [the dependency injection reference](references/dependencies.md) for `yield`, scopes, and class dependencies. +* Response models: prefer return types; use `response_model` when the public response schema differs from the internal return value; see [the response reference](references/responses.md). +* Pydantic models: do not use ellipsis or `RootModel`; see [the Pydantic reference](references/pydantic.md). +* Routing: declare router-level prefix, tags, and shared dependencies on the `APIRouter`; see [the path operation reference](references/path-operations.md). +* Tooling and related libraries: use uv, Ruff, ty, Asyncer, SQLModel, and HTTPX when applicable; see [the other tools reference](references/other-tools.md). + +## Use the `fastapi` CLI + +Run the development server on localhost with reload: + +```bash +fastapi dev +``` + +Run the production server: + +```bash +fastapi run +``` + +Prefer declaring the entrypoint in `pyproject.toml`: + +```toml +[tool.fastapi] +entrypoint = "my_app.main:app" +``` + +When adding the entrypoint is not possible, or the user explicitly asks not to, pass the app file path: + +```bash +fastapi dev my_app/main.py +``` + +## Use `Annotated` + +Always prefer the `Annotated` style for parameter and dependency declarations. It keeps function signatures working in other contexts, respects the types, and allows reusability. + +Use `Annotated` for parameter declarations, including `Path`, `Query`, `Header`, etc.: + +```python +from typing import Annotated + +from fastapi import FastAPI, Path, Query + +app = FastAPI() + + +@app.get("/items/{item_id}") +async def read_item( + item_id: Annotated[int, Path(ge=1, description="The item ID")], + q: Annotated[str | None, Query(max_length=50)] = None, +): + return {"message": "Hello World"} +``` + +Use `Annotated` for dependencies with `Depends()`. Unless asked not to, create a new type alias for the dependency to allow reusing it: + +```python +from typing import Annotated + +from fastapi import Depends, FastAPI + +app = FastAPI() + + +def get_current_user(): + return {"username": "johndoe"} + + +CurrentUserDep = Annotated[dict, Depends(get_current_user)] + + +@app.get("/items/") +async def read_item(current_user: CurrentUserDep): + return {"message": "Hello World"} +``` + +## Do not use Ellipsis for *path operations* or Pydantic models + +Do not use `...` as a default value for required parameters or model fields. It's not needed and not recommended. + +```python +from typing import Annotated + +from fastapi import FastAPI, Query +from pydantic import BaseModel, Field + +app = FastAPI() + + +class Item(BaseModel): + name: str + description: str | None = None + price: float = Field(gt=0) + + +@app.post("/items/") +async def create_item(item: Item, project_id: Annotated[int, Query()]): + return item +``` + +See [the Pydantic reference](references/pydantic.md) for more details. + +## Return Type or Response Model + +When possible, include a return type. It will be used to validate, filter, document, and serialize the response. + +```python +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + description: str | None = None + + +@app.get("/items/me") +async def get_item() -> Item: + return Item(name="Plumbus", description="All-purpose home device") +``` + +Return types or response models filter data to avoid exposing sensitive information, and they let Pydantic serialize the data on the Rust side for performance. + +Use `response_model` when the type you return is not the same as the public schema you want to validate, filter, document, and serialize. See [the response reference](references/responses.md). + +## Performance + +Do not use `ORJSONResponse` or `UJSONResponse`, they are deprecated. + +Instead, declare a return type or response model. Pydantic will handle the data serialization on the Rust side. + +## Including Routers + +When declaring routers, prefer to add router-level parameters like prefix, tags, and shared dependencies to the router itself instead of in `include_router()`. + +```python +from fastapi import APIRouter, Depends, FastAPI + +app = FastAPI() + + +def get_current_user(): + return {"username": "johndoe"} + + +router = APIRouter( + prefix="/items", + tags=["items"], + dependencies=[Depends(get_current_user)], +) + + +@router.get("/") +async def list_items(): + return [] + + +app.include_router(router) +``` + +See [the path operation reference](references/path-operations.md) for more routing patterns. + +## Serve Frontend Apps + +Use `app.frontend()` to serve a built static frontend app, for example a directory generated by Vite, Astro, Angular, Svelte, Vue, or a similar tool. + +```python +from fastapi import FastAPI + +app = FastAPI() + +app.frontend("/", directory="dist") +``` + +Use `router.frontend()` when the frontend belongs to an `APIRouter`; normal router prefix behavior applies when the router is included. + +```python +from fastapi import APIRouter, FastAPI + +app = FastAPI() +router = APIRouter(prefix="/admin") + +router.frontend("/", directory="admin-dist") +app.include_router(router) +``` + +`app.frontend()` and `router.frontend()` are low-priority routes: regular API routes are matched first, then frontend files and client-side routing fallbacks. Use this for single-page apps and built frontend assets instead of mounting `StaticFiles` manually. + +## Dependency Injection + +Use dependencies when the logic can't be declared in Pydantic validation, depends on external resources, needs cleanup with `yield`, or is shared across endpoints. + +Apply shared dependencies at the router level via `dependencies=[Depends(...)]`. + +See [the dependency injection reference](references/dependencies.md) for detailed patterns including `yield` with `scope`, and class dependencies. + +## Async vs Sync *path operations* + +Use `async` *path operations* only when fully certain that the logic called inside is compatible with async and await, and that it doesn't block. + +```python +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/async-items/") +async def read_async_items(): + data = await some_async_library.fetch_items() + return data + + +@app.get("/items/") +def read_items(): + data = some_blocking_library.fetch_items() + return data +``` + +In case of doubt, or by default, use regular `def` functions. They will be run in a threadpool so they don't block the event loop. The same rules apply to dependencies. + +Make sure blocking code is not run inside of `async` functions. The logic will work, but will damage performance heavily. + +When needing to mix blocking and async code, see Asyncer in [the other tools reference](references/other-tools.md). + +## Streaming (JSON Lines, SSE, bytes) + +To stream Server-Sent Events, use `response_class=EventSourceResponse` and `yield` items from the endpoint. + +```python +from collections.abc import AsyncIterable + +from fastapi import FastAPI +from fastapi.sse import EventSourceResponse, ServerSentEvent + +app = FastAPI() + + +@app.get("/events", response_class=EventSourceResponse) +async def stream_events() -> AsyncIterable[ServerSentEvent]: + yield ServerSentEvent(data={"status": "started"}, event="status", id="1") +``` + +Plain objects are automatically JSON-serialized as `data:` fields. Use `ServerSentEvent` for full control over SSE fields (`event`, `id`, `retry`, `comment`) and `raw_data` for pre-formatted strings. + +See [the streaming reference](references/streaming.md) for JSON Lines, Server-Sent Events (`EventSourceResponse`, `ServerSentEvent`), and byte streaming (`StreamingResponse`) patterns. + +## Tooling + +See [the other tools reference](references/other-tools.md) for details on uv, Ruff, ty for package management, linting, type checking, formatting, etc. + +## Other Libraries + +See [the other tools reference](references/other-tools.md) for details on other libraries: + +* Asyncer for handling async and await, concurrency, mixing async and blocking code, prefer it over AnyIO or asyncio. +* SQLModel for working with SQL databases, prefer it over SQLAlchemy. +* HTTPX for interacting with HTTP (other APIs), prefer it over Requests. + +## Do not use Pydantic RootModels + +Do not use Pydantic `RootModel`; instead use regular type annotations with `Annotated` and Pydantic validation utilities. + +```python +from typing import Annotated + +from fastapi import Body, FastAPI +from pydantic import Field + +app = FastAPI() + + +@app.post("/items/") +async def create_items(items: Annotated[list[int], Field(min_length=1), Body()]): + return items +``` + +FastAPI supports these type annotations and will create a Pydantic `TypeAdapter` for them, so types work normally without custom wrapper models. See [the Pydantic reference](references/pydantic.md). + +## Use one HTTP operation per function + +Don't mix HTTP operations in a single function. Having one function per HTTP operation helps separate concerns and organize the code. + +```python +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + + +@app.get("/items/") +async def list_items(): + return [] + + +@app.post("/items/") +async def create_item(item: Item): + return item +``` + +See [the path operation reference](references/path-operations.md) for more examples. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 307817be..cacb5c99 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,7 +71,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-install-project COPY /brewman ./ -COPY --from=builder /frontend/browser /app/static +COPY --from=builder /frontend/browser /app/frontend # Sync the project # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers diff --git a/ansible/files/Caddyfile.j2 b/ansible/files/Caddyfile.j2 index 72722b95..770221dc 100644 --- a/ansible/files/Caddyfile.j2 +++ b/ansible/files/Caddyfile.j2 @@ -1,24 +1,3 @@ {{ host }} { - # Match and proxy API routes - @apiRoutes { - path_regexp ^/(api|token|refresh|attendance-report|fingerprint-report|db-image) - } - handle @apiRoutes { - reverse_proxy @apiRoutes {{ host_directory }}:80 - } - - # Match requests that end with .js, .css, .ico, or .html - @staticFiles { - path_regexp \.(js|css|ico|html)$ - } - handle @staticFiles { - rewrite * /static{uri} - reverse_proxy {{ host_directory }}:80 - } - - # All other frontend routes → /static/index.html - handle { - rewrite * /static/index.html - reverse_proxy {{ host_directory }}:80 - } + reverse_proxy {{ host_directory }}:80 } diff --git a/brewman/alembic/versions/ee77296e69b7_recipe_template_removed.py b/brewman/alembic/versions/ee77296e69b7_recipe_template_removed.py new file mode 100644 index 00000000..148d7d88 --- /dev/null +++ b/brewman/alembic/versions/ee77296e69b7_recipe_template_removed.py @@ -0,0 +1,66 @@ +"""recipe template removed + +Revision ID: ee77296e69b7 +Revises: 09a4f0ca450f +Create Date: 2026-08-26 05:54:36.396717 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "ee77296e69b7" +down_revision = "09a4f0ca450f" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("recipe_templates") + op.alter_column( + "recipes", + "recipe_yield", + existing_type=sa.NUMERIC(precision=15, scale=2), + type_=sa.Numeric(precision=12, scale=4), + existing_nullable=False, + ) + op.drop_index(op.f("ix_recipes_date"), table_name="recipes") + op.drop_constraint(op.f("uq_recipes_sku_id"), "recipes", type_="unique") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint( + op.f("uq_recipes_sku_id"), "recipes", ["sku_id", "date"], postgresql_nulls_not_distinct=False + ) + op.create_index(op.f("ix_recipes_date"), "recipes", ["date"], unique=False) + op.alter_column( + "recipes", + "recipe_yield", + existing_type=sa.Numeric(precision=12, scale=4), + type_=sa.NUMERIC(precision=15, scale=2), + existing_nullable=False, + ) + op.create_table( + "recipe_templates", + sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), autoincrement=False, nullable=False), + sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column("date", sa.DATE(), autoincrement=False, nullable=False), + sa.Column("text", sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column("selected", sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_recipe_templates")), + sa.UniqueConstraint( + "name", name=op.f("uq_recipe_templates_name"), postgresql_include=[], postgresql_nulls_not_distinct=False + ), + ) + op.create_index( + op.f("only_one_selected_template"), + "recipe_templates", + ["selected"], + unique=True, + postgresql_where="(selected = true)", + ) + # ### end Alembic commands ### diff --git a/brewman/brewman/db/base.py b/brewman/brewman/db/base.py index 77ac8f9a..0891b877 100644 --- a/brewman/brewman/db/base.py +++ b/brewman/brewman/db/base.py @@ -17,6 +17,7 @@ from ..models.incentive import Incentive from ..models.inventory import Inventory from ..models.journal import Journal from ..models.login_history import LoginHistory +from ..models.mozimo_stock_register import MozimoStockRegister from ..models.period import Period from ..models.permission import Permission from ..models.price import Price @@ -30,7 +31,6 @@ from ..models.recipe_item import RecipeItem from ..models.recipe_photo import RecipePhoto from ..models.recipe_step import RecipeStep from ..models.recipe_tag import RecipeTag -from ..models.recipe_template import RecipeTemplate from ..models.role import Role from ..models.role_include import RoleInclude from ..models.role_permission import RolePermission @@ -63,6 +63,7 @@ __all__ = [ "Inventory", "Journal", "LoginHistory", + "MozimoStockRegister", "Permission", "Period", "Price", @@ -76,7 +77,6 @@ __all__ = [ "RecipePhoto", "RecipeStep", "RecipeTag", - "RecipeTemplate", "reg", "Role", "RoleInclude", diff --git a/brewman/brewman/db/friendly_db_error.py b/brewman/brewman/db/friendly_db_error.py index 8bddf0c2..96a2551d 100644 --- a/brewman/brewman/db/friendly_db_error.py +++ b/brewman/brewman/db/friendly_db_error.py @@ -89,9 +89,6 @@ _CONSTRAINT_FRIENDLY: dict[str, FriendlyDBError] = { ), "uq_recipe_items_recipe_id": FriendlyDBError(409, "RECIPE_ITEMS_EXISTS", "Items already exist for this recipe."), "uq_recipe_tags_recipe_id": FriendlyDBError(409, "RECIPE_TAGS_EXISTS", "Tags already exist for this recipe."), - "uq_recipe_templates_name": FriendlyDBError( - 409, "RECIPE_TEMPLATE_NAME_EXISTS", "A recipe template with this name already exists." - ), "uq_recipes_sku_id": FriendlyDBError(409, "RECIPE_FOR_SKU_EXISTS", "A recipe already exists for this SKU."), "uq_tags_name": FriendlyDBError(409, "TAG_NAME_EXISTS", "A tag with this name already exists."), # --- role_permissions --- @@ -114,9 +111,6 @@ _CONSTRAINT_FRIENDLY: dict[str, FriendlyDBError] = { "only_one_valid_attendance": FriendlyDBError( 409, "ATTENDANCE_EXISTS", "A valid attendance already exists for this employee on this date." ), - "only_one_selected_template": FriendlyDBError( - 409, "TEMPLATE_ALREADY_SELECTED", "Only one template can be selected at a time." - ), } # Optional: FK-specific friendly messages (use when you want better UX than generic FK failure) diff --git a/brewman/brewman/main.py b/brewman/brewman/main.py index 1e5d1ab3..e45c62b3 100644 --- a/brewman/brewman/main.py +++ b/brewman/brewman/main.py @@ -4,7 +4,6 @@ import uvicorn from fastapi import FastAPI, Request from fastapi.responses import JSONResponse -from fastapi.staticfiles import StaticFiles from sqlalchemy.exc import IntegrityError, SQLAlchemyError from starlette.middleware.sessions import SessionMiddleware @@ -45,7 +44,6 @@ from .routers import ( rate_contract, rebase, recipe, - recipe_template, role, tag, temporal_product, @@ -124,7 +122,6 @@ app.include_router(product.router, prefix="/api/products", tags=["products"]) app.include_router(temporal_product.router, prefix="/api/temporal-products", tags=["products"]) app.include_router(product_group.router, prefix="/api/product-groups", tags=["products"]) app.include_router(recipe.router, prefix="/api/recipes", tags=["products"]) -app.include_router(recipe_template.router, prefix="/api/recipe-templates", tags=["products"]) app.include_router(period.router, prefix="/api/periods", tags=["periods"]) app.include_router(client.router, prefix="/api/clients", tags=["clients"]) @@ -174,7 +171,7 @@ app.include_router(rebase.router, prefix="/api/rebase", tags=["management"]) app.include_router(title.router, prefix="/api/title") app.include_router(health.router, prefix="/health", tags=["health"]) -app.mount("/static", StaticFiles(directory="static"), name="static") +app.frontend("/", directory="frontend", check_dir=False) def init() -> None: diff --git a/brewman/brewman/models/recipe_photo.py b/brewman/brewman/models/recipe_photo.py index d54dd8c5..968366cd 100644 --- a/brewman/brewman/models/recipe_photo.py +++ b/brewman/brewman/models/recipe_photo.py @@ -24,14 +24,18 @@ class RecipePhoto: id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()") ) - recipe_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("recipes.id"), nullable=False, index=True) - image_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("images.id"), nullable=False, index=True) + recipe_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True + ) + image_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("images.id", ondelete="CASCADE"), nullable=False, index=True + ) order_index: Mapped[int] = mapped_column(Integer, nullable=False, index=True) caption: Mapped[str] = mapped_column(Text, nullable=False) recipe: Mapped[Recipe] = relationship("Recipe", back_populates="photos") - image: Mapped[DbImage] = relationship("DbImage") + image: Mapped[DbImage] = relationship("DbImage", cascade="all, delete-orphan", single_parent=True) def __init__( self, diff --git a/brewman/brewman/models/recipe_step.py b/brewman/brewman/models/recipe_step.py index 6f2eb302..c69c5191 100644 --- a/brewman/brewman/models/recipe_step.py +++ b/brewman/brewman/models/recipe_step.py @@ -26,7 +26,9 @@ class RecipeStep: id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()") ) - recipe_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("recipes.id"), nullable=False, index=True) + recipe_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True + ) order_index: Mapped[int] = mapped_column(Integer, nullable=False, index=True) phase: Mapped[str] = mapped_column(Text, nullable=False) # Prep/Cook/Finish/Serve diff --git a/brewman/brewman/models/recipe_template.py b/brewman/brewman/models/recipe_template.py deleted file mode 100644 index 8b171b2d..00000000 --- a/brewman/brewman/models/recipe_template.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -import uuid - -from datetime import date - -from sqlalchemy import Boolean, Date, Index, Unicode, Uuid, text -from sqlalchemy.orm import Mapped, mapped_column - -from ..db.base_class import reg - - -@reg.mapped_as_dataclass(unsafe_hash=True) -class RecipeTemplate: - __tablename__ = "recipe_templates" - - id: Mapped[uuid.UUID] = mapped_column( - Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()") - ) - name: Mapped[str] = mapped_column(Unicode, unique=True, nullable=False) - date_: Mapped[date] = mapped_column("date", Date, nullable=False) - text_: Mapped[str] = mapped_column("text", Unicode, nullable=False) - selected: Mapped[bool] = mapped_column(Boolean, nullable=False) - - __table_args__ = ( - Index( - "only_one_selected_template", - "selected", - unique=True, - postgresql_where=(selected == True), # noqa: E712 - ), - ) - - def __init__(self, name: str, date_: date, text: str, selected: bool, id_: uuid.UUID | None = None) -> None: - self.name = name - self.date_ = date_ - self.text_ = text - self.selected = selected - if id_ is not None: - self.id = id_ diff --git a/brewman/brewman/models/setting_type.py b/brewman/brewman/models/setting_type.py index a0e76e75..fa51f656 100644 --- a/brewman/brewman/models/setting_type.py +++ b/brewman/brewman/models/setting_type.py @@ -4,4 +4,3 @@ import enum class SettingType(enum.IntEnum): VOUCHER_LOCK = 0 MAINTENANCE_MODE = 1 - RECIPE_TEMPLATE = 2 diff --git a/brewman/brewman/routers/recipe_template.py b/brewman/brewman/routers/recipe_template.py deleted file mode 100644 index 9f10d679..00000000 --- a/brewman/brewman/routers/recipe_template.py +++ /dev/null @@ -1,84 +0,0 @@ -import datetime -import uuid - -from typing import Annotated - -from fastapi import APIRouter, Security -from sqlalchemy import delete, select, update - -from ..core.security import get_current_active_user as get_user -from ..db.session import SessionDep -from ..models.recipe_template import RecipeTemplate -from ..schemas import recipe_template as schemas -from ..schemas.user import UserToken - - -router = APIRouter() - - -@router.post("", response_model=None) -def save( - data: schemas.RecipeTemplateIn, user: Annotated[UserToken, Security(get_user, scopes=["recipes"])], db: SessionDep -) -> None: - date_ = data.date_ if data.date_ is not None else datetime.date.today() - if data.selected: - db.execute(update(RecipeTemplate).values(selected=False)) - item = RecipeTemplate(name=data.name, date_=date_, text=data.text, selected=data.selected) - db.add(item) - db.commit() - return None - - -@router.put("/{id_}", response_model=None) -def update_route( - id_: uuid.UUID, - data: schemas.RecipeTemplateIn, - user: Annotated[UserToken, Security(get_user, scopes=["recipes"])], - db: SessionDep, -) -> None: - date_ = data.date_ if data.date_ is not None else datetime.date.today() - item = db.execute(select(RecipeTemplate).where(RecipeTemplate.id == id_)).scalar_one() - if data.selected and not item.selected: - db.execute(update(RecipeTemplate).where(RecipeTemplate.id != id_).values(selected=False)) - item.name = data.name - item.text_ = data.text - item.selected = data.selected - item.date_ = date_ - db.commit() - return None - - -@router.delete("/{id_}", response_model=None) -def delete_route( - id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["recipes"])], db: SessionDep -) -> None: - db.execute(delete(RecipeTemplate).where(RecipeTemplate.id == id_)) - db.commit() - return None - - -@router.get("", response_model=schemas.RecipeTemplateIn) -def show_blank( - user: Annotated[UserToken, Security(get_user, scopes=["recipes"])], -) -> schemas.RecipeTemplateIn: - return schemas.RecipeTemplateIn(name="", date_=datetime.date.today(), text="", selected=False) - - -@router.get("/list", response_model=list[schemas.RecipeTemplate]) -async def show_list( - user: Annotated[UserToken, Security(get_user, scopes=["recipes"])], db: SessionDep -) -> list[schemas.RecipeTemplate]: - list_ = db.execute(select(RecipeTemplate).order_by(RecipeTemplate.name)).scalars().all() - return [ - schemas.RecipeTemplate(id_=i.id, name=i.name, date_=i.date_, text=i.text_, selected=i.selected) for i in list_ - ] - - -@router.get("/{id_}", response_model=schemas.RecipeTemplate) -def show_id( - id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["recipes"])], db: SessionDep -) -> schemas.RecipeTemplate: - item = db.execute(select(RecipeTemplate).where(RecipeTemplate.id == id_)).scalar_one() - return schemas.RecipeTemplate( - id_=item.id, name=item.name, date_=item.date_, text=item.text_, selected=item.selected - ) diff --git a/brewman/brewman/routers/reports/purchases.py b/brewman/brewman/routers/reports/purchases.py index c6ccdf0d..0678df6e 100644 --- a/brewman/brewman/routers/reports/purchases.py +++ b/brewman/brewman/routers/reports/purchases.py @@ -89,7 +89,7 @@ def build_report( quantity=quantity, rate=rate, amount=amount, - url=["/", "product-ledger", str(product.id)], + url=["/", "product-ledger", str(product.product_id)], ) body.append(row) return ( diff --git a/brewman/brewman/schemas/recipe_template.py b/brewman/brewman/schemas/recipe_template.py deleted file mode 100644 index 1c23f31d..00000000 --- a/brewman/brewman/schemas/recipe_template.py +++ /dev/null @@ -1,40 +0,0 @@ -import uuid - -from datetime import date, datetime - -from pydantic import ( - BaseModel, - ConfigDict, - FieldSerializationInfo, - field_serializer, - field_validator, -) - -from . import to_camel - - -class RecipeTemplateIn(BaseModel): - name: str - date_: date | None = None - text: str - selected: bool - - @field_validator("date_", mode="before") - @classmethod - def parse_valid_from(cls, value: date | str | None) -> date | None: - if value is None: - return None - if isinstance(value, date): - return value - return datetime.strptime(value, "%d-%b-%Y").date() - - @field_serializer("date_") - def serialize_date(self, value: date | None, info: FieldSerializationInfo) -> str | None: - return None if value is None else value.strftime("%d-%b-%Y") - - model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) - - -class RecipeTemplate(RecipeTemplateIn): - id_: uuid.UUID - model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/brewman/uv.lock b/brewman/uv.lock index 3575378e..7249dd4f 100644 --- a/brewman/uv.lock +++ b/brewman/uv.lock @@ -211,7 +211,7 @@ wheels = [ [[package]] name = "brewman" -version = "14.2.0" +version = "15.0.0" source = { editable = "." } dependencies = [ { name = "alembic" }, diff --git a/overlord/src/app/app.routes.ts b/overlord/src/app/app.routes.ts index 9a5beb12..8e1bf134 100644 --- a/overlord/src/app/app.routes.ts +++ b/overlord/src/app/app.routes.ts @@ -65,10 +65,6 @@ export const routes: Routes = [ path: 'recipes', loadChildren: () => import('./recipe/recipe.routes').then((mod) => mod.routes), }, - { - path: 'recipe-templates', - loadChildren: () => import('./recipe-template/recipe-template.routes').then((mod) => mod.routes), - }, { path: 'roles', loadChildren: () => import('./role/role.routes').then((mod) => mod.routes), diff --git a/overlord/src/app/attendance/attendance.component.html b/overlord/src/app/attendance/attendance.component.html index 00ca0d5f..172c1e06 100644 --- a/overlord/src/app/attendance/attendance.component.html +++ b/overlord/src/app/attendance/attendance.component.html @@ -1,6 +1,6 @@