Compare commits
No commits in common. "multitenant" and "master" have entirely different histories.
multitenan
...
master
121 changed files with 3560 additions and 42304 deletions
|
|
@ -1 +1,29 @@
|
|||
repos: []
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.9
|
||||
hooks:
|
||||
# Run the linter
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
# Run the formatter
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.11.2
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
- pydantic==2.9.2
|
||||
- fastapi==0.115.0
|
||||
- httpx==0.27.2
|
||||
- aiosqlite==0.20.0
|
||||
args: [--config-file=pyproject.toml]
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- id: check-merge-conflict
|
||||
|
|
|
|||
218
README.md
218
README.md
|
|
@ -1,146 +1,132 @@
|
|||
## Doof Backend (aka Munch Ease) 🍽️
|
||||
|
||||
FastAPI backend for collaborative meal planning, recipe wrangling, and grocery shopping. Household-scoped, JWT-secured, SQLite-fast. Bring your recipes, we’ll do the rest.
|
||||
|
||||
## Why you’ll love it
|
||||
|
||||
- 🚀 Fast and modern API with FastAPI + Pydantic v2
|
||||
- 🏠 Household-scoped everything (recipes, meals, shopping) for clean multi-user isolation
|
||||
- 🔐 JWT access tokens + HttpOnly refresh cookie (Argon2 password hashing)
|
||||
- 🧪 100% test-friendly: ephemeral SQLite, deterministic APIs, RFC7807 errors
|
||||
- 🧠 Ingredient NLP parsing with product matching
|
||||
- 🛒 One-click shopping lists: request meals or individual ingredients, dedup done for you
|
||||
- 🧾 OpenAPI on tap for your frontend and SDKs
|
||||
- 🔗 Built-in recipe parsing from URLs (with BeautifulSoup + httpx)
|
||||
|
||||
## Stack
|
||||
|
||||
- Runtime: Python 3.11+
|
||||
- Web: FastAPI, Starlette
|
||||
- Data models: Pydantic v2 (camelCase JSON via custom `ApiModel`)
|
||||
- Database: SQLite (aiosqlite), schema bootstrapped in each `repository.py`
|
||||
- Auth: Custom HMAC-SHA256 JWTs + Argon2 password hashing
|
||||
- Parsing/Scraping: `ingredient-parser-nlp`, `beautifulsoup4`, `httpx`
|
||||
- Tooling: ruff (lint+format), mypy (typecheck), pytest, pre-commit, uvicorn
|
||||
|
||||
## Features at a glance
|
||||
|
||||
- 🔑 Auth: Register, login, refresh, logout; access bearer token + HttpOnly refresh cookie
|
||||
- 🏡 Households: Create, list, invite members; accept invitations via shareable links
|
||||
- 🧾 Recipes: Create, list, paginate, delete (hide) with actor attribution; parse-from-url helper
|
||||
- 🧪 Ingredients: NLP parse one or many lines; product matching baked in
|
||||
- 🍽️ Meals: Plan, get, update, delete, mark consumed; validate participants and recipes
|
||||
- 🛍️ Shopping: Request meals and ingredients, view current list, purchase to a list, fetch past lists
|
||||
- 🩺 Health: `GET /healthz` returns a tiny “ok” model for probes
|
||||
- 📜 Errors: RFC7807 Problem Details everywhere, with tidy camelCase payloads
|
||||
|
||||
## Project structure
|
||||
|
||||
- `main.py` — FastAPI app factory, routers, exception handlers, health, frontend proxy/static
|
||||
- `settings.py` — Environment-driven runtime settings (no external deps)
|
||||
- `security.py` — Minimal JWT utilities (HS256) + helpers
|
||||
- `db.py` — aiosqlite connect + `create()` bootstraps all domain tables
|
||||
- `api/` — HTTP surface (versioned under `/api/v1`) - STRICTLY NO SQL AT THIS LAYER!
|
||||
- `auth.py` — register, login, refresh, logout
|
||||
- `households.py` — create/list, members, invitations, scoped routes
|
||||
- `ingredients.py` — household-scoped NLP parsing
|
||||
- `recipes.py` — household-scoped list/get/create/delete, public utilities
|
||||
- `meals.py` — household-scoped CRUD + mark consumed
|
||||
- `shopping.py` — household-scoped current list, purchase, request/unrequest
|
||||
- `openapi.py` — OpenAPI augmentation (cookie auth, problem+json)
|
||||
- `deps.py` — DB/session, auth, household scoping, error helpers
|
||||
- Domain packages (models + repository + sql mutation + helpers):
|
||||
- `persons/`
|
||||
- `users/`, `households/`, `ingredients/`, `recipes/` (incl. `scraping.py`), `meals/`, `products/` (Coles/Woolworths helpers), `shopping/`
|
||||
- Strive to be useful as a fairly portal package independant of the http layer
|
||||
- `scripts/` — Utility scripts
|
||||
- `export_openapi.py` — writes `openapi.json` from the live app
|
||||
- `manual_parse_recipes.py` — Test parsing against a variety of sources
|
||||
- `tests/` — API and domain tests with fixtures and sample files
|
||||
Meal planner backend
|
||||
|
||||
## Quickstart
|
||||
|
||||
First time setup:
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
Run the dev server:
|
||||
|
||||
Run the development server:
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Run all checks (lint, typecheck, tests, format check, OpenAPI export):
|
||||
|
||||
Run all quality checks (lint, typecheck, test, format check, OpenAPI export):
|
||||
```bash
|
||||
make all-checks
|
||||
```
|
||||
|
||||
## Environment
|
||||
## Structure
|
||||
|
||||
These are read from the environment (see `settings.py`):
|
||||
- `main.py`: FastAPI app with all HTTP endpoints.
|
||||
- `db.py`: aiosqlite connection + schema bootstrap across subpackages (calls each feature's `repository.create`).
|
||||
- Domain packages with models and persistence:
|
||||
- `products/` (models.py, repository.py, scrapers for Woolworths/Coles)
|
||||
- `ingredients/` (models.py, repository.py)
|
||||
- `recipes/` (models.py, repository.py, scraping.py)
|
||||
- `meals/` (models.py, repository.py, service.py)
|
||||
- `persons/` (models.py, repository.py)
|
||||
- `shopping/` (models.py, repository.py)
|
||||
- `tests/`: unit and API tests with sample HTTP fixtures.
|
||||
|
||||
- `DOOF_DB` — SQLite file path (default `./data/doof.sqlite`)
|
||||
- `DOOF_PROD` — `true/false` controls frontend proxy vs. static serving (default `false`)
|
||||
- `FRONTEND_DEV_URL` — dev server to reverse-proxy in non-prod (default `http://localhost:8000/`)
|
||||
- `DOOF_JWT_ISSUER`, `DOOF_JWT_AUDIENCE` — JWT claims
|
||||
- `DOOF_JWT_ACCESS_TTL`, `DOOF_JWT_REFRESH_TTL` — TTLs in seconds (default 900/2592000)
|
||||
- `DOOF_JWT_ACCESS_SECRET_B64`, `DOOF_JWT_REFRESH_SECRET_B64` — base64 secrets (use in prod!)
|
||||
## Getting started
|
||||
|
||||
Dev convenience: if secrets aren’t provided, deterministic dev secrets are used. Don’t ship those.
|
||||
|
||||
## API surface (v1)
|
||||
|
||||
- Base: `/api/v1`
|
||||
- Auth: `/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/logout`
|
||||
- Households: `/households`, `/users/me/households`, `/households/{slug}/members`, `/households/{slug}/whoami`, invitations create/accept
|
||||
- Ingredients: `/households/{slug}/ingredients/parse` (single or batch parsing)
|
||||
- Recipes: `/households/{slug}/recipes` (list/paged, create), `/{id}` (get/delete), `/parse-from-url`
|
||||
- Meals: `/households/{slug}/meals` (create/update/delete/get/upcoming/mark-consumed)
|
||||
- Shopping: `/households/{slug}/shopping/current`, `/{listId}`, request/unrequest meals and ingredients, purchase lists
|
||||
- Health: `/healthz`
|
||||
|
||||
Errors are consistent Problem Details (`application/problem+json`). Models serialize in camelCase.
|
||||
|
||||
## Make targets
|
||||
|
||||
- `make install` — Create venv and install dependencies
|
||||
- `make dev` — Run dev server (`uvicorn main:app --reload`)
|
||||
- `make test` — Run tests (pytest -q)
|
||||
- `make format` — Format with ruff
|
||||
- `make lint` — Lint with ruff
|
||||
- `make typecheck` — Type check with mypy
|
||||
- `make openapi` — Export OpenAPI to `openapi.json`
|
||||
- `make all-checks` — Lint + typecheck + tests + format check + OpenAPI export
|
||||
- `make clean` — Remove venv and caches
|
||||
|
||||
## Development notes
|
||||
|
||||
- Schema bootstrap: `db.create(conn)` calls each feature’s `repository.create` to make tables
|
||||
- Frontend integration:
|
||||
- Dev: requests for non-`/api/*` are reverse-proxied to `FRONTEND_DEV_URL`
|
||||
- Prod: static files served from `./front-dist`
|
||||
- Security: Argon2 password hashing; HS256 JWTs signed with your secrets; refresh token stored as HttpOnly cookie
|
||||
- DX niceties: camelCase JSON by default, strict validation, helpful error messages
|
||||
|
||||
## OpenAPI
|
||||
|
||||
Generate the spec file used by the frontend and CI:
|
||||
### Manual setup (alternative to make install)
|
||||
|
||||
Create and activate virtual environment:
|
||||
```bash
|
||||
make openapi
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
```
|
||||
|
||||
This writes `openapi.json` at the repo root.
|
||||
Install packages:
|
||||
```bash
|
||||
pip install -r ./requirements.txt
|
||||
pip install -r ./dev-requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
Install pre-commit hooks:
|
||||
```bash
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
Built with love and leftovers. Hungry for issues and PRs. 🧑🍳
|
||||
### Running the application
|
||||
|
||||
Run API (dev):
|
||||
```bash
|
||||
make dev
|
||||
# or: uvicorn main:app --reload
|
||||
```
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
make test
|
||||
# or: pytest -q
|
||||
```
|
||||
|
||||
### Available Make targets
|
||||
|
||||
- `make install` - Create venv and install all dependencies
|
||||
- `make dev` - Run development server
|
||||
- `make test` - Run tests
|
||||
- `make format` - Format code with ruff
|
||||
- `make lint` - Lint code with ruff
|
||||
- `make typecheck` - Type check with mypy
|
||||
- `make openapi` - Export OpenAPI schema
|
||||
- `make all-checks` - Run all quality checks
|
||||
- `make clean` - Remove venv and caches
|
||||
|
||||
## Tooling
|
||||
|
||||
This repo includes baseline configs in `pyproject.toml`:
|
||||
- ruff (format and lint)
|
||||
- mypy (type check)
|
||||
|
||||
Pre-commit hooks are configured to run:
|
||||
- ruff (lint + format)
|
||||
- mypy
|
||||
- trailing whitespace fixer
|
||||
- end-of-file fixer
|
||||
|
||||
Quality checks (run locally):
|
||||
```bash
|
||||
make format # Format code
|
||||
make lint # Lint code
|
||||
make typecheck # Type check
|
||||
make all-checks # Run all checks
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
Copy `.env.example` to `.env` and customize as needed:
|
||||
|
||||
- DOOF_DB: Path to sqlite database (default: `./data/doof.sqlite`)
|
||||
- DOOF_PROD: Set to `true` in production (default: `false`)
|
||||
- FRONTEND_DEV_URL: Frontend dev server URL for reverse proxy (default: `http://localhost:8080/`)
|
||||
- DOOF_PORT: Port the server listens on when containerized; align Dockerfile `EXPOSE` accordingly.
|
||||
|
||||
## OpenAPI schema
|
||||
|
||||
- Generate the schema artifact used by the frontend and CI checks:
|
||||
```
|
||||
python scripts/export_openapi.py
|
||||
```
|
||||
|
||||
This writes `openapi.json` to the repo root. Versioned endpoints live under `/api/v1`, legacy under `/api` (deprecated with `Deprecation` header).
|
||||
|
||||
## Schema lint/diff (manual)
|
||||
|
||||
Optionally, lint and compare schemas locally using Node tools:
|
||||
```
|
||||
npx -y @stoplight/spectral-cli lint openapi.json
|
||||
npx -y openapi-diff --fail-on-changed --fail-on-incompatible path/to/baseline.json openapi.json
|
||||
```
|
||||
|
||||
Keep a `baseline.json` on release branches to detect breaking changes.
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
# API package for FastAPI routers.
|
||||
# Routers are split by feature: recipes, meals, shopping, auth, households.
|
||||
# Routers will be split by feature: recipes, meals, persons, shopping, auth.
|
||||
|
|
|
|||
182
api/auth.py
182
api/auth.py
|
|
@ -1,159 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
|
||||
from api.deps import error_response, get_db
|
||||
from common import ApiModel
|
||||
from security import JwtConfig, create_jwt
|
||||
from settings import settings
|
||||
from users import repository as users_db
|
||||
from users.models import User
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
# Argon2-only password hashing
|
||||
_ph: PasswordHasher = PasswordHasher()
|
||||
import persons
|
||||
from api.deps import cookie_person, error_response, get_db
|
||||
from common import ApiModel, ProblemDetails
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class RegisterBody(ApiModel):
|
||||
email: str
|
||||
password: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class LoginBody(ApiModel):
|
||||
email: str
|
||||
password: str
|
||||
username: str
|
||||
|
||||
|
||||
class TokenResponse(ApiModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: User
|
||||
|
||||
|
||||
def _hash_pw(pw: str) -> str:
|
||||
"""Hash a password using Argon2."""
|
||||
return _ph.hash(pw)
|
||||
|
||||
|
||||
def _verify_pw(pw: str, stored: str) -> bool:
|
||||
"""Verify password using Argon2-only stored hashes."""
|
||||
try:
|
||||
return _ph.verify(stored, pw)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _jwt_config() -> JwtConfig:
|
||||
# Secrets can be provided base64-encoded via env; fallback to deterministic dev defaults (NOT for prod)
|
||||
if settings.access_secret_b64:
|
||||
access = base64.b64decode(settings.access_secret_b64)
|
||||
else:
|
||||
access = b"dev-access-secret-change-me-32bytes!!"[:32]
|
||||
if settings.refresh_secret_b64:
|
||||
refresh = base64.b64decode(settings.refresh_secret_b64)
|
||||
else:
|
||||
refresh = b"dev-refresh-secret-change-me-32bytes!!"[:32]
|
||||
return JwtConfig(
|
||||
issuer=settings.jwt_issuer,
|
||||
audience=settings.jwt_audience,
|
||||
access_secret=access,
|
||||
refresh_secret=refresh,
|
||||
access_ttl_seconds=settings.access_ttl_seconds,
|
||||
refresh_ttl_seconds=settings.refresh_ttl_seconds,
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=persons.Person,
|
||||
operation_id="login",
|
||||
summary="Login and set user_id cookie",
|
||||
responses={
|
||||
200: {"model": persons.Person, "description": "Successful Response"},
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Person not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
},
|
||||
},
|
||||
)
|
||||
async def login(
|
||||
request: Request,
|
||||
data: LoginBody,
|
||||
response: Response,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> persons.Person | Response:
|
||||
person = await persons.get_by_name(conn, data.username)
|
||||
if not person:
|
||||
return error_response(request, 404, "Person not found")
|
||||
|
||||
# When using response_model, return the Pydantic model and set the cookie on the Response
|
||||
response.set_cookie(key="user_id", value=str(person.id))
|
||||
return person
|
||||
|
||||
|
||||
def _token_pair_for_user(user: User) -> tuple[str, str]:
|
||||
cfg = _jwt_config()
|
||||
sub = str(user.id)
|
||||
access = create_jwt(cfg, sub, kind="access", extra={"user_id": user.id, "email": user.email})
|
||||
refresh = create_jwt(cfg, sub, kind="refresh")
|
||||
return access, refresh
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, operation_id="register")
|
||||
async def register(
|
||||
request: Request, body: RegisterBody, conn: aiosqlite.Connection = Depends(get_db)
|
||||
):
|
||||
existing = await users_db.get_by_email(conn, body.email)
|
||||
if existing:
|
||||
return error_response(request, 400, "Email already registered")
|
||||
uid = await users_db.insert_user(conn, body.email, body.display_name)
|
||||
await users_db.set_local_credentials(conn, uid, _hash_pw(body.password))
|
||||
user = await users_db.get_by_email(conn, body.email)
|
||||
assert user is not None
|
||||
access, refresh = _token_pair_for_user(user)
|
||||
resp = JSONResponse(TokenResponse(access_token=access, user=user).model_dump(by_alias=True))
|
||||
# HttpOnly refresh cookie
|
||||
resp.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="lax",
|
||||
max_age=settings.refresh_ttl_seconds,
|
||||
path="/api/v1/auth/refresh",
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=persons.Person,
|
||||
operation_id="refresh",
|
||||
summary="Refresh current user from cookie",
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse, operation_id="loginV2")
|
||||
async def login(request: Request, body: LoginBody, conn: aiosqlite.Connection = Depends(get_db)):
|
||||
user: Optional[User] = await users_db.get_by_email(conn, body.email)
|
||||
if not user:
|
||||
return error_response(request, 401, "Invalid credentials")
|
||||
stored = await users_db.get_local_password_hash(conn, user.id)
|
||||
if not stored or not _verify_pw(body.password, stored):
|
||||
return error_response(request, 401, "Invalid credentials")
|
||||
access, refresh = _token_pair_for_user(user)
|
||||
resp = JSONResponse(TokenResponse(access_token=access, user=user).model_dump(by_alias=True))
|
||||
resp.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="lax",
|
||||
max_age=settings.refresh_ttl_seconds,
|
||||
path="/api/v1/auth/refresh",
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
class RefreshResponse(ApiModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshResponse, operation_id="refreshV2")
|
||||
async def refresh(request: Request):
|
||||
token = request.cookies.get("refresh_token")
|
||||
if not token:
|
||||
return error_response(request, 401, "Unauthorized")
|
||||
from security import verify_jwt
|
||||
|
||||
try:
|
||||
_h, payload = verify_jwt(_jwt_config(), token, expected_kind="refresh")
|
||||
except Exception:
|
||||
return error_response(request, 401, "Unauthorized")
|
||||
user_id = str(payload.get("sub"))
|
||||
access = create_jwt(_jwt_config(), user_id, kind="access")
|
||||
return RefreshResponse(access_token=access)
|
||||
|
||||
|
||||
@router.post("/logout", operation_id="logoutV2")
|
||||
async def logout():
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.delete_cookie("refresh_token", path="/api/v1/auth/refresh")
|
||||
return resp
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
|
||||
return user
|
||||
|
|
|
|||
112
api/deps.py
112
api/deps.py
|
|
@ -3,20 +3,13 @@ from __future__ import annotations
|
|||
from typing import AsyncGenerator, Optional
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi import Cookie, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import db
|
||||
import persons
|
||||
from common import ProblemDetails
|
||||
from settings import settings
|
||||
from users.models import User
|
||||
from security import JwtConfig, verify_jwt
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class HouseholdCtx(TypedDict):
|
||||
id: int
|
||||
slug: str
|
||||
|
||||
|
||||
# Dependency to create SQLite connection with PRAGMAs and per-request transaction
|
||||
|
|
@ -44,6 +37,34 @@ async def get_db() -> AsyncGenerator[aiosqlite.Connection, None]:
|
|||
await sql_db.close()
|
||||
|
||||
|
||||
async def cookie_person(
|
||||
user_id: int = Cookie(..., alias="user_id"),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> persons.Person:
|
||||
"""Return the authenticated user from the user_id cookie or raise 401.
|
||||
|
||||
When the cookie is missing, FastAPI will raise 422 (validation error).
|
||||
"""
|
||||
person = await persons.get_by_id(conn, user_id)
|
||||
if not person:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return person
|
||||
|
||||
|
||||
async def cookie_person_optional(
|
||||
user_id: Optional[int] = Cookie(default=None, alias="user_id"),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> Optional[persons.Person]:
|
||||
"""Return the authenticated user if cookie present; otherwise None.
|
||||
|
||||
Use for endpoints that want to return 401 for missing auth themselves.
|
||||
"""
|
||||
if user_id is None:
|
||||
return None
|
||||
person = await persons.get_by_id(conn, user_id)
|
||||
return person
|
||||
|
||||
|
||||
def error_response(request: Optional[Request], status_code: int, message: str) -> JSONResponse:
|
||||
body = ProblemDetails(
|
||||
title=message,
|
||||
|
|
@ -56,76 +77,3 @@ def error_response(request: Optional[Request], status_code: int, message: str) -
|
|||
status_code=status_code,
|
||||
media_type="application/problem+json",
|
||||
)
|
||||
|
||||
|
||||
def _jwt_config() -> JwtConfig:
|
||||
import base64
|
||||
|
||||
if settings.access_secret_b64:
|
||||
access = base64.b64decode(settings.access_secret_b64)
|
||||
else:
|
||||
access = b"dev-access-secret-change-me-32bytes!!"[:32]
|
||||
if settings.refresh_secret_b64:
|
||||
refresh = base64.b64decode(settings.refresh_secret_b64)
|
||||
else:
|
||||
refresh = b"dev-refresh-secret-change-me-32bytes!!"[:32]
|
||||
return JwtConfig(
|
||||
issuer=settings.jwt_issuer,
|
||||
audience=settings.jwt_audience,
|
||||
access_secret=access,
|
||||
refresh_secret=refresh,
|
||||
access_ttl_seconds=settings.access_ttl_seconds,
|
||||
refresh_ttl_seconds=settings.refresh_ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> User:
|
||||
"""JWT bearer auth: expects Authorization: Bearer <JWT> with sub=user id.
|
||||
|
||||
Returns 401 on failure.
|
||||
"""
|
||||
auth = request.headers.get("Authorization")
|
||||
if not auth or not auth.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
token = auth.split(" ", 1)[1].strip()
|
||||
try:
|
||||
_h, payload = verify_jwt(_jwt_config(), token, expected_kind="access")
|
||||
user_id = int(str(payload.get("sub")))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# Lookup by id
|
||||
async with conn.execute(
|
||||
"SELECT id, email, display_name, profile_photo_url FROM User WHERE id = ?",
|
||||
(user_id,),
|
||||
) as c:
|
||||
row = await c.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3])
|
||||
|
||||
|
||||
async def get_household_from_slug(
|
||||
request: Request,
|
||||
householdSlug: str, # path parameter
|
||||
user: User = Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> HouseholdCtx:
|
||||
# Find household by slug
|
||||
async with conn.execute(
|
||||
"SELECT id, slug FROM Household WHERE slug = ? LIMIT 1",
|
||||
(householdSlug,),
|
||||
) as c:
|
||||
row = await c.fetchone()
|
||||
if not row:
|
||||
# 404 to avoid leaking membership existence
|
||||
raise HTTPException(status_code=404, detail="Household not found")
|
||||
hid = int(row[0])
|
||||
# Verify membership
|
||||
async with conn.execute(
|
||||
"SELECT 1 FROM HouseholdMember WHERE user_id = ? AND household_id = ? LIMIT 1",
|
||||
(user.id, hid),
|
||||
) as c:
|
||||
m = await c.fetchone()
|
||||
if not m:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
return {"id": hid, "slug": row[1]}
|
||||
|
|
|
|||
13
api/dtos.py
13
api/dtos.py
|
|
@ -1,13 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from common import ApiModel
|
||||
|
||||
|
||||
class MemberRef(ApiModel):
|
||||
id: int
|
||||
display_name: str
|
||||
|
||||
# Back-compat for tests that access `.name`
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.display_name
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from api.deps import get_current_user, get_db, error_response, get_household_from_slug
|
||||
from pydantic import Field
|
||||
from common import ApiModel
|
||||
from households import repository as households_repo
|
||||
from users.models import User
|
||||
|
||||
router = APIRouter(tags=["households"])
|
||||
|
||||
|
||||
class CreateHouseholdBody(ApiModel):
|
||||
name: str
|
||||
|
||||
|
||||
class HouseholdResponse(ApiModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
return s or "household"
|
||||
|
||||
|
||||
@router.get("/users/me/households", response_model=List[HouseholdResponse])
|
||||
async def list_my_households(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
results = await households_repo.list_for_user(conn, user.id)
|
||||
return [HouseholdResponse.model_validate(h) for h in results]
|
||||
|
||||
|
||||
@router.post("/households", response_model=HouseholdResponse)
|
||||
async def create_household(
|
||||
request: Request,
|
||||
body: CreateHouseholdBody,
|
||||
user: User = Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
slug = slugify(body.name)
|
||||
household = await households_repo.create_for_user(conn, body.name, slug, user.id)
|
||||
if household is None:
|
||||
return error_response(request, 400, "Unable to create household")
|
||||
return HouseholdResponse.model_validate(household)
|
||||
|
||||
|
||||
# Household-scoped router and endpoint to validate scoping mechanics
|
||||
scoped = APIRouter(prefix="/households/{householdSlug}")
|
||||
|
||||
|
||||
class WhoAmI(ApiModel):
|
||||
household_id: int
|
||||
household_slug: str
|
||||
|
||||
|
||||
@scoped.get("/whoami", response_model=WhoAmI)
|
||||
async def whoami(household=Depends(get_household_from_slug)):
|
||||
return WhoAmI(household_id=household["id"], household_slug=household["slug"])
|
||||
|
||||
|
||||
# Members listing to unblock frontend
|
||||
class HouseholdMember(ApiModel):
|
||||
id: int
|
||||
display_name: str
|
||||
role: str
|
||||
|
||||
|
||||
@scoped.get("/members", response_model=list[HouseholdMember])
|
||||
async def list_members(
|
||||
household=Depends(get_household_from_slug),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
member_data = await households_repo.list_members(conn, household["id"])
|
||||
return [HouseholdMember.model_validate(m) for m in member_data]
|
||||
|
||||
|
||||
# Invitations
|
||||
class InvitationResponse(ApiModel):
|
||||
token: str
|
||||
status: str = "pending"
|
||||
|
||||
|
||||
class InviteLinkResponse(ApiModel):
|
||||
# Force snake_case in JSON output to match spec and tests
|
||||
invite_link: str = Field(serialization_alias="invite_link")
|
||||
|
||||
|
||||
@scoped.post("/invitations", response_model=InviteLinkResponse)
|
||||
async def create_invitation(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
household=Depends(get_household_from_slug),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urljoin, urlencode
|
||||
from settings import settings
|
||||
|
||||
token = secrets.token_urlsafe(24)
|
||||
expires_at = (datetime.utcnow() + timedelta(days=14)).isoformat() + "Z"
|
||||
|
||||
success = await households_repo.create_invitation(
|
||||
conn, household["id"], user.id, token, expires_at
|
||||
)
|
||||
|
||||
if not success:
|
||||
return error_response(request, 400, "Unable to create invitation")
|
||||
|
||||
base = settings.frontend_dev_url
|
||||
# Ensure base ends with a slash for urljoin
|
||||
if not base.endswith("/"):
|
||||
base = base + "/"
|
||||
path_with_query = f"invitations/accept?{urlencode({'token': token})}"
|
||||
invite_link = urljoin(base, path_with_query)
|
||||
return InviteLinkResponse(invite_link=invite_link)
|
||||
|
||||
|
||||
class AcceptInvitationBody(ApiModel):
|
||||
token: str
|
||||
|
||||
|
||||
class AcceptInvitationResponse(ApiModel):
|
||||
status: str = "accepted"
|
||||
household: HouseholdResponse
|
||||
|
||||
|
||||
# Accept invitation (mounted on root router via main.py)
|
||||
@router.post("/invitations/accept", response_model=AcceptInvitationResponse)
|
||||
async def accept_invitation(
|
||||
request: Request,
|
||||
body: AcceptInvitationBody,
|
||||
user: User = Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
# Lookup invitation
|
||||
invitation = await households_repo.get_invitation_by_token(conn, body.token)
|
||||
if not invitation:
|
||||
return error_response(request, 404, "Invitation not found")
|
||||
|
||||
if invitation.status != "pending":
|
||||
return error_response(request, 400, "Invitation not pending")
|
||||
|
||||
# Add membership and mark invitation accepted
|
||||
await households_repo.accept_invitation(
|
||||
conn, invitation.id, user.id, invitation.household_id
|
||||
)
|
||||
|
||||
# Load household details for response
|
||||
household = await households_repo.get_household_by_id(conn, invitation.household_id)
|
||||
if not household:
|
||||
return error_response(request, 404, "Household not found")
|
||||
|
||||
return AcceptInvitationResponse(
|
||||
status="accepted",
|
||||
household=HouseholdResponse.model_validate(household),
|
||||
)
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
import aiosqlite
|
||||
|
||||
import ingredients as ingredients_mod
|
||||
from api.deps import get_db, get_household_from_slug
|
||||
|
||||
|
||||
router = APIRouter(prefix="/households/{householdSlug}/ingredients", tags=["ingredients"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/parse",
|
||||
response_model=ingredients_mod.Ingredient | list[ingredients_mod.Ingredient],
|
||||
summary="Parse an ingredient line or lines from a string",
|
||||
)
|
||||
async def parse_ingredient(
|
||||
line: str | None = Query(None, description="Single ingredient line to parse"),
|
||||
lines: list[str] | None = Query(None, description="Multiple ingredient lines to parse"),
|
||||
household=Depends(get_household_from_slug),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
# Batch mode takes precedence if provided
|
||||
if lines is not None:
|
||||
parsed = [ingredients_mod.parse_ingredient_from_nlp(item_line) for item_line in lines]
|
||||
matched = await ingredients_mod.match_existing_products(conn, parsed)
|
||||
return matched
|
||||
# Single line mode
|
||||
if line is None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=422, detail="Query parameter 'line' or 'lines' is required")
|
||||
parsed_one = ingredients_mod.parse_ingredient_from_nlp(line)
|
||||
matched_one = await ingredients_mod.match_existing_products(conn, [parsed_one])
|
||||
return matched_one[0]
|
||||
505
api/meals.py
505
api/meals.py
|
|
@ -7,455 +7,214 @@ import aiosqlite
|
|||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
|
||||
import meals
|
||||
import shopping
|
||||
import ingredients
|
||||
from common import ProblemDetails, ApiModel
|
||||
from api.dtos import MemberRef
|
||||
from api.deps import error_response
|
||||
from api.deps import get_db, get_household_from_slug
|
||||
import persons
|
||||
import shopping
|
||||
from api.deps import cookie_person, error_response, get_db
|
||||
from common import ProblemDetails, ApiModel, Field
|
||||
|
||||
# Keep validate_meal import surface for tests that reference api.meals.validate_meal
|
||||
from meals.service import validate_meal
|
||||
|
||||
router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals"])
|
||||
|
||||
|
||||
# MemberRef now imported from api.dtos
|
||||
|
||||
|
||||
class MealRecipeIn(ApiModel):
|
||||
meal_id: int
|
||||
recipe_id: int
|
||||
servings: float
|
||||
|
||||
|
||||
class MealIn(ApiModel):
|
||||
id: int = -1
|
||||
suggested_date: datetime.datetime
|
||||
consumed_date: Optional[datetime.datetime] = None
|
||||
chefs: List[MemberRef]
|
||||
cleanup: List[MemberRef]
|
||||
consumers: List[MemberRef]
|
||||
recipes: List[MealRecipeIn] = []
|
||||
extra_ingredients: List[ingredients.Ingredient] = []
|
||||
router = APIRouter(prefix="/meals", tags=["meals"])
|
||||
|
||||
|
||||
class MealOut(ApiModel):
|
||||
id: int = -1
|
||||
suggested_date: datetime.datetime
|
||||
consumed_date: Optional[datetime.datetime] = None
|
||||
chefs: List[MemberRef]
|
||||
cleanup: List[MemberRef]
|
||||
consumers: List[MemberRef]
|
||||
recipes: List[meals.MealRecipe]
|
||||
extra_ingredients: List[ingredients.Ingredient]
|
||||
chefs: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||
cleanup: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||
consumers: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||
recipes: List[meals.MealRecipe] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||
extra_ingredients: List[ingredients.Ingredient] = Field(
|
||||
min_length=0, json_schema_extra={"minItems": 0}
|
||||
)
|
||||
purchase_date: Optional[datetime.datetime] = None
|
||||
|
||||
|
||||
class MarkConsumedBody(ApiModel):
|
||||
consumed_date: Optional[datetime.datetime] = None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/upcoming",
|
||||
operation_id="getUpcomingMealsV2",
|
||||
summary="List upcoming meals in a date range (scoped)",
|
||||
"/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range"
|
||||
)
|
||||
async def get_upcoming_meals_scoped(
|
||||
household=Depends(get_household_from_slug),
|
||||
async def get_upcoming_meals(
|
||||
date_from: datetime.datetime = Query(..., alias="from"),
|
||||
to: datetime.datetime = Query(...),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> List[MealOut]:
|
||||
hid = household["id"]
|
||||
# Use a scoped repository function, falling back to filtering in SQL until repository is fully scoped
|
||||
try:
|
||||
async for _ in meals.find_upcoming_meals_by_date_range_scoped(conn, date_from, to, hid):
|
||||
# if function exists, break immediately to use it
|
||||
break
|
||||
use_scoped = True
|
||||
except AttributeError:
|
||||
use_scoped = False
|
||||
|
||||
) -> List[meals.Meal]:
|
||||
# Load base meals
|
||||
result: List[meals.Meal] = []
|
||||
if use_scoped:
|
||||
async for meal in meals.find_upcoming_meals_by_date_range_scoped(conn, date_from, to, hid):
|
||||
async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to):
|
||||
result.append(meal)
|
||||
else:
|
||||
# Temporary path: direct query with household_id filter
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {",".join(meals.Meal.KEYS)} FROM Meal
|
||||
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL AND household_id = ?
|
||||
""",
|
||||
(date_from, to, hid),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
result.append(meals.Meal(**{k: v for k, v in zip(meals.Meal.KEYS, row)}))
|
||||
|
||||
if not result:
|
||||
return []
|
||||
return result
|
||||
|
||||
# Load relateds similar to v1
|
||||
# Batch load participants for all meals
|
||||
await meals.bulk_load_participants(conn, result)
|
||||
|
||||
# Load recipes and extra ingredients per meal (recipes include a small join)
|
||||
for meal in result:
|
||||
await meals.load_recipes(conn, meal)
|
||||
await meals.load_extra_ingredients(conn, meal)
|
||||
|
||||
# Map domain Meal -> outward MealOut
|
||||
out: List[MealOut] = []
|
||||
for m in result:
|
||||
out.append(
|
||||
MealOut(
|
||||
id=m.id,
|
||||
suggested_date=m.suggested_date,
|
||||
consumed_date=m.consumed_date,
|
||||
chefs=list(m.chefs),
|
||||
cleanup=list(m.cleanup),
|
||||
consumers=list(m.consumers),
|
||||
recipes=m.recipes,
|
||||
extra_ingredients=m.extra_ingredients,
|
||||
purchase_date=m.purchase_date,
|
||||
)
|
||||
)
|
||||
return out
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{meal_id}",
|
||||
operation_id="getMealV2",
|
||||
summary="Get a meal by id (scoped)",
|
||||
response_model=MealOut,
|
||||
responses={404: {"model": ProblemDetails}},
|
||||
)
|
||||
async def get_meal_scoped(
|
||||
meal_id: int,
|
||||
request: Request,
|
||||
household=Depends(get_household_from_slug),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> MealOut | Response:
|
||||
hid = household["id"]
|
||||
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
|
||||
if not meal:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
return MealOut(
|
||||
id=meal.id,
|
||||
suggested_date=meal.suggested_date,
|
||||
consumed_date=meal.consumed_date,
|
||||
chefs=list(meal.chefs),
|
||||
cleanup=list(meal.cleanup),
|
||||
consumers=list(meal.consumers),
|
||||
recipes=meal.recipes,
|
||||
extra_ingredients=meal.extra_ingredients,
|
||||
purchase_date=meal.purchase_date,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{meal_id}/consumed",
|
||||
operation_id="markMealConsumedV2",
|
||||
summary="Mark a meal as consumed (scoped)",
|
||||
response_model=MealOut,
|
||||
operation_id="getMeal",
|
||||
summary="Get a meal by id",
|
||||
responses={
|
||||
400: {"model": ProblemDetails},
|
||||
404: {"model": ProblemDetails},
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Meal not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def mark_meal_consumed_scoped(
|
||||
meal_id: int,
|
||||
request: Request,
|
||||
body: Optional[MarkConsumedBody] = None,
|
||||
household=Depends(get_household_from_slug),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> MealOut | Response:
|
||||
hid = household["id"]
|
||||
consumed_date: Optional[datetime.datetime] = None
|
||||
if body is not None:
|
||||
# Model aliasing handles consumedDate -> consumed_date
|
||||
consumed_date = getattr(body, "consumed_date", None)
|
||||
if consumed_date is not None and not getattr(consumed_date, "tzinfo", None):
|
||||
return error_response(request, 400, "Consumed date must include timezone")
|
||||
|
||||
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
|
||||
async def get_meal(
|
||||
meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> meals.Meal | Response:
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
|
||||
# Clear any outstanding meal request entries for this meal
|
||||
await shopping.remove_request(conn, person=None, meal=meal)
|
||||
|
||||
return MealOut(
|
||||
id=meal.id,
|
||||
suggested_date=meal.suggested_date,
|
||||
consumed_date=meal.consumed_date,
|
||||
chefs=list(meal.chefs),
|
||||
cleanup=list(meal.cleanup),
|
||||
consumers=list(meal.consumers),
|
||||
recipes=meal.recipes,
|
||||
extra_ingredients=meal.extra_ingredients,
|
||||
purchase_date=meal.purchase_date,
|
||||
)
|
||||
return meal
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
operation_id="createMealV2",
|
||||
summary="Create a new meal (scoped)",
|
||||
response_model=MealOut,
|
||||
responses={400: {"model": ProblemDetails}},
|
||||
operation_id="createMeal",
|
||||
summary="Create a new meal",
|
||||
responses={
|
||||
400: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Validation error",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def create_meal_scoped(
|
||||
meal: MealIn,
|
||||
response: Response,
|
||||
async def create_meal(
|
||||
meal: meals.Meal,
|
||||
request: Request,
|
||||
household=Depends(get_household_from_slug),
|
||||
response: Response,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> MealOut | Response:
|
||||
# Validate using existing service logic
|
||||
# Map MealIn -> domain Meal
|
||||
def _from_member(m: MemberRef) -> MemberRef:
|
||||
return MemberRef(id=m.id, display_name=m.display_name)
|
||||
) -> meals.Meal | Response:
|
||||
validation_response = validate_meal(meal, request)
|
||||
if validation_response:
|
||||
return validation_response
|
||||
|
||||
domain_meal = meals.Meal(
|
||||
id=meal.id,
|
||||
suggested_date=meal.suggested_date,
|
||||
consumed_date=meal.consumed_date,
|
||||
chefs=[_from_member(p) for p in meal.chefs],
|
||||
cleanup=[_from_member(p) for p in meal.cleanup],
|
||||
consumers=[_from_member(p) for p in meal.consumers],
|
||||
recipes=[
|
||||
meals.MealRecipe(meal_id=r.meal_id, recipe_id=r.recipe_id, servings=r.servings)
|
||||
for r in meal.recipes
|
||||
],
|
||||
extra_ingredients=list(meal.extra_ingredients),
|
||||
)
|
||||
msg = meals.validate_meal(domain_meal)
|
||||
if msg:
|
||||
return error_response(request, 400, msg)
|
||||
# Disallow empty extra ingredients (no product and no textual content)
|
||||
for ing in domain_meal.extra_ingredients:
|
||||
name = (ing.name or "").strip()
|
||||
line = (ing.line or "").strip()
|
||||
has_product = getattr(ing, "product", None) is not None or (
|
||||
getattr(ing, "product_id", None) is not None and getattr(ing, "product_id") >= 0
|
||||
)
|
||||
if not has_product and name == "" and line == "":
|
||||
return error_response(
|
||||
request, 400, "Ingredient must include a name or line or a product"
|
||||
)
|
||||
# quantity must be > 0
|
||||
try:
|
||||
q = float(getattr(ing, "quantity", 0))
|
||||
except Exception:
|
||||
q = getattr(ing, "quantity", 0)
|
||||
if isinstance(q, (int, float)) and q <= 0:
|
||||
return error_response(request, 400, "Ingredient quantity must be greater than 0")
|
||||
# Proactive validation via repositories
|
||||
hid = household["id"]
|
||||
# Validate members exist as users (do not require household membership here to preserve existing behavior/tests)
|
||||
member_ids = {m.id for m in (*domain_meal.chefs, *domain_meal.cleanup, *domain_meal.consumers)}
|
||||
if member_ids:
|
||||
from users.repository import get_by_ids as get_users_by_ids
|
||||
|
||||
users = await get_users_by_ids(conn, sorted(member_ids))
|
||||
valid_ids = set(users.keys())
|
||||
invalid = sorted(member_ids - valid_ids)
|
||||
if invalid:
|
||||
return error_response(
|
||||
request, 400, f"Invalid member id(s): {', '.join(map(str, invalid))}"
|
||||
)
|
||||
|
||||
# Validate recipes (existence and household scope) via recipes repository
|
||||
if domain_meal.recipes:
|
||||
from recipes.repository import find_recipe_by_id_scoped, find_recipe_by_id
|
||||
|
||||
invalid_recipes: list[int] = []
|
||||
for r in domain_meal.recipes:
|
||||
rid = int(r.recipe_id) if r.recipe_id is not None else -1
|
||||
if rid < 0:
|
||||
invalid_recipes.append(rid)
|
||||
continue
|
||||
recipe = await find_recipe_by_id_scoped(conn, rid, hid)
|
||||
if not recipe:
|
||||
# Fallback to global existence if scoping isn't set on that record
|
||||
recipe = await find_recipe_by_id(conn, rid)
|
||||
if not recipe:
|
||||
invalid_recipes.append(rid)
|
||||
if invalid_recipes:
|
||||
return error_response(
|
||||
request,
|
||||
400,
|
||||
f"Invalid recipe id(s): {', '.join(map(str, sorted(set(invalid_recipes))))}",
|
||||
)
|
||||
try:
|
||||
await meals.insert_meal_scoped(conn, domain_meal, hid)
|
||||
except aiosqlite.IntegrityError:
|
||||
# Likely an invalid foreign key (unknown member or recipe id)
|
||||
return error_response(
|
||||
request,
|
||||
400,
|
||||
"Invalid member or recipe id. Ensure participant IDs are valid household members and recipes exist.",
|
||||
)
|
||||
response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{domain_meal.id}"
|
||||
|
||||
return MealOut(
|
||||
id=domain_meal.id,
|
||||
suggested_date=domain_meal.suggested_date,
|
||||
consumed_date=domain_meal.consumed_date,
|
||||
chefs=list(domain_meal.chefs),
|
||||
cleanup=list(domain_meal.cleanup),
|
||||
consumers=list(domain_meal.consumers),
|
||||
recipes=domain_meal.recipes,
|
||||
extra_ingredients=domain_meal.extra_ingredients,
|
||||
purchase_date=domain_meal.purchase_date,
|
||||
)
|
||||
await meals.insert_meal(conn, meal)
|
||||
response.headers["Location"] = f"/api/v1/meals/{meal.id}"
|
||||
return meal
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{meal_id}",
|
||||
operation_id="updateMealV2",
|
||||
summary="Update an existing meal (scoped)",
|
||||
response_model=MealOut,
|
||||
responses={400: {"model": ProblemDetails}, 404: {"model": ProblemDetails}},
|
||||
operation_id="updateMeal",
|
||||
summary="Update an existing meal",
|
||||
responses={
|
||||
400: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Validation error",
|
||||
"content": {"application/problem+json": {}},
|
||||
},
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Meal not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
},
|
||||
},
|
||||
)
|
||||
async def update_meal_scoped(
|
||||
meal_id: int,
|
||||
meal: MealIn,
|
||||
request: Request,
|
||||
household=Depends(get_household_from_slug),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> MealOut | Response:
|
||||
async def update_meal(
|
||||
meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> meals.Meal | Response:
|
||||
if meal.id != meal_id:
|
||||
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
|
||||
hid = household["id"]
|
||||
existing = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
|
||||
|
||||
existing = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not existing:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
def _from_member(m: MemberRef) -> MemberRef:
|
||||
return MemberRef(id=m.id, display_name=m.display_name)
|
||||
validation_response = validate_meal(meal, request)
|
||||
if validation_response:
|
||||
return validation_response
|
||||
|
||||
domain_meal = meals.Meal(
|
||||
id=meal.id,
|
||||
suggested_date=meal.suggested_date,
|
||||
consumed_date=meal.consumed_date,
|
||||
chefs=[_from_member(p) for p in meal.chefs],
|
||||
cleanup=[_from_member(p) for p in meal.cleanup],
|
||||
consumers=[_from_member(p) for p in meal.consumers],
|
||||
recipes=[
|
||||
meals.MealRecipe(meal_id=r.meal_id, recipe_id=r.recipe_id, servings=r.servings)
|
||||
for r in meal.recipes
|
||||
],
|
||||
extra_ingredients=list(meal.extra_ingredients),
|
||||
)
|
||||
msg = meals.validate_meal(domain_meal)
|
||||
if msg:
|
||||
return error_response(request, 400, msg)
|
||||
# Proactive validation similar to create (user existence only)
|
||||
member_ids = {m.id for m in (*domain_meal.chefs, *domain_meal.cleanup, *domain_meal.consumers)}
|
||||
hid = household["id"]
|
||||
if member_ids:
|
||||
from users.repository import get_by_ids as get_users_by_ids
|
||||
await meals.update_meal(conn, meal)
|
||||
|
||||
users = await get_users_by_ids(conn, sorted(member_ids))
|
||||
valid_ids = set(users.keys())
|
||||
invalid = sorted(member_ids - valid_ids)
|
||||
if invalid:
|
||||
return error_response(
|
||||
request, 400, f"Invalid member id(s): {', '.join(map(str, invalid))}"
|
||||
)
|
||||
# Re-fetch and return the updated meal. Pass request and conn explicitly to avoid Depends resolution.
|
||||
return await get_meal(meal_id, request, conn)
|
||||
|
||||
if domain_meal.recipes:
|
||||
from recipes.repository import find_recipe_by_id_scoped, find_recipe_by_id
|
||||
|
||||
invalid_recipes: list[int] = []
|
||||
for r in domain_meal.recipes:
|
||||
rid = int(r.recipe_id) if r.recipe_id is not None else -1
|
||||
if rid < 0:
|
||||
invalid_recipes.append(rid)
|
||||
continue
|
||||
recipe = await find_recipe_by_id_scoped(conn, rid, hid)
|
||||
if not recipe:
|
||||
recipe = await find_recipe_by_id(conn, rid)
|
||||
if not recipe:
|
||||
invalid_recipes.append(rid)
|
||||
if invalid_recipes:
|
||||
return error_response(
|
||||
request,
|
||||
400,
|
||||
f"Invalid recipe id(s): {', '.join(map(str, sorted(set(invalid_recipes))))}",
|
||||
@router.post(
|
||||
"/{meal_id}/consumed",
|
||||
response_model=MealOut,
|
||||
operation_id="markMealConsumed",
|
||||
summary="Mark a meal as consumed",
|
||||
responses={
|
||||
400: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Validation error",
|
||||
"content": {"application/problem+json": {}},
|
||||
},
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Meal not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
},
|
||||
},
|
||||
)
|
||||
# Disallow empty extra ingredients on update as well
|
||||
for ing in domain_meal.extra_ingredients:
|
||||
name = (ing.name or "").strip()
|
||||
line = (ing.line or "").strip()
|
||||
has_product = getattr(ing, "product", None) is not None or (
|
||||
getattr(ing, "product_id", None) is not None and getattr(ing, "product_id") >= 0
|
||||
)
|
||||
if not has_product and name == "" and line == "":
|
||||
return error_response(
|
||||
request, 400, "Ingredient must include a name or line or a product"
|
||||
)
|
||||
try:
|
||||
q = float(getattr(ing, "quantity", 0))
|
||||
except Exception:
|
||||
q = getattr(ing, "quantity", 0)
|
||||
if isinstance(q, (int, float)) and q <= 0:
|
||||
return error_response(request, 400, "Ingredient quantity must be greater than 0")
|
||||
async def mark_consumed(
|
||||
meal_id: int,
|
||||
request: Request,
|
||||
consumed_date: Optional[datetime.datetime] = None,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
person: persons.Person = Depends(cookie_person),
|
||||
) -> meals.Meal | Response:
|
||||
if consumed_date and not consumed_date.tzinfo:
|
||||
return error_response(request, 400, "Consumed date must include timezone")
|
||||
|
||||
await meals.update_meal(conn, domain_meal)
|
||||
# Return updated state
|
||||
updated = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
|
||||
assert updated is not None
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
return MealOut(
|
||||
id=updated.id,
|
||||
suggested_date=updated.suggested_date,
|
||||
consumed_date=updated.consumed_date,
|
||||
chefs=list(updated.chefs),
|
||||
cleanup=list(updated.cleanup),
|
||||
consumers=list(updated.consumers),
|
||||
recipes=updated.recipes,
|
||||
extra_ingredients=updated.extra_ingredients,
|
||||
purchase_date=updated.purchase_date,
|
||||
)
|
||||
await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
|
||||
await shopping.remove_request(conn, person, meal=meal)
|
||||
|
||||
return meal
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{meal_id}",
|
||||
operation_id="deleteMealV2",
|
||||
summary="Delete a meal (scoped)",
|
||||
response_model=MealOut,
|
||||
responses={404: {"model": ProblemDetails}},
|
||||
operation_id="deleteMeal",
|
||||
summary="Delete a meal",
|
||||
responses={
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Meal not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def delete_meal_scoped(
|
||||
async def delete_meal(
|
||||
meal_id: int,
|
||||
request: Request,
|
||||
household=Depends(get_household_from_slug),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> MealOut | Response:
|
||||
hid = household["id"]
|
||||
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
|
||||
person: persons.Person = Depends(cookie_person),
|
||||
) -> meals.Meal | Response:
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
# Remove outstanding requests for this meal in current household
|
||||
from shopping.repository import remove_meal_request_scoped
|
||||
|
||||
await remove_meal_request_scoped(conn, meal_id, hid)
|
||||
await shopping.remove_request(conn, person, meal=meal)
|
||||
await meals.delete_meal(conn, meal.id)
|
||||
|
||||
return MealOut(
|
||||
id=meal.id,
|
||||
suggested_date=meal.suggested_date,
|
||||
consumed_date=meal.consumed_date,
|
||||
chefs=list(meal.chefs),
|
||||
cleanup=list(meal.cleanup),
|
||||
consumers=list(meal.consumers),
|
||||
recipes=meal.recipes,
|
||||
extra_ingredients=meal.extra_ingredients,
|
||||
purchase_date=meal.purchase_date,
|
||||
)
|
||||
return meal
|
||||
|
||||
|
||||
__all__ = ["router", "validate_meal"]
|
||||
def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[Response]:
|
||||
"""HTTP-friendly wrapper that maps service validation to ProblemDetails."""
|
||||
msg = meals.validate_meal(meal)
|
||||
if msg:
|
||||
return error_response(request, 400, msg)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from fastapi import FastAPI
|
|||
|
||||
|
||||
def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
|
||||
"""Augment FastAPI's OpenAPI spec with RFC7807 responses and JWT bearer auth.
|
||||
"""Augment FastAPI's OpenAPI spec with RFC7807 responses and cookie auth.
|
||||
|
||||
This mutates the app's OpenAPI generation in-place while delegating to the
|
||||
original generator for the base schema.
|
||||
|
|
@ -52,32 +52,33 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
|
|||
},
|
||||
},
|
||||
)
|
||||
responses.setdefault(
|
||||
"Problem403",
|
||||
{
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {},
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Legacy cookieAuth removed; JWT bearer is the only auth now
|
||||
|
||||
# Bearer (JWT) auth for v2
|
||||
# Cookie-based auth for documentation (does not enforce at runtime)
|
||||
security_schemes.setdefault(
|
||||
"bearerAuth",
|
||||
"cookieAuth",
|
||||
{
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "JWT access token in Authorization header",
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "user_id",
|
||||
"description": "Authentication via user_id cookie (session-style).",
|
||||
},
|
||||
)
|
||||
|
||||
# Normalize responses (RFC7807) but do not add cookie auth
|
||||
# Normalize v1 responses and mark cookie security for known endpoints
|
||||
paths = spec.get("paths", {})
|
||||
protected_ops: set[str] = {
|
||||
"parseRecipe",
|
||||
"createRecipe",
|
||||
"deleteRecipe",
|
||||
"markMealConsumed",
|
||||
"deleteMeal",
|
||||
"purchaseIngredients",
|
||||
"getMyShoppingList",
|
||||
"syncMyShoppingList",
|
||||
"requestMeal",
|
||||
"unrequestMeal",
|
||||
"refresh",
|
||||
}
|
||||
for path, ops in paths.items():
|
||||
if not isinstance(path, str) or not path.startswith("/api/v1/"):
|
||||
continue
|
||||
|
|
@ -96,6 +97,25 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
|
|||
if "422" not in resp:
|
||||
resp["422"] = {"$ref": "#/components/responses/Problem422"}
|
||||
|
||||
op_id = op.get("operationId")
|
||||
if isinstance(op_id, str) and op_id in protected_ops:
|
||||
security = op.setdefault("security", [])
|
||||
if not any(isinstance(s, dict) and "cookieAuth" in s for s in security):
|
||||
security.append({"cookieAuth": []})
|
||||
|
||||
# Ensure the cookie parameter is documented as required integer (non-null)
|
||||
params = op.get("parameters")
|
||||
if isinstance(params, list):
|
||||
for p in params:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if p.get("in") == "cookie" and p.get("name") == "user_id":
|
||||
p["required"] = True
|
||||
schema = p.setdefault("schema", {})
|
||||
if isinstance(schema, dict):
|
||||
schema.clear()
|
||||
schema.update({"type": "integer", "title": "User Id"})
|
||||
|
||||
# Keep endpoint-specific schemas driven by route declarations only (no forced overrides)
|
||||
|
||||
# Normalize outward-facing shopping list storeName enum to avoid empty-string value
|
||||
|
|
@ -120,28 +140,6 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
|
|||
if isinstance(store, dict) and store.get("$ref") == "#/components/schemas/StoreEnum":
|
||||
props["storeName"] = {"$ref": "#/components/schemas/StoreNameOut"}
|
||||
|
||||
# Mark bearer security for protected routes: /api/v1/users/me/* and /api/v1/households/*
|
||||
for path, ops in paths.items():
|
||||
if not isinstance(path, str) or not path.startswith("/api/v1/"):
|
||||
continue
|
||||
if not isinstance(ops, dict):
|
||||
continue
|
||||
needs_bearer = path.startswith("/api/v1/users/me/") or path.startswith(
|
||||
"/api/v1/households/"
|
||||
)
|
||||
if not needs_bearer:
|
||||
continue
|
||||
for _method, op in ops.items():
|
||||
if not isinstance(op, dict):
|
||||
continue
|
||||
security = op.setdefault("security", [])
|
||||
if not any(isinstance(s, dict) and "bearerAuth" in s for s in security):
|
||||
security.append({"bearerAuth": []})
|
||||
# ensure 403 Problem is defined on these operations
|
||||
resp = op.setdefault("responses", {})
|
||||
if "403" not in resp:
|
||||
resp["403"] = {"$ref": "#/components/responses/Problem403"}
|
||||
|
||||
return spec
|
||||
|
||||
# Rebind app.openapi to our generator (FastAPI supports this pattern). Mypy needs a narrow ignore here.
|
||||
|
|
|
|||
93
api/persons.py
Normal file
93
api/persons.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
|
||||
import persons
|
||||
from api.deps import get_db
|
||||
from common import Page
|
||||
|
||||
router = APIRouter(prefix="/persons", tags=["persons"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
operation_id="listPersons",
|
||||
response_model=Page[persons.Person],
|
||||
summary="List persons (paginated)",
|
||||
responses={
|
||||
200: {
|
||||
"description": "A page of persons",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"items": [{"id": 1, "name": "Ada Lovelace"}],
|
||||
"nextCursor": "2",
|
||||
"prevCursor": "0",
|
||||
"total": 1,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def list_persons(
|
||||
q: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Optional case-insensitive name filter (applied after page read; may be pushed to SQL in the future).",
|
||||
),
|
||||
cursor: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.",
|
||||
),
|
||||
limit: int = Query(
|
||||
50,
|
||||
ge=1,
|
||||
le=200,
|
||||
description="Maximum number of items to return (1-200).",
|
||||
),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> Page[persons.Person]:
|
||||
# v1: DB-backed pagination
|
||||
last_id = None
|
||||
if cursor:
|
||||
try:
|
||||
last_id = int(cursor)
|
||||
except ValueError:
|
||||
last_id = None
|
||||
|
||||
fetch_limit = limit + 1
|
||||
paged: List[persons.Person] = []
|
||||
if q:
|
||||
async for p in persons.search_by_name_paged(conn, q, last_id, fetch_limit):
|
||||
paged.append(p)
|
||||
else:
|
||||
async for p in persons.get_all_paged(conn, last_id, fetch_limit):
|
||||
paged.append(p)
|
||||
|
||||
has_more = len(paged) > limit
|
||||
items = paged[:limit]
|
||||
next_cursor = str(items[-1].id) if has_more and items else None
|
||||
# Compute prevCursor via DB helper
|
||||
prev_cursor: Optional[str] = None
|
||||
if items:
|
||||
first_id = items[0].id
|
||||
prev_cursor = await persons.compute_prev_cursor(conn, first_id, limit, q)
|
||||
total = await (persons.count_by_name(conn, q) if q else persons.count_all(conn))
|
||||
return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
operation_id="createPerson",
|
||||
summary="Create a person",
|
||||
response_model=persons.Person,
|
||||
)
|
||||
async def create_person(
|
||||
person: persons.Person, response: Response, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> persons.Person:
|
||||
await persons.insert_person(conn, person)
|
||||
response.headers["Location"] = f"/api/v1/persons/{person.id}"
|
||||
return person
|
||||
423
api/recipes.py
423
api/recipes.py
|
|
@ -1,21 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
|
||||
import ingredients as ingredients_mod
|
||||
import persons
|
||||
import recipes
|
||||
from api.deps import error_response, get_db, get_household_from_slug, get_current_user
|
||||
from api.deps import cookie_person, error_response, get_db
|
||||
from common import Page, ProblemDetails, ApiModel, Field
|
||||
from api.dtos import MemberRef
|
||||
|
||||
router = APIRouter(prefix="/households/{householdSlug}/recipes", tags=["recipes"])
|
||||
# Public, stateless recipes utilities
|
||||
public = APIRouter(prefix="/recipes", tags=["recipes"])
|
||||
router = APIRouter(prefix="/recipes", tags=["recipes"])
|
||||
|
||||
|
||||
# Outward DTO with required arrays in the schema
|
||||
class RecipeOut(ApiModel):
|
||||
id: int = -1
|
||||
name: str
|
||||
|
|
@ -25,245 +25,254 @@ class RecipeOut(ApiModel):
|
|||
ingredients: List[ingredients_mod.Ingredient] = Field(
|
||||
min_length=0, json_schema_extra={"minItems": 0}
|
||||
)
|
||||
based_on_recipe: Optional[int] = None
|
||||
date_created: datetime.datetime
|
||||
created_by_id: int
|
||||
created_by: Optional[MemberRef] = None
|
||||
created_by: Optional[persons.Person] = None
|
||||
date_hidden: Optional[datetime.datetime] = None
|
||||
hidden_by_id: Optional[int] = None
|
||||
hidden_by: Optional[MemberRef] = None
|
||||
hidden_by: Optional[persons.Person] = None
|
||||
|
||||
|
||||
class RecipeCreate(ApiModel):
|
||||
name: str
|
||||
link: str
|
||||
serves: int
|
||||
image_urls: List[str] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||
ingredients: List[ingredients_mod.Ingredient] = Field(
|
||||
min_length=0, json_schema_extra={"minItems": 0}
|
||||
@router.get(
|
||||
"/parse",
|
||||
response_model=RecipeOut,
|
||||
operation_id="parseRecipe",
|
||||
summary="Parse a recipe from a URL",
|
||||
responses={
|
||||
400: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Recipe not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ParseUrlIn(ApiModel):
|
||||
url: str
|
||||
|
||||
|
||||
@router.get("", response_model=Page[RecipeOut])
|
||||
async def list_recipes(
|
||||
household=Depends(get_household_from_slug),
|
||||
q: Optional[str] = Query(default=None),
|
||||
cursor: Optional[str] = Query(default=None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
async def parse_recipe_handler(
|
||||
url: str,
|
||||
request: Request,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
person=Depends(cookie_person),
|
||||
) -> recipes.Recipe | Response:
|
||||
parsed = await recipes.parse_recipe(conn, person, url)
|
||||
if not parsed:
|
||||
return error_response(request, 400, "Recipe not found")
|
||||
return parsed
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ingredients/parse",
|
||||
operation_id="parseIngredients",
|
||||
summary="Parse raw ingredient lines",
|
||||
)
|
||||
async def parse_ingredients(
|
||||
lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> List[ingredients_mod.Ingredient]:
|
||||
had_links = False
|
||||
result: List[ingredients_mod.Ingredient] = []
|
||||
for line in lines:
|
||||
ingredient = await ingredients_mod.parse_ingredient_from_link(conn, line)
|
||||
if ingredient:
|
||||
result.append(ingredient)
|
||||
had_links = True
|
||||
continue
|
||||
|
||||
ingredient = ingredients_mod.parse_ingredient_from_nlp(line)
|
||||
if ingredient:
|
||||
result.append(ingredient)
|
||||
continue
|
||||
|
||||
if had_links:
|
||||
# Transaction will commit at end of request
|
||||
pass
|
||||
|
||||
await ingredients_mod.match_existing_products(conn, result)
|
||||
return result
|
||||
|
||||
|
||||
async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[recipes.Recipe]:
|
||||
r = await recipes.find_recipe_by_id(conn, id)
|
||||
if not r:
|
||||
return None
|
||||
|
||||
r.ingredients = []
|
||||
async for ingredient in ingredients_mod.find_ingredients_by_recipe_id(conn, id):
|
||||
r.ingredients.append(ingredient)
|
||||
|
||||
r.created_by = await persons.get_by_id(conn, r.created_by_id)
|
||||
|
||||
return r
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
operation_id="listRecipes",
|
||||
response_model=Page[RecipeOut],
|
||||
summary="List recipes (paginated)",
|
||||
responses={
|
||||
200: {
|
||||
"description": "A page of recipes",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Example Recipe",
|
||||
"link": "https://example.com/recipes/1",
|
||||
"serves": 4,
|
||||
"imageUrls": [],
|
||||
"ingredients": [],
|
||||
}
|
||||
],
|
||||
"nextCursor": "2",
|
||||
"prevCursor": "0",
|
||||
"total": 1,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def list_recipes(
|
||||
request: Request,
|
||||
q: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).",
|
||||
),
|
||||
cursor: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.",
|
||||
),
|
||||
limit: int = Query(
|
||||
50,
|
||||
ge=1,
|
||||
le=200,
|
||||
description="Maximum number of items to return (1-200).",
|
||||
),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> Page[recipes.Recipe]:
|
||||
last_id = None
|
||||
if cursor:
|
||||
try:
|
||||
last_id = int(cursor)
|
||||
except ValueError:
|
||||
last_id = None
|
||||
|
||||
fetch_limit = limit + 1
|
||||
hid = household["id"]
|
||||
paged: List[recipes.Recipe] = []
|
||||
if q:
|
||||
async for r in recipes.find_recipes_by_name_paged_scoped(
|
||||
conn, q, last_id, fetch_limit, hid
|
||||
):
|
||||
async for r in recipes.find_recipes_by_name_paged(conn, q, last_id, fetch_limit):
|
||||
paged.append(r)
|
||||
else:
|
||||
async for r in recipes.get_all_paged_scoped(conn, last_id, fetch_limit, hid):
|
||||
async for r in recipes.get_all_paged(conn, last_id, fetch_limit):
|
||||
paged.append(r)
|
||||
# Filter by household_id once repositories are fully updated; currently placeholder until repo changes land.
|
||||
|
||||
has_more = len(paged) > limit
|
||||
items = paged[:limit]
|
||||
# Batch-load ingredients for the page to avoid N+1 queries
|
||||
if items:
|
||||
recipe_ids = [r.id for r in items]
|
||||
by_recipe = await ingredients_mod.find_ingredients_by_recipe_ids(conn, recipe_ids)
|
||||
for r in items:
|
||||
r.ingredients = by_recipe.get(r.id, [])
|
||||
# Lookup creators' display names
|
||||
creator_ids = {r.created_by_id for r in items if getattr(r, "created_by_id", None) is not None}
|
||||
creator_lookup: Dict[int, str] = {}
|
||||
if creator_ids:
|
||||
from users.repository import get_by_ids as get_users_by_ids
|
||||
|
||||
users = await get_users_by_ids(conn, list(creator_ids))
|
||||
creator_lookup = {uid: u.display_name for uid, u in users.items()}
|
||||
|
||||
def to_recipe_out(r: recipes.Recipe) -> RecipeOut:
|
||||
mref = None
|
||||
name = creator_lookup.get(r.created_by_id)
|
||||
if name is not None:
|
||||
mref = MemberRef(id=r.created_by_id, display_name=name)
|
||||
return RecipeOut(
|
||||
id=r.id,
|
||||
name=r.name,
|
||||
link=r.link,
|
||||
serves=r.serves,
|
||||
image_urls=r.image_urls,
|
||||
ingredients=r.ingredients,
|
||||
created_by_id=r.created_by_id,
|
||||
created_by=mref,
|
||||
)
|
||||
|
||||
next_cursor = str(items[-1].id) if has_more and items else None
|
||||
total = await (
|
||||
recipes.count_by_name_scoped(conn, q, hid) if q else recipes.count_all_scoped(conn, hid)
|
||||
# Compute prevCursor via DB helper
|
||||
prev_cursor: Optional[str] = None
|
||||
if items:
|
||||
first_id = items[0].id
|
||||
prev_cursor = await recipes.compute_prev_cursor(conn, first_id, limit, q)
|
||||
total = await (recipes.count_by_name(conn, q) if q else recipes.count_all(conn))
|
||||
return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{recipe_id}",
|
||||
response_model=RecipeOut,
|
||||
operation_id="getRecipe",
|
||||
summary="Get a single recipe",
|
||||
responses={
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Recipe not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
outward_items = [to_recipe_out(r) for r in items]
|
||||
return Page(items=outward_items, nextCursor=next_cursor, prevCursor=None, total=total)
|
||||
|
||||
|
||||
@router.get("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}})
|
||||
async def get_recipe(
|
||||
recipe_id: int,
|
||||
household=Depends(get_household_from_slug),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"])
|
||||
recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> recipes.Recipe | Response:
|
||||
r = await load_full_recipe(conn, recipe_id)
|
||||
if not r:
|
||||
return error_response(None, 404, "Recipe not found")
|
||||
# Ensure ingredients are loaded for single-recipe fetch
|
||||
await recipes.load_recipe_ingredients(conn, r)
|
||||
# load creator display name
|
||||
mref = None
|
||||
from users.repository import get_by_id as get_user_by_id
|
||||
return error_response(request, 404, "Recipe not found")
|
||||
|
||||
u = await get_user_by_id(conn, r.created_by_id)
|
||||
if u:
|
||||
mref = MemberRef(id=r.created_by_id, display_name=u.display_name)
|
||||
return RecipeOut(
|
||||
id=r.id,
|
||||
name=r.name,
|
||||
link=r.link,
|
||||
serves=r.serves,
|
||||
image_urls=r.image_urls,
|
||||
ingredients=r.ingredients,
|
||||
created_by_id=r.created_by_id,
|
||||
created_by=mref,
|
||||
return r
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=RecipeOut,
|
||||
operation_id="createRecipe",
|
||||
summary="Create a new recipe (versioning semantics applied)",
|
||||
responses={
|
||||
400: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Validation error",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}})
|
||||
async def delete_recipe(
|
||||
recipe_id: int,
|
||||
household=Depends(get_household_from_slug),
|
||||
user=Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
# Load recipe in-scope
|
||||
r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"])
|
||||
if not r:
|
||||
return error_response(None, 404, "Recipe not found")
|
||||
# Hide within household via repository and set hidden_by_id using a single UPDATE
|
||||
from recipes.repository import hide_recipe_scoped_with_actor
|
||||
|
||||
ok = await hide_recipe_scoped_with_actor(conn, recipe_id, household["id"], user.id)
|
||||
if not ok:
|
||||
return error_response(None, 404, "Recipe not found")
|
||||
# Best-effort creator lookup
|
||||
mref = None
|
||||
from users.repository import get_by_id as get_user_by_id
|
||||
|
||||
u = await get_user_by_id(conn, r.created_by_id)
|
||||
if u:
|
||||
mref = MemberRef(id=r.created_by_id, display_name=u.display_name)
|
||||
# hiddenBy is the current user
|
||||
hidden = MemberRef(id=user.id, display_name=user.display_name)
|
||||
return RecipeOut(
|
||||
id=r.id,
|
||||
name=r.name,
|
||||
link=r.link,
|
||||
serves=r.serves,
|
||||
image_urls=r.image_urls,
|
||||
ingredients=r.ingredients,
|
||||
created_by_id=r.created_by_id,
|
||||
created_by=mref,
|
||||
hidden_by_id=user.id,
|
||||
hidden_by=hidden,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=RecipeOut, responses={400: {"model": ProblemDetails}})
|
||||
async def create_recipe(
|
||||
recipe: RecipeCreate,
|
||||
recipe: recipes.Recipe,
|
||||
request: Request,
|
||||
response: Response,
|
||||
household=Depends(get_household_from_slug),
|
||||
user=Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
user: persons.Person = Depends(cookie_person),
|
||||
) -> recipes.Recipe | Response:
|
||||
if not recipe.ingredients:
|
||||
return error_response(None, 400, "Recipe must have at least one ingredient")
|
||||
# Disallow empty ingredients with neither product nor textual content
|
||||
for ing in recipe.ingredients:
|
||||
name = (ing.name or "").strip()
|
||||
line = (ing.line or "").strip()
|
||||
has_product = getattr(ing, "product", None) is not None or (
|
||||
getattr(ing, "product_id", None) is not None and getattr(ing, "product_id") >= 0
|
||||
)
|
||||
if not has_product and name == "" and line == "":
|
||||
return error_response(None, 400, "Ingredient must include a name or line or a product")
|
||||
# quantity must be > 0
|
||||
try:
|
||||
q = float(getattr(ing, "quantity", 0))
|
||||
except Exception:
|
||||
q = getattr(ing, "quantity", 0)
|
||||
if isinstance(q, (int, float)) and q <= 0:
|
||||
return error_response(None, 400, "Ingredient quantity must be greater than 0")
|
||||
hid = household["id"]
|
||||
# Build domain model and insert
|
||||
r = recipes.Recipe(
|
||||
id=-1,
|
||||
name=recipe.name,
|
||||
link=recipe.link,
|
||||
serves=recipe.serves,
|
||||
image_urls=recipe.image_urls,
|
||||
ingredients=list(recipe.ingredients),
|
||||
created_by_id=user.id,
|
||||
)
|
||||
await recipes.insert_recipe_scoped(conn, r, hid)
|
||||
return error_response(request, 400, "Recipe must have at least one ingredient")
|
||||
|
||||
if recipe.id >= 0:
|
||||
await recipes.hide_recipe(conn, recipe.id, user)
|
||||
recipe.based_on_recipe = recipe.id
|
||||
recipe.id = 0
|
||||
|
||||
recipe.created_by_id = user.id
|
||||
await recipes.insert_recipe(conn, recipe)
|
||||
for ingredient in recipe.ingredients:
|
||||
ingredient.recipe_id = r.id
|
||||
ingredient.recipe_id = recipe.id
|
||||
if ingredient.product:
|
||||
ingredient.product_id = ingredient.product.id
|
||||
try:
|
||||
await ingredients_mod.insert_ingredient(conn, ingredient)
|
||||
except ValueError as e:
|
||||
return error_response(None, 400, str(e))
|
||||
response.headers["Location"] = f"/api/v1/households/{household['slug']}/recipes/{r.id}"
|
||||
created = MemberRef(id=user.id, display_name=user.display_name)
|
||||
out = RecipeOut(
|
||||
id=r.id,
|
||||
name=r.name,
|
||||
link=r.link,
|
||||
serves=r.serves,
|
||||
image_urls=r.image_urls,
|
||||
ingredients=r.ingredients,
|
||||
created_by_id=r.created_by_id,
|
||||
created_by=created,
|
||||
|
||||
# Transaction will commit at end of request
|
||||
# Set Location to the new resource
|
||||
response.headers["Location"] = f"/api/v1/recipes/{recipe.id}"
|
||||
return recipe
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{recipe_id}",
|
||||
response_model=recipes.Recipe,
|
||||
operation_id="deleteRecipe",
|
||||
summary="Soft-delete (hide) a recipe",
|
||||
responses={
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Recipe not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
return out.model_dump(by_alias=False)
|
||||
|
||||
|
||||
@router.post("/parse-from-url", response_model=RecipeCreate)
|
||||
async def parse_from_url(
|
||||
body: ParseUrlIn,
|
||||
household=Depends(get_household_from_slug),
|
||||
user=Depends(get_current_user),
|
||||
async def delete_recipe(
|
||||
recipe_id: int,
|
||||
request: Request,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
# Build an unsaved Recipe object using NLP parsing and product matching; do not insert
|
||||
r = await recipes.parse_recipe(conn, user, body.url)
|
||||
if not r:
|
||||
# Parsing failed: treat as unprocessable rather than not-found
|
||||
return error_response(None, 422, "Unable to parse recipe from URL")
|
||||
# Return the same shape a client would POST to create
|
||||
return RecipeCreate(
|
||||
name=r.name,
|
||||
link=r.link,
|
||||
serves=r.serves,
|
||||
image_urls=r.image_urls,
|
||||
ingredients=r.ingredients,
|
||||
)
|
||||
user: persons.Person = Depends(cookie_person),
|
||||
) -> recipes.Recipe | Response:
|
||||
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
|
||||
if not recipe:
|
||||
return error_response(request, 404, "Recipe not found")
|
||||
|
||||
|
||||
"""
|
||||
Note: public parse endpoint moved to /api/v1/ingredients/parse (see api/ingredients.py)
|
||||
"""
|
||||
await recipes.hide_recipe(conn, recipe_id, user)
|
||||
return recipe
|
||||
|
|
|
|||
373
api/shopping.py
373
api/shopping.py
|
|
@ -1,39 +1,134 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Literal
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
|
||||
import ingredients
|
||||
import meals
|
||||
import persons
|
||||
import recipes
|
||||
import shopping
|
||||
from common import ApiModel as _ApiModel
|
||||
from api.deps import error_response, get_db, get_household_from_slug, get_current_user
|
||||
from api.shopping_models import (
|
||||
CurrentShoppingList,
|
||||
ShoppingListOut,
|
||||
PurchasedShoppingList,
|
||||
_to_ingredient_item,
|
||||
ListIngredientItem,
|
||||
_to_meal_item,
|
||||
_to_shopping_list_out,
|
||||
RequestedMealItem,
|
||||
PurchaseListIn,
|
||||
from shopping.models import StoreEnum
|
||||
from api.deps import cookie_person, error_response, get_db
|
||||
from common import ApiModel, Field, ProblemDetails
|
||||
|
||||
|
||||
# Outward-facing models to reduce unnecessary nulls in API responses
|
||||
class ListIngredientItem(ApiModel):
|
||||
kind: Literal["ingredient"] = "ingredient"
|
||||
id: int = -1
|
||||
ingredient_id: int
|
||||
person_id: int
|
||||
created_date: datetime
|
||||
# These may be present when the ingredient is part of a requested meal
|
||||
list_id: int | None = None
|
||||
meal_id: int | None = None
|
||||
recipe_id: int | None = None
|
||||
|
||||
|
||||
class RequestedMealItem(ApiModel):
|
||||
kind: Literal["requestedMeal"] = "requestedMeal"
|
||||
id: int = -1
|
||||
person_id: int
|
||||
meal_id: int
|
||||
created_date: datetime
|
||||
|
||||
|
||||
# Input DTOs (separate from internal DB/domain models)
|
||||
class IngredientPurchaseItemIn(ApiModel):
|
||||
ingredient_id: int
|
||||
person_id: int
|
||||
created_date: datetime | None = None
|
||||
meal_id: int | None = None
|
||||
recipe_id: int | None = None
|
||||
|
||||
|
||||
class PurchaseListIn(ApiModel):
|
||||
store_name: StoreEnum
|
||||
items: List[IngredientPurchaseItemIn]
|
||||
|
||||
|
||||
# Output DTOs for purchased lists
|
||||
class StoreNameOut(str, Enum):
|
||||
woolworths = "woolworths"
|
||||
coles = "coles"
|
||||
home = "home"
|
||||
|
||||
|
||||
class ShoppingListOut(ApiModel):
|
||||
id: int
|
||||
created_date: datetime
|
||||
# outward-only enum values: include "home" instead of an empty string
|
||||
store_name: Literal["woolworths", "coles", "home"]
|
||||
purchased_by_id: int
|
||||
purchased_by: persons.Person | None = None
|
||||
# Make items required in the schema; callers must always send an array (possibly empty)
|
||||
items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||
|
||||
|
||||
router = APIRouter(prefix="/shopping", tags=["shopping"])
|
||||
|
||||
|
||||
class CurrentShoppingList(ApiModel):
|
||||
outstanding_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||
requested_meals: List[RequestedMealItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||
# Make all collections required to avoid undefined/null semantics in clients
|
||||
purchased_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||
|
||||
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
||||
meals_lookup: Dict[int, meals.Meal]
|
||||
shopping_list_lookup: Dict[int, ShoppingListOut]
|
||||
recipes_lookup: Dict[int, recipes.Recipe]
|
||||
|
||||
|
||||
# Mapping helpers from domain -> outward API
|
||||
def _to_ingredient_item(item: shopping.ShoppingListItem) -> ListIngredientItem:
|
||||
return ListIngredientItem(
|
||||
id=item.id,
|
||||
ingredient_id=item.ingredient_id if item.ingredient_id is not None else -1,
|
||||
person_id=item.person_id,
|
||||
created_date=item.created_date,
|
||||
list_id=item.list_id,
|
||||
meal_id=item.meal_id,
|
||||
recipe_id=item.recipe_id,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping"])
|
||||
|
||||
def _to_meal_item(item: shopping.ShoppingListItem) -> RequestedMealItem:
|
||||
return RequestedMealItem(
|
||||
id=item.id,
|
||||
person_id=item.person_id,
|
||||
meal_id=item.meal_id if item.meal_id is not None else -1,
|
||||
created_date=item.created_date,
|
||||
)
|
||||
|
||||
|
||||
def _to_shopping_list_out(sl: shopping.ShoppingList) -> ShoppingListOut:
|
||||
# Map internal enum value "" to outward-friendly "home"
|
||||
outward_store = "home" if sl.store_name == StoreEnum.home else sl.store_name.value
|
||||
return ShoppingListOut(
|
||||
id=sl.id,
|
||||
created_date=sl.created_date,
|
||||
store_name=outward_store,
|
||||
purchased_by_id=sl.purchased_by_id,
|
||||
purchased_by=sl.purchased_by,
|
||||
items=[_to_ingredient_item(i) for i in sl.items],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/current",
|
||||
response_model=CurrentShoppingList,
|
||||
operation_id="getCurrentShoppingListV2",
|
||||
summary="Get the current aggregated shopping list (scoped)",
|
||||
operation_id="getCurrentShoppingList",
|
||||
summary="Get the current aggregated shopping list",
|
||||
)
|
||||
async def get_current_shopping_list_scoped(
|
||||
household=Depends(get_household_from_slug),
|
||||
async def get_current_shopping_list(
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> CurrentShoppingList:
|
||||
hid = household["id"]
|
||||
(
|
||||
outstanding_requests,
|
||||
purchased_requests,
|
||||
|
|
@ -41,14 +136,14 @@ async def get_current_shopping_list_scoped(
|
|||
meals_lookup,
|
||||
recipes_lookup,
|
||||
ingredients_lookup,
|
||||
) = await shopping.get_outstanding_requests_scoped(conn, hid)
|
||||
|
||||
# Load full lists for additional lookups (by household)
|
||||
) = await shopping.get_outstanding_requests(conn)
|
||||
other_shopping_list_ids = {item.list_id for item in purchased_requests}
|
||||
|
||||
# Load full lists for additional lookups
|
||||
other_lists_domain: Dict[int, shopping.ShoppingList] = {}
|
||||
for list_id in other_shopping_list_ids:
|
||||
if list_id is not None:
|
||||
sl = await shopping.load_shopping_list_scoped(conn, list_id, hid)
|
||||
sl = await shopping.load_shopping_list(conn, list_id)
|
||||
if sl is not None:
|
||||
other_lists_domain[list_id] = sl
|
||||
|
||||
|
|
@ -59,7 +154,10 @@ async def get_current_shopping_list_scoped(
|
|||
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
|
||||
)
|
||||
|
||||
shopping_list_lookup = {k: _to_shopping_list_out(v) for k, v in other_lists_domain.items()}
|
||||
# Convert domain shopping lists to outward form for response
|
||||
shopping_list_lookup: Dict[int, ShoppingListOut] = {
|
||||
k: _to_shopping_list_out(v) for k, v in other_lists_domain.items()
|
||||
}
|
||||
|
||||
return CurrentShoppingList(
|
||||
outstanding_items=[_to_ingredient_item(i) for i in outstanding_requests],
|
||||
|
|
@ -72,20 +170,31 @@ async def get_current_shopping_list_scoped(
|
|||
)
|
||||
|
||||
|
||||
class PurchasedShoppingList(ApiModel):
|
||||
list: ShoppingListOut
|
||||
# Lookup maps are required to be present (may be empty)
|
||||
meals_lookup: Dict[int, meals.Meal]
|
||||
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
||||
recipes_lookup: Dict[int, recipes.Recipe]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{list_id}",
|
||||
response_model=PurchasedShoppingList,
|
||||
operation_id="getShoppingListV2",
|
||||
summary="Get a purchased shopping list by id (scoped)",
|
||||
operation_id="getShoppingList",
|
||||
summary="Get a purchased shopping list by id",
|
||||
responses={
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Shopping list not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def get_shopping_list_scoped(
|
||||
list_id: int,
|
||||
request: Request,
|
||||
household=Depends(get_household_from_slug),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
async def get_shopping_list(
|
||||
list_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> PurchasedShoppingList | Response:
|
||||
hid = household["id"]
|
||||
shopping_list = await shopping.load_shopping_list_scoped(conn, list_id, hid)
|
||||
shopping_list = await shopping.load_shopping_list(conn, list_id)
|
||||
if not shopping_list:
|
||||
return error_response(request, 404, "Shopping list not found")
|
||||
|
||||
|
|
@ -103,20 +212,35 @@ async def get_shopping_list_scoped(
|
|||
@router.post(
|
||||
"",
|
||||
response_model=PurchasedShoppingList,
|
||||
operation_id="purchaseIngredientsV2",
|
||||
summary="Purchase ingredients for a shopping list (scoped)",
|
||||
operation_id="purchaseIngredients",
|
||||
summary="Purchase ingredients for a shopping list",
|
||||
responses={
|
||||
400: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Validation error",
|
||||
"content": {"application/problem+json": {}},
|
||||
},
|
||||
401: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Unauthorized (invalid or unknown user)",
|
||||
"content": {"application/problem+json": {}},
|
||||
},
|
||||
},
|
||||
)
|
||||
async def purchase_ingredients_scoped(
|
||||
async def purchase_ingredients(
|
||||
shopping_list: PurchaseListIn,
|
||||
request: Request,
|
||||
household=Depends(get_household_from_slug),
|
||||
user=Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
person: persons.Person = Depends(cookie_person),
|
||||
) -> PurchasedShoppingList | Response:
|
||||
# Ensure the caller is authenticated and maps to a known user
|
||||
if not person:
|
||||
return error_response(request, 401, "Unauthorized")
|
||||
|
||||
# Map outward input DTO to domain model
|
||||
domain_items: List[shopping.ShoppingListItem] = []
|
||||
for it in shopping_list.items:
|
||||
created = it.created_date or __import__("datetime").datetime.now().astimezone()
|
||||
created = it.created_date or datetime.now().astimezone()
|
||||
domain_items.append(
|
||||
shopping.ShoppingListItem(
|
||||
ingredient_id=it.ingredient_id,
|
||||
|
|
@ -128,16 +252,17 @@ async def purchase_ingredients_scoped(
|
|||
)
|
||||
|
||||
domain_list = shopping.ShoppingList(
|
||||
items=domain_items, store_name=shopping_list.store_name, purchased_by_id=user.id
|
||||
purchased_by=person, items=domain_items, store_name=shopping_list.store_name
|
||||
)
|
||||
|
||||
try:
|
||||
# Perform purchase; repository enforces relationships, and inputs were household-scoped
|
||||
await shopping.purchase(conn, domain_list)
|
||||
except ValueError as e:
|
||||
# Map domain validation errors to a proper Problem Details response
|
||||
return error_response(request, 400, str(e))
|
||||
|
||||
# Lookups for outward response
|
||||
# Build lookup maps and construct the outward response with required collections
|
||||
meals_lookup: Dict[int, meals.Meal]
|
||||
recipes_lookup: Dict[int, recipes.Recipe]
|
||||
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
||||
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
|
||||
conn, domain_list.items
|
||||
)
|
||||
|
|
@ -149,113 +274,105 @@ async def purchase_ingredients_scoped(
|
|||
)
|
||||
|
||||
|
||||
class MealIdWrapper(_ApiModel):
|
||||
@router.get(
|
||||
"/current/me/ingredients",
|
||||
operation_id="getMyShoppingList",
|
||||
summary="Get my outstanding ingredient requests",
|
||||
)
|
||||
async def get_my_shopping_list(
|
||||
conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)
|
||||
) -> List[ingredients.Ingredient]:
|
||||
return await shopping.get_persons_requests(conn, person.id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/current/me/ingredients",
|
||||
operation_id="syncMyShoppingList",
|
||||
summary="Sync my outstanding ingredient requests",
|
||||
)
|
||||
async def sync_my_shopping_list(
|
||||
requests: List[ingredients.Ingredient],
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
person: persons.Person = Depends(cookie_person),
|
||||
) -> List[ingredients.Ingredient]:
|
||||
def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool:
|
||||
return a.id == b.id or a.line == b.line
|
||||
|
||||
my_shopping_list = await shopping.get_persons_requests(conn, person.id)
|
||||
to_remove = [r for r in my_shopping_list if not any(isMatching(r, req) for req in requests)]
|
||||
to_add = [req for req in requests if not any(isMatching(req, r) for r in my_shopping_list)]
|
||||
|
||||
for r in to_remove:
|
||||
await shopping.remove_request(conn, person, ingredient=r)
|
||||
|
||||
for r in to_add:
|
||||
if r.id < 0:
|
||||
await ingredients.insert_ingredient(conn, r)
|
||||
await shopping.request(conn, person, ingredient=r)
|
||||
|
||||
return await get_my_shopping_list(conn, person)
|
||||
|
||||
|
||||
class MealIdWrapper(ApiModel):
|
||||
meal_id: int
|
||||
|
||||
|
||||
class Ok(_ApiModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
@router.post(
|
||||
"/current/meals/me",
|
||||
response_model=RequestedMealItem,
|
||||
operation_id="requestMealV2",
|
||||
summary="Request a meal for shopping (scoped)",
|
||||
operation_id="requestMeal",
|
||||
summary="Request a meal for shopping",
|
||||
responses={
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Meal not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def request_meal_scoped(
|
||||
async def request_meal(
|
||||
r: MealIdWrapper,
|
||||
request: Request,
|
||||
household=Depends(get_household_from_slug),
|
||||
user=Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
hid = household["id"]
|
||||
from meals.repository import find_meal_by_id_scoped
|
||||
|
||||
meal = await find_meal_by_id_scoped(conn, r.meal_id, hid)
|
||||
person: persons.Person = Depends(cookie_person),
|
||||
) -> RequestedMealItem | Response:
|
||||
meal = await meals.find_meal_by_id(conn, r.meal_id)
|
||||
if not meal:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
try:
|
||||
item = await shopping.request_meal_scoped(conn, meal, hid, user.id)
|
||||
except ValueError as e:
|
||||
return error_response(request, 400, str(e))
|
||||
return _to_meal_item(item)
|
||||
response = await shopping.request(conn, person, meal=meal)
|
||||
return _to_meal_item(response)
|
||||
|
||||
|
||||
class Ok(ApiModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/current/meals/{meal_id}",
|
||||
response_model=Ok,
|
||||
operation_id="unrequestMealV2",
|
||||
summary="Remove a meal request (scoped)",
|
||||
operation_id="unrequestMeal",
|
||||
summary="Remove a meal request",
|
||||
responses={
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Meal not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def unrequest_meal_scoped(
|
||||
async def unrequest_meal(
|
||||
meal_id: int,
|
||||
household=Depends(get_household_from_slug),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
hid = household["id"]
|
||||
await shopping.remove_meal_request_scoped(conn, meal_id, hid)
|
||||
return Ok()
|
||||
|
||||
|
||||
class IngredientIdWrapper(_ApiModel):
|
||||
ingredient_id: int
|
||||
|
||||
|
||||
@router.post(
|
||||
"/current/ingredients",
|
||||
response_model=ListIngredientItem,
|
||||
operation_id="requestIngredientV2",
|
||||
summary="Request an ingredient for shopping (scoped)",
|
||||
)
|
||||
async def request_ingredient_scoped(
|
||||
r: IngredientIdWrapper,
|
||||
request: Request,
|
||||
household=Depends(get_household_from_slug),
|
||||
user=Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
hid = household["id"]
|
||||
from ingredients.repository import find_ingredient_by_id
|
||||
person: persons.Person = Depends(cookie_person),
|
||||
) -> Ok | Response:
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
ingredient = await find_ingredient_by_id(conn, r.ingredient_id)
|
||||
if not ingredient:
|
||||
return error_response(request, 404, "Ingredient not found")
|
||||
|
||||
try:
|
||||
item = await shopping.request_ingredient_scoped(conn, ingredient, hid, user.id)
|
||||
except ValueError as e:
|
||||
return error_response(request, 400, str(e))
|
||||
return _to_ingredient_item(item)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/current/ingredients",
|
||||
response_model=Ok,
|
||||
operation_id="unrequestIngredientV2",
|
||||
summary="Remove an ingredient request (scoped)",
|
||||
)
|
||||
async def unrequest_ingredient_scoped(
|
||||
r: IngredientIdWrapper,
|
||||
household=Depends(get_household_from_slug),
|
||||
user=Depends(get_current_user),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
"""Remove a personal ad-hoc ingredient request for the current user in this household.
|
||||
|
||||
Idempotent: returns ok=true whether or not a row was actually deleted.
|
||||
"""
|
||||
hid = household["id"]
|
||||
await shopping.remove_ingredient_request_scoped(conn, r.ingredient_id, user.id, hid)
|
||||
await shopping.remove_request(conn, person, meal=meal)
|
||||
return Ok()
|
||||
|
||||
|
||||
# Re-export shared DTOs for importers
|
||||
__all__ = [
|
||||
"router",
|
||||
"CurrentShoppingList",
|
||||
"ShoppingListOut",
|
||||
"PurchasedShoppingList",
|
||||
]
|
||||
# Removed duplicate placeholder endpoints left over from earlier scaffolding
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, cast
|
||||
from enum import Enum
|
||||
|
||||
import ingredients
|
||||
import meals
|
||||
from api.dtos import MemberRef
|
||||
import recipes
|
||||
import shopping
|
||||
from common import ApiModel, Field
|
||||
from shopping.models import StoreEnum
|
||||
|
||||
|
||||
# Outward-facing models and mapping helpers shared by shopping API
|
||||
class ListIngredientItem(ApiModel):
|
||||
kind: Literal["ingredient"] = "ingredient"
|
||||
id: int = -1
|
||||
ingredient_id: int
|
||||
person_id: int
|
||||
created_date: datetime
|
||||
# These may be present when the ingredient is part of a requested meal
|
||||
list_id: int | None = None
|
||||
meal_id: int | None = None
|
||||
recipe_id: int | None = None
|
||||
|
||||
|
||||
class RequestedMealItem(ApiModel):
|
||||
kind: Literal["requestedMeal"] = "requestedMeal"
|
||||
id: int = -1
|
||||
person_id: int
|
||||
meal_id: int
|
||||
created_date: datetime
|
||||
|
||||
|
||||
# Input DTOs (separate from internal DB/domain models)
|
||||
class IngredientPurchaseItemIn(ApiModel):
|
||||
ingredient_id: int
|
||||
person_id: int
|
||||
created_date: datetime | None = None
|
||||
meal_id: int | None = None
|
||||
recipe_id: int | None = None
|
||||
|
||||
|
||||
class PurchaseListIn(ApiModel):
|
||||
store_name: StoreEnum
|
||||
items: List[IngredientPurchaseItemIn]
|
||||
|
||||
|
||||
# Output DTOs for purchased lists
|
||||
class StoreNameOut(str, Enum):
|
||||
woolworths = "woolworths"
|
||||
coles = "coles"
|
||||
home = "home"
|
||||
|
||||
|
||||
class ShoppingListOut(ApiModel):
|
||||
id: int
|
||||
created_date: datetime
|
||||
# outward-only enum values: include "home" instead of an empty string
|
||||
store_name: Literal["woolworths", "coles", "home"]
|
||||
purchased_by_id: int
|
||||
purchased_by: MemberRef | None = None
|
||||
# Make items required in the schema; callers must always send an array (possibly empty)
|
||||
items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||
|
||||
|
||||
class CurrentShoppingList(ApiModel):
|
||||
outstanding_items: List[ListIngredientItem] = Field(
|
||||
min_length=0, json_schema_extra={"minItems": 0}
|
||||
)
|
||||
requested_meals: List[RequestedMealItem] = Field(
|
||||
min_length=0, json_schema_extra={"minItems": 0}
|
||||
)
|
||||
# Make all collections required to avoid undefined/null semantics in clients
|
||||
purchased_items: List[ListIngredientItem] = Field(
|
||||
min_length=0, json_schema_extra={"minItems": 0}
|
||||
)
|
||||
|
||||
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
||||
meals_lookup: Dict[int, meals.Meal]
|
||||
shopping_list_lookup: Dict[int, ShoppingListOut]
|
||||
recipes_lookup: Dict[int, recipes.Recipe]
|
||||
|
||||
|
||||
class PurchasedShoppingList(ApiModel):
|
||||
list: ShoppingListOut
|
||||
# Lookup maps are required to be present (may be empty)
|
||||
meals_lookup: Dict[int, meals.Meal]
|
||||
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
||||
recipes_lookup: Dict[int, recipes.Recipe]
|
||||
|
||||
|
||||
def _to_ingredient_item(item: shopping.ShoppingListItem) -> ListIngredientItem:
|
||||
return ListIngredientItem(
|
||||
id=item.id,
|
||||
ingredient_id=item.ingredient_id if item.ingredient_id is not None else -1,
|
||||
person_id=item.person_id,
|
||||
created_date=item.created_date,
|
||||
list_id=item.list_id,
|
||||
meal_id=item.meal_id,
|
||||
recipe_id=item.recipe_id,
|
||||
)
|
||||
|
||||
|
||||
def _to_meal_item(item: shopping.ShoppingListItem) -> RequestedMealItem:
|
||||
return RequestedMealItem(
|
||||
id=item.id,
|
||||
person_id=item.person_id,
|
||||
meal_id=item.meal_id if item.meal_id is not None else -1,
|
||||
created_date=item.created_date,
|
||||
)
|
||||
|
||||
|
||||
def _to_shopping_list_out(sl: shopping.ShoppingList) -> ShoppingListOut:
|
||||
# Map internal enum value "" to outward-friendly "home"
|
||||
if sl.store_name == StoreEnum.home:
|
||||
outward_store: Literal["woolworths", "coles", "home"] = "home"
|
||||
else:
|
||||
# Remaining enum values are 'woolworths' or 'coles'
|
||||
outward_store = cast(Literal["woolworths", "coles"], sl.store_name.value)
|
||||
# Map purchased_by if present: expect ShoppingList.purchased_by to carry display_name if available
|
||||
mref = None
|
||||
if sl.purchased_by is not None:
|
||||
try:
|
||||
# sl.purchased_by may be a lightweight object with id/name
|
||||
pid = getattr(sl.purchased_by, "id", None)
|
||||
pname = getattr(sl.purchased_by, "name", None) or getattr(
|
||||
sl.purchased_by, "display_name", None
|
||||
)
|
||||
if isinstance(pid, int) and pname:
|
||||
mref = MemberRef(id=pid, display_name=pname)
|
||||
except Exception:
|
||||
mref = None
|
||||
return ShoppingListOut(
|
||||
id=sl.id,
|
||||
created_date=sl.created_date,
|
||||
store_name=outward_store,
|
||||
purchased_by_id=sl.purchased_by_id,
|
||||
purchased_by=mref,
|
||||
items=[_to_ingredient_item(i) for i in sl.items],
|
||||
)
|
||||
8
db.py
8
db.py
|
|
@ -20,13 +20,9 @@ async def create(conn: aiosqlite.Connection):
|
|||
|
||||
await recipe_db.create(conn)
|
||||
|
||||
import users.repository as users_db
|
||||
import persons.repository as person_db
|
||||
|
||||
await users_db.create(conn)
|
||||
|
||||
import households.repository as households_db
|
||||
|
||||
await households_db.create(conn)
|
||||
await person_db.create(conn)
|
||||
|
||||
import meals.repository as meals_db
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
from households.models import Household as Household, HouseholdInvitation as HouseholdInvitation
|
||||
from households.repository import create as create
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
from __future__ import annotations
|
||||
from typing import ClassVar
|
||||
|
||||
from common import ApiModel
|
||||
|
||||
|
||||
class Household(ApiModel):
|
||||
KEYS: ClassVar[list[str]] = ["id", "name", "slug"]
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
class HouseholdMember(ApiModel):
|
||||
KEYS: ClassVar[list[str]] = ["id", "display_name", "role"]
|
||||
id: int
|
||||
display_name: str
|
||||
role: str
|
||||
|
||||
|
||||
class HouseholdInvitation(ApiModel):
|
||||
KEYS: ClassVar[list[str]] = [
|
||||
"id",
|
||||
"household_id",
|
||||
"invited_by_user_id",
|
||||
"token",
|
||||
"expires_at",
|
||||
"status",
|
||||
]
|
||||
id: int
|
||||
household_id: int
|
||||
invited_by_user_id: int
|
||||
token: str
|
||||
expires_at: str
|
||||
status: str
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
from __future__ import annotations
|
||||
from typing import List
|
||||
from households.models import Household, HouseholdMember, HouseholdInvitation
|
||||
|
||||
|
||||
async def create(conn):
|
||||
# Households table
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Household (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Membership table
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS HouseholdMember (
|
||||
user_id INTEGER NOT NULL,
|
||||
household_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, household_id),
|
||||
FOREIGN KEY(user_id) REFERENCES User(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(household_id) REFERENCES Household(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Invitations table
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS HouseholdInvitation (
|
||||
id INTEGER PRIMARY KEY,
|
||||
household_id INTEGER NOT NULL,
|
||||
invited_by_user_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
FOREIGN KEY(household_id) REFERENCES Household(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(invited_by_user_id) REFERENCES User(id) ON DELETE SET NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Indices
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_household_slug ON Household(slug);")
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_household_member_household ON HouseholdMember(household_id);"
|
||||
)
|
||||
|
||||
|
||||
async def member_ids_in_household(conn, household_id: int, user_ids: list[int]) -> set[int]:
|
||||
"""Return the subset of user_ids that are members of the given household.
|
||||
|
||||
Uses HouseholdMember join User to ensure users exist.
|
||||
"""
|
||||
if not user_ids:
|
||||
return set()
|
||||
placeholders = ",".join(["?"] * len(user_ids))
|
||||
query = f"""
|
||||
SELECT u.id
|
||||
FROM HouseholdMember hm
|
||||
JOIN User u ON u.id = hm.user_id
|
||||
WHERE hm.household_id = ? AND u.id IN ({placeholders})
|
||||
"""
|
||||
valid: set[int] = set()
|
||||
async with conn.execute(query, (household_id, *sorted(user_ids))) as c:
|
||||
async for row in c:
|
||||
valid.add(int(row[0]))
|
||||
return valid
|
||||
|
||||
|
||||
async def are_members(conn, household_id: int, user_ids: list[int]) -> bool:
|
||||
"""True only if all provided user_ids are members of the household."""
|
||||
if not user_ids:
|
||||
return True
|
||||
found = await member_ids_in_household(conn, household_id, user_ids)
|
||||
return found == set(user_ids)
|
||||
|
||||
|
||||
async def list_for_user(conn, user_id: int) -> List[Household]:
|
||||
results: List[Household] = []
|
||||
cols = ", ".join([f"h.{k}" for k in Household.KEYS])
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {cols} FROM Household h
|
||||
JOIN HouseholdMember m ON m.household_id = h.id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY h.id
|
||||
""",
|
||||
(user_id,),
|
||||
) as c:
|
||||
async for row in c:
|
||||
results.append(Household(**{k: v for k, v in zip(Household.KEYS, row)}))
|
||||
return results
|
||||
|
||||
|
||||
async def create_for_user(conn, name: str, slug: str, user_id: int) -> Household | None:
|
||||
try:
|
||||
async with conn.execute(
|
||||
"INSERT INTO Household (name, slug) VALUES (?, ?)", (name, slug)
|
||||
) as cur:
|
||||
lrid = cur.lastrowid
|
||||
if lrid is None:
|
||||
return None
|
||||
hid = int(lrid)
|
||||
await conn.execute(
|
||||
"INSERT INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)",
|
||||
(user_id, hid, "admin"),
|
||||
)
|
||||
return Household(id=hid, name=name, slug=slug)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def list_members(conn, household_id: int) -> List[HouseholdMember]:
|
||||
members: List[HouseholdMember] = []
|
||||
cols = ", ".join([f"u.{k}" for k in ["id", "display_name"]] + ["m.role"])
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {cols}
|
||||
FROM HouseholdMember m
|
||||
JOIN User u ON u.id = m.user_id
|
||||
WHERE m.household_id = ?
|
||||
ORDER BY lower(u.display_name), u.id
|
||||
""",
|
||||
(household_id,),
|
||||
) as c:
|
||||
async for row in c:
|
||||
members.append(
|
||||
HouseholdMember(**{k: v for k, v in zip(HouseholdMember.KEYS, row)})
|
||||
)
|
||||
return members
|
||||
|
||||
|
||||
async def create_invitation(
|
||||
conn, household_id: int, invited_by_user_id: int, token: str, expires_at: str
|
||||
) -> bool:
|
||||
try:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO HouseholdInvitation (household_id, invited_by_user_id, token, expires_at, status)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(household_id, invited_by_user_id, token, expires_at, "pending"),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def get_invitation_by_token(conn, token: str) -> HouseholdInvitation | None:
|
||||
cols = ", ".join(HouseholdInvitation.KEYS)
|
||||
async with conn.execute(
|
||||
f"SELECT {cols} FROM HouseholdInvitation WHERE token = ?",
|
||||
(token,),
|
||||
) as c:
|
||||
row = await c.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return HouseholdInvitation(**{k: v for k, v in zip(HouseholdInvitation.KEYS, row)})
|
||||
|
||||
|
||||
async def accept_invitation(conn, invitation_id: int, user_id: int, household_id: int):
|
||||
await conn.execute(
|
||||
"INSERT OR IGNORE INTO HouseholdMember (user_id, household_id, role) VALUES (?, ?, ?)",
|
||||
(user_id, household_id, "member"),
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE HouseholdInvitation SET status = 'accepted' WHERE id = ?",
|
||||
(invitation_id,),
|
||||
)
|
||||
|
||||
|
||||
async def get_household_by_id(conn, household_id: int) -> Household | None:
|
||||
cols = ", ".join(Household.KEYS)
|
||||
async with conn.execute(
|
||||
f"SELECT {cols} FROM Household WHERE id = ?",
|
||||
(household_id,),
|
||||
) as c:
|
||||
row = await c.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Household(**{k: v for k, v in zip(Household.KEYS, row)})
|
||||
|
|
@ -46,19 +46,3 @@ class Ingredient(ApiModel):
|
|||
except ValueError:
|
||||
return v
|
||||
return v
|
||||
|
||||
# Quantity must be > 0
|
||||
@field_validator("quantity")
|
||||
@classmethod
|
||||
def _positive_quantity(cls, v: float) -> float:
|
||||
if v is None:
|
||||
return v
|
||||
if isinstance(v, (int, float)) and v <= 0:
|
||||
raise ValueError("Ingredient quantity must be greater than 0")
|
||||
return v
|
||||
|
||||
# Backward compatibility: DB may contain NULL preparation; normalize to empty string
|
||||
@field_validator("preparation", mode="before")
|
||||
@classmethod
|
||||
def _normalize_preparation(cls, v: Any) -> Any:
|
||||
return "" if v is None else v
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ async def create(conn):
|
|||
product_id INTEGER,
|
||||
recipe_id INTEGER,
|
||||
meal_id INTEGER,
|
||||
household_id INTEGER,
|
||||
FOREIGN KEY (product_id) REFERENCES Product(id),
|
||||
FOREIGN KEY (recipe_id) REFERENCES Recipe(id),
|
||||
FOREIGN KEY (meal_id) REFERENCES Meal(id)
|
||||
|
|
@ -37,21 +36,6 @@ async def insert_ingredient(conn, ingredient: Ingredient):
|
|||
if ingredient.product_id is None or ingredient.product_id < 0:
|
||||
ingredient.product_id = None
|
||||
|
||||
# Disallow empty ingredients (no product and no textual content)
|
||||
name = (ingredient.name or "").strip()
|
||||
line = (ingredient.line or "").strip()
|
||||
has_product = ingredient.product_id is not None and ingredient.product_id >= 0
|
||||
if not has_product and name == "" and line == "":
|
||||
raise ValueError("Ingredient must include a name or line or a product")
|
||||
|
||||
# Enforce positive quantity at repository layer as well
|
||||
try:
|
||||
q = float(ingredient.quantity)
|
||||
except Exception:
|
||||
q = ingredient.quantity
|
||||
if isinstance(q, (int, float)) and q <= 0:
|
||||
raise ValueError("Ingredient quantity must be greater than 0")
|
||||
|
||||
async with conn.execute(
|
||||
"""
|
||||
INSERT INTO Ingredient (name, line, preparation, unit, quantity, product_id, recipe_id, meal_id)
|
||||
|
|
|
|||
70
main.py
70
main.py
|
|
@ -10,12 +10,14 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
|||
|
||||
from api import (
|
||||
auth as auth_router,
|
||||
recipes as recipes_router,
|
||||
meals as meals_router,
|
||||
persons as persons_router,
|
||||
products as products_router,
|
||||
recipes as recipes_router,
|
||||
shopping as shopping_router,
|
||||
households as households_router,
|
||||
)
|
||||
from api.deps import (
|
||||
cookie_person as cookie_person, # noqa: F401 - re-exported for tests
|
||||
error_response as error_response, # noqa: F401 - re-exported for completeness
|
||||
get_db as get_db, # noqa: F401 - re-exported for tests dependency overrides
|
||||
)
|
||||
|
|
@ -89,7 +91,31 @@ async def request_validation_exc_handler(request: Request, exc: Exception):
|
|||
for e in exc.errors():
|
||||
loc = ".".join([str(p) for p in e.get("loc", [])])
|
||||
errors.setdefault(loc, []).append(e.get("msg"))
|
||||
# Standard 422 for request validation errors
|
||||
# Special-case: Missing user_id cookie on POST /api/v1/shopping should be 401
|
||||
try:
|
||||
is_shopping_post = request.method.upper() == "POST" and request.url.path == "/api/v1/shopping"
|
||||
except Exception:
|
||||
is_shopping_post = False
|
||||
if is_shopping_post:
|
||||
if any(
|
||||
isinstance(e.get("loc"), (list, tuple))
|
||||
and len(e.get("loc")) >= 2
|
||||
and e.get("loc")[0] == "cookie"
|
||||
and e.get("loc")[1] == "user_id"
|
||||
for e in exc.errors()
|
||||
):
|
||||
body = ProblemDetails(
|
||||
title="Unauthorized",
|
||||
status=401,
|
||||
type="https://httpstatuses.com/401",
|
||||
instance=str(request.url),
|
||||
errors=errors,
|
||||
)
|
||||
return JSONResponse(
|
||||
content=body.model_dump(by_alias=True),
|
||||
status_code=401,
|
||||
media_type="application/problem+json",
|
||||
)
|
||||
|
||||
body = ProblemDetails(
|
||||
title="Validation Error",
|
||||
|
|
@ -127,24 +153,12 @@ def create_app() -> FastAPI:
|
|||
app.add_exception_handler(RequestValidationError, request_validation_exc_handler)
|
||||
|
||||
# Routers
|
||||
# v1 routers removed; v2 household-scoped and JWT-only API below
|
||||
# v2 JWT auth and households
|
||||
app.include_router(auth_router.router, prefix="/api/v1", tags=["auth"]) # canonical
|
||||
app.include_router(households_router.router, prefix="/api/v1", tags=["households"]) # canonical
|
||||
# Mount household-scoped endpoints
|
||||
try:
|
||||
app.include_router(
|
||||
households_router.scoped, prefix="/api/v1", tags=["households"]
|
||||
) # scoped
|
||||
except Exception:
|
||||
pass
|
||||
app.include_router(recipes_router.router, prefix="/api/v1", tags=["recipes"]) # canonical
|
||||
app.include_router(recipes_router.public, prefix="/api/v1", tags=["recipes"]) # public utils
|
||||
from api import ingredients as ingredients_router
|
||||
|
||||
app.include_router(ingredients_router.router, prefix="/api/v1", tags=["ingredients"]) # scoped
|
||||
app.include_router(meals_router.router, prefix="/api/v1", tags=["meals"]) # canonical
|
||||
app.include_router(shopping_router.router, prefix="/api/v1", tags=["shopping"]) # canonical
|
||||
app.include_router(products_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(recipes_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
|
||||
# Routes
|
||||
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)
|
||||
|
|
@ -161,20 +175,10 @@ def create_app() -> FastAPI:
|
|||
from starlette.responses import StreamingResponse
|
||||
|
||||
async def _reverse_proxy(request: StarletteRequest):
|
||||
# Do not proxy API routes; return 404 to let API clients fail fast in dev
|
||||
if str(request.url.path).startswith("/api/"):
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
raise StarletteHTTPException(status_code=404)
|
||||
|
||||
import httpx
|
||||
|
||||
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
|
||||
client = getattr(app.state, "proxy_client", None)
|
||||
if client is None:
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
raise StarletteHTTPException(status_code=503, detail="Proxy not configured")
|
||||
client = app.state.proxy_client
|
||||
rp_req = client.build_request(
|
||||
request.method, url, headers=request.headers.raw, content=request.stream()
|
||||
)
|
||||
|
|
@ -196,4 +200,4 @@ app = create_app()
|
|||
|
||||
DATABASE_PATH = settings.database_path
|
||||
|
||||
# get_db and error_response are imported from api.deps
|
||||
# get_db, cookie_person, and error_response are imported from api.deps
|
||||
|
|
|
|||
|
|
@ -4,11 +4,8 @@ from meals.repository import (
|
|||
create as create,
|
||||
delete_meal as delete_meal,
|
||||
find_meal_by_id as find_meal_by_id,
|
||||
find_meal_by_id_scoped as find_meal_by_id_scoped,
|
||||
find_upcoming_meals_by_date_range as find_upcoming_meals_by_date_range,
|
||||
find_upcoming_meals_by_date_range_scoped as find_upcoming_meals_by_date_range_scoped,
|
||||
insert_meal as insert_meal,
|
||||
insert_meal_scoped as insert_meal_scoped,
|
||||
insert_meal_participant as insert_meal_participant,
|
||||
insert_meal_recipe as insert_meal_recipe,
|
||||
load_extra_ingredients as load_extra_ingredients,
|
||||
|
|
@ -27,3 +24,4 @@ from meals.roles import (
|
|||
ROLE_CONSUMER as ROLE_CONSUMER,
|
||||
)
|
||||
from meals.service import get_duplicates as get_duplicates, validate_meal as validate_meal
|
||||
from persons import Person as Person
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ from __future__ import annotations
|
|||
import datetime
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic import Field
|
||||
|
||||
from common import ApiModel
|
||||
from ingredients import Ingredient
|
||||
from api.dtos import MemberRef
|
||||
from persons.models import Person
|
||||
from recipes import Recipe
|
||||
|
||||
|
||||
|
|
@ -25,39 +25,11 @@ class Meal(ApiModel):
|
|||
suggested_date: datetime.datetime
|
||||
consumed_date: Optional[datetime.datetime] = None
|
||||
|
||||
chefs: List[MemberRef] = Field(default_factory=list)
|
||||
cleanup: List[MemberRef] = Field(default_factory=list)
|
||||
consumers: List[MemberRef] = Field(default_factory=list)
|
||||
chefs: List[Person] = Field(default_factory=list)
|
||||
cleanup: List[Person] = Field(default_factory=list)
|
||||
consumers: List[Person] = Field(default_factory=list)
|
||||
recipes: List[MealRecipe] = Field(default_factory=list)
|
||||
extra_ingredients: List[Ingredient] = Field(default_factory=list)
|
||||
|
||||
# Set from shopping list
|
||||
purchase_date: Optional[datetime.datetime] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _coerce_members(cls, data: dict) -> dict:
|
||||
# Accept legacy Person objects in tests by coercing to MemberRef
|
||||
def to_member_ref(x):
|
||||
# Already a mapping suitable for MemberRef or an instance with id/display_name
|
||||
if isinstance(x, dict):
|
||||
if "id" in x and ("display_name" in x or "displayName" in x or "name" in x):
|
||||
# normalize display_name key
|
||||
if "display_name" not in x:
|
||||
dn = x.get("displayName") or x.get("name")
|
||||
x = {**x, "display_name": dn}
|
||||
return x
|
||||
return x
|
||||
# Object with attributes
|
||||
pid = getattr(x, "id", None)
|
||||
dname = getattr(x, "display_name", None) or getattr(x, "name", None)
|
||||
if isinstance(pid, int) and dname:
|
||||
return {"id": pid, "display_name": dname}
|
||||
return x
|
||||
|
||||
if isinstance(data, dict):
|
||||
for key in ("chefs", "cleanup", "consumers"):
|
||||
val = data.get(key)
|
||||
if isinstance(val, list):
|
||||
data[key] = [to_member_ref(v) for v in val]
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ from ingredients import (
|
|||
insert_ingredient,
|
||||
)
|
||||
from meals.models import Meal, MealRecipe
|
||||
from api.dtos import MemberRef
|
||||
from persons.models import Person
|
||||
from persons.repository import get_by_ids as persons_get_by_ids
|
||||
from recipes.models import Recipe
|
||||
from recipes.repository import load_recipe_ingredients, row_to_recipe
|
||||
|
||||
|
|
@ -23,8 +24,7 @@ async def create(conn):
|
|||
suggested_date DATETIME,
|
||||
consumed_date DATETIME DEFAULT NULL,
|
||||
deleted_date DATETIME DEFAULT NULL,
|
||||
purchase_date DATETIME DEFAULT NULL,
|
||||
household_id INTEGER
|
||||
purchase_date DATETIME DEFAULT NULL
|
||||
);"""
|
||||
)
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ async def create(conn):
|
|||
person_id INTEGER,
|
||||
role TEXT,
|
||||
FOREIGN KEY(meal_id) REFERENCES Meal(id),
|
||||
FOREIGN KEY(person_id) REFERENCES User(id)
|
||||
FOREIGN KEY(person_id) REFERENCES Person(id)
|
||||
);"""
|
||||
)
|
||||
# Useful indexes
|
||||
|
|
@ -58,10 +58,6 @@ async def create(conn):
|
|||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_meal_recipes_meal_id ON MealRecipe(meal_id);"
|
||||
)
|
||||
# Composite index for household-scoped pagination/lookups
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_meal_household_id_id ON Meal(household_id, id);"
|
||||
)
|
||||
|
||||
|
||||
async def insert_meal_participant(conn, meal_id: int, person_id: int, role: str):
|
||||
|
|
@ -74,7 +70,7 @@ async def insert_meal_participant(conn, meal_id: int, person_id: int, role: str)
|
|||
)
|
||||
|
||||
|
||||
async def sync_meal_participants(conn, meal_id: int, participants: List[MemberRef], role: str):
|
||||
async def sync_meal_participants(conn, meal_id: int, participants: List[Person], role: str):
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM MealParticipant
|
||||
|
|
@ -128,28 +124,6 @@ async def insert_meal(conn, meal: Meal):
|
|||
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients)
|
||||
|
||||
|
||||
async def insert_meal_scoped(conn, meal: Meal, household_id: int):
|
||||
async with conn.execute(
|
||||
"""
|
||||
INSERT INTO Meal (suggested_date, household_id)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(meal.suggested_date.isoformat(), household_id),
|
||||
) as cursor:
|
||||
meal.id = cursor.lastrowid
|
||||
|
||||
# Reuse existing syncs (they operate by meal_id)
|
||||
await sync_meal_participants(conn, meal.id, meal.chefs, ROLE_CHEF)
|
||||
await sync_meal_participants(conn, meal.id, meal.cleanup, ROLE_CLEANUP)
|
||||
await sync_meal_participants(conn, meal.id, meal.consumers, ROLE_CONSUMER)
|
||||
|
||||
for meal_recipe in meal.recipes:
|
||||
meal_recipe.meal_id = meal.id
|
||||
await insert_meal_recipe(conn, meal_recipe)
|
||||
|
||||
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients)
|
||||
|
||||
|
||||
async def find_meal_by_id(conn, meal_id: int) -> Optional[Meal]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
|
|
@ -169,25 +143,6 @@ async def find_meal_by_id(conn, meal_id: int) -> Optional[Meal]:
|
|||
return None
|
||||
|
||||
|
||||
async def find_meal_by_id_scoped(conn, meal_id: int, household_id: int) -> Optional[Meal]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {",".join(Meal.KEYS)} FROM Meal
|
||||
WHERE id = ? AND household_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(meal_id, household_id),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
meal = Meal(**{k: v for k, v in zip(Meal.KEYS, row)})
|
||||
|
||||
await load_participants(conn, meal)
|
||||
await load_recipes(conn, meal)
|
||||
await load_extra_ingredients(conn, meal)
|
||||
return meal
|
||||
return None
|
||||
|
||||
|
||||
async def find_upcoming_meals_by_date_range(
|
||||
conn, start: datetime.datetime, end: datetime.datetime
|
||||
) -> AsyncIterator[Meal]:
|
||||
|
|
@ -202,20 +157,6 @@ async def find_upcoming_meals_by_date_range(
|
|||
yield Meal(**{k: v for k, v in zip(Meal.KEYS, row)})
|
||||
|
||||
|
||||
async def find_upcoming_meals_by_date_range_scoped(
|
||||
conn, start: datetime.datetime, end: datetime.datetime, household_id: int
|
||||
) -> AsyncIterator[Meal]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {",".join(Meal.KEYS)} FROM Meal
|
||||
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL AND household_id = ?
|
||||
""",
|
||||
(start, end, household_id),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Meal(**{k: v for k, v in zip(Meal.KEYS, row)})
|
||||
|
||||
|
||||
async def load_participants(conn, meal: Meal) -> None:
|
||||
# Fetch all participant links
|
||||
links: list[tuple[int, str]] = []
|
||||
|
|
@ -232,50 +173,21 @@ async def load_participants(conn, meal: Meal) -> None:
|
|||
if not links:
|
||||
return
|
||||
|
||||
# Bulk load users by id and build objects with both display_name and name (compat)
|
||||
# Bulk load persons by id
|
||||
unique_ids = sorted({pid for pid, _ in links})
|
||||
people: dict[int, object] = {}
|
||||
if unique_ids:
|
||||
placeholders = ",".join(["?"] * len(unique_ids))
|
||||
async with conn.execute(
|
||||
f"SELECT id, display_name FROM User WHERE id IN ({placeholders})",
|
||||
unique_ids,
|
||||
) as c:
|
||||
async for row in c:
|
||||
uid, dname = int(row[0]), str(row[1])
|
||||
obj = type("_M", (), {})()
|
||||
setattr(obj, "id", uid)
|
||||
setattr(obj, "display_name", dname)
|
||||
setattr(obj, "name", dname)
|
||||
people[uid] = obj
|
||||
# No legacy Person fallback: tests should seed User rows via fixtures
|
||||
people = await persons_get_by_ids(conn, unique_ids)
|
||||
|
||||
for pid, role in links:
|
||||
person = people.get(pid)
|
||||
if role == ROLE_CHEF:
|
||||
if person:
|
||||
meal.chefs.append(
|
||||
MemberRef(
|
||||
id=int(getattr(person, "id")),
|
||||
display_name=str(getattr(person, "display_name")),
|
||||
)
|
||||
)
|
||||
meal.chefs.append(person)
|
||||
elif role == ROLE_CLEANUP:
|
||||
if person:
|
||||
meal.cleanup.append(
|
||||
MemberRef(
|
||||
id=int(getattr(person, "id")),
|
||||
display_name=str(getattr(person, "display_name")),
|
||||
)
|
||||
)
|
||||
meal.cleanup.append(person)
|
||||
elif role == ROLE_CONSUMER:
|
||||
if person:
|
||||
meal.consumers.append(
|
||||
MemberRef(
|
||||
id=int(getattr(person, "id")),
|
||||
display_name=str(getattr(person, "display_name")),
|
||||
)
|
||||
)
|
||||
meal.consumers.append(person)
|
||||
else:
|
||||
raise Exception(f"Unknown role: {role}")
|
||||
|
||||
|
|
@ -284,7 +196,7 @@ async def bulk_load_participants(conn, meals: List[Meal]) -> None:
|
|||
"""Populate participants for many meals in one query to avoid N+1.
|
||||
|
||||
For each meal, fills meal.chefs, meal.cleanup, meal.consumers using a bulk
|
||||
lookup of MealParticipant rows and a single user lookup.
|
||||
lookup of MealParticipant rows and a single persons.get_by_ids fetch.
|
||||
"""
|
||||
if not meals:
|
||||
return
|
||||
|
|
@ -311,23 +223,8 @@ async def bulk_load_participants(conn, meals: List[Meal]) -> None:
|
|||
if not person_ids:
|
||||
return
|
||||
|
||||
# Bulk load users once and build objects with both display_name and name
|
||||
people: dict[int, object] = {}
|
||||
unique_ids = sorted(person_ids)
|
||||
if unique_ids:
|
||||
placeholders = ",".join(["?"] * len(unique_ids))
|
||||
async with conn.execute(
|
||||
f"SELECT id, display_name FROM User WHERE id IN ({placeholders})",
|
||||
unique_ids,
|
||||
) as c:
|
||||
async for row in c:
|
||||
uid, dname = int(row[0]), str(row[1])
|
||||
obj = type("_M", (), {})()
|
||||
setattr(obj, "id", uid)
|
||||
setattr(obj, "display_name", dname)
|
||||
setattr(obj, "name", dname)
|
||||
people[uid] = obj
|
||||
# No legacy Person fallback: tests should seed User rows via fixtures
|
||||
# Bulk load persons once
|
||||
people = await persons_get_by_ids(conn, sorted(person_ids))
|
||||
|
||||
# Assign per meal
|
||||
by_id = {m.id: m for m in meals}
|
||||
|
|
@ -344,26 +241,11 @@ async def bulk_load_participants(conn, meals: List[Meal]) -> None:
|
|||
if not person:
|
||||
continue
|
||||
if role == ROLE_CHEF:
|
||||
meal.chefs.append(
|
||||
MemberRef(
|
||||
id=int(getattr(person, "id")),
|
||||
display_name=str(getattr(person, "display_name")),
|
||||
)
|
||||
)
|
||||
meal.chefs.append(person)
|
||||
elif role == ROLE_CLEANUP:
|
||||
meal.cleanup.append(
|
||||
MemberRef(
|
||||
id=int(getattr(person, "id")),
|
||||
display_name=str(getattr(person, "display_name")),
|
||||
)
|
||||
)
|
||||
meal.cleanup.append(person)
|
||||
elif role == ROLE_CONSUMER:
|
||||
meal.consumers.append(
|
||||
MemberRef(
|
||||
id=int(getattr(person, "id")),
|
||||
display_name=str(getattr(person, "display_name")),
|
||||
)
|
||||
)
|
||||
meal.consumers.append(person)
|
||||
else:
|
||||
raise Exception(f"Unknown role: {role}")
|
||||
|
||||
|
|
|
|||
|
|
@ -3,20 +3,16 @@ from __future__ import annotations
|
|||
from typing import List, Set
|
||||
|
||||
from meals.models import Meal
|
||||
from api.dtos import MemberRef
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
def get_duplicates(items: List[MemberRef]) -> Set[str]:
|
||||
def get_duplicates(items: List[Person]) -> Set[str]:
|
||||
"""Return the set of duplicate person names based on repeated ids."""
|
||||
seen: set[int] = set()
|
||||
duplicates: set[str] = set()
|
||||
for item in items:
|
||||
if item.id in seen:
|
||||
# prefer display_name; fall back to best-effort repr
|
||||
name = (
|
||||
getattr(item, "display_name", None) or getattr(item, "name", None) or str(item.id)
|
||||
)
|
||||
duplicates.add(name)
|
||||
duplicates.add(item.name)
|
||||
seen.add(item.id)
|
||||
return duplicates
|
||||
|
||||
|
|
|
|||
2211
openapi.json
2211
openapi.json
File diff suppressed because it is too large
Load diff
15
persons/__init__.py
Normal file
15
persons/__init__.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from persons.models import Person as Person
|
||||
from persons.repository import (
|
||||
compute_prev_cursor as compute_prev_cursor,
|
||||
count_all as count_all,
|
||||
count_by_name as count_by_name,
|
||||
create as create,
|
||||
get_all as get_all,
|
||||
get_all_paged as get_all_paged,
|
||||
get_by_id as get_by_id,
|
||||
get_by_ids as get_by_ids,
|
||||
get_by_name as get_by_name,
|
||||
insert_person as insert_person,
|
||||
search_by_name as search_by_name,
|
||||
search_by_name_paged as search_by_name_paged,
|
||||
)
|
||||
10
persons/models.py
Normal file
10
persons/models.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from typing import ClassVar, List
|
||||
|
||||
from common import ApiModel
|
||||
|
||||
|
||||
class Person(ApiModel):
|
||||
KEYS: ClassVar[List[str]] = ["id", "name"]
|
||||
|
||||
id: int = -1
|
||||
name: str
|
||||
196
persons/repository.py
Normal file
196
persons/repository.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
from typing import AsyncIterator, List, Optional
|
||||
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Person (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE
|
||||
);"""
|
||||
)
|
||||
# Useful indexes for search and pagination
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_person_name ON Person(name);")
|
||||
|
||||
|
||||
async def search_by_name(conn, name: str) -> AsyncIterator[Person]:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name LIKE ?
|
||||
""",
|
||||
(f"%{name}%",),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_by_name(conn, name: str) -> Optional[Person]:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name = ?
|
||||
""",
|
||||
(name,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_by_id(conn, id: int) -> Optional[Person]:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE id = ?
|
||||
""",
|
||||
(id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_by_ids(conn, ids: List[int]) -> dict[int, Person]:
|
||||
"""Fetch many persons in a single query. Returns a dict id->Person.
|
||||
|
||||
If ids is empty, returns {}.
|
||||
"""
|
||||
if not ids:
|
||||
return {}
|
||||
placeholders = ",".join(["?"] * len(ids))
|
||||
query = f"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE id IN ({placeholders})
|
||||
"""
|
||||
result: dict[int, Person] = {}
|
||||
async with conn.execute(query, ids) as cursor:
|
||||
async for row in cursor:
|
||||
p = Person(id=row[0], name=row[1])
|
||||
result[p.id] = p
|
||||
return result
|
||||
|
||||
|
||||
async def get_all(conn) -> AsyncIterator[Person]:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
"""
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_all_paged(conn, after_id: Optional[int], limit: int) -> AsyncIterator[Person]:
|
||||
after = after_id if after_id is not None else -1
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""",
|
||||
(after, limit),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def search_by_name_paged(
|
||||
conn, name: str, after_id: Optional[int], limit: int
|
||||
) -> AsyncIterator[Person]:
|
||||
after = after_id if after_id is not None else -1
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name LIKE ? AND id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""",
|
||||
(f"%{name}%", after, limit),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def count_all(conn) -> int:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM Person
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def count_by_name(conn, name: str) -> int:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM Person
|
||||
WHERE name LIKE ?
|
||||
""",
|
||||
(f"%{name}%",),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def compute_prev_cursor(
|
||||
conn, first_id: int, limit: int, name: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Compute a prevCursor string for paginated persons, respecting optional name LIKE filter."""
|
||||
if limit <= 0:
|
||||
return None
|
||||
if name:
|
||||
query = """
|
||||
SELECT id
|
||||
FROM Person
|
||||
WHERE name LIKE ? AND id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
|
||||
else:
|
||||
query = """
|
||||
SELECT id
|
||||
FROM Person
|
||||
WHERE id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
params = (first_id, limit)
|
||||
|
||||
async with conn.execute(query, params) as c:
|
||||
prev_ids = [row[0] async for row in c]
|
||||
if len(prev_ids) == limit and prev_ids:
|
||||
return str(min(prev_ids) - 1)
|
||||
return None
|
||||
|
||||
|
||||
async def insert_person(conn, person: Person) -> Person:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
INSERT INTO Person (name)
|
||||
VALUES (?)
|
||||
""",
|
||||
(person.name,),
|
||||
)
|
||||
person.id = cursor.lastrowid
|
||||
return person
|
||||
|
|
@ -2,58 +2,32 @@ import re
|
|||
from typing import Optional
|
||||
|
||||
from ingredients import match_existing_products, parse_ingredient_from_nlp
|
||||
from api.dtos import MemberRef
|
||||
from persons.models import Person
|
||||
from recipes.models import Recipe as Recipe
|
||||
from recipes.repository import (
|
||||
compute_prev_cursor as compute_prev_cursor,
|
||||
count_all as count_all,
|
||||
count_by_name as count_by_name,
|
||||
count_all_scoped as count_all_scoped,
|
||||
count_by_name_scoped as count_by_name_scoped,
|
||||
find_recipe_by_id as find_recipe_by_id,
|
||||
find_recipe_by_id_scoped as find_recipe_by_id_scoped,
|
||||
find_recipes_by_name as find_recipes_by_name,
|
||||
find_recipes_by_name_paged as find_recipes_by_name_paged,
|
||||
find_recipes_by_name_paged_scoped as find_recipes_by_name_paged_scoped,
|
||||
get_all as get_all,
|
||||
get_all_paged as get_all_paged,
|
||||
get_all_paged_scoped as get_all_paged_scoped,
|
||||
hide_recipe as hide_recipe,
|
||||
insert_recipe as insert_recipe,
|
||||
insert_recipe_scoped as insert_recipe_scoped,
|
||||
load_recipe_ingredients as load_recipe_ingredients,
|
||||
row_to_recipe as row_to_recipe,
|
||||
)
|
||||
from recipes.scraping import (
|
||||
scrape_recipe_ldata as _scrape_recipe_ldata,
|
||||
scrape_recipe_ldata_from_html as _scrape_recipe_ldata_from_html,
|
||||
)
|
||||
from recipes.scraping import scrape_recipe_ldata as _scrape_recipe_ldata
|
||||
|
||||
|
||||
async def parse_recipe(conn, created_by, url: str, log=None, dump_dir: str | None = None) -> Optional[Recipe]:
|
||||
"""Parse a recipe from a URL. Returns None if parsing fails.
|
||||
|
||||
Accepts an optional log callable taking a single string argument; when provided,
|
||||
the scraper will emit diagnostic messages useful for manual testing.
|
||||
"""
|
||||
ldata = await _scrape_recipe_ldata(url, log=log, dump_dir=dump_dir)
|
||||
|
||||
async def parse_recipe(conn, created_by: Person, url: str) -> Optional[Recipe]:
|
||||
ldata = await _scrape_recipe_ldata(url)
|
||||
if ldata:
|
||||
return await _get_recipe_from_ldata(conn, url, ldata, created_by)
|
||||
return None
|
||||
|
||||
|
||||
async def parse_recipe_from_html(conn, created_by, base_url: str, html: str, log=None) -> Optional[Recipe]:
|
||||
"""Parse a recipe from raw HTML (offline). Returns None if parsing fails.
|
||||
|
||||
This mirrors parse_recipe() but uses already-downloaded HTML via the offline
|
||||
scraper entrypoint. Useful for tests/assertions against saved snapshots.
|
||||
"""
|
||||
ldata = _scrape_recipe_ldata_from_html(html, base_url, log=log)
|
||||
if ldata:
|
||||
return await _get_recipe_from_ldata(conn, base_url, ldata, created_by)
|
||||
return None
|
||||
|
||||
|
||||
def find_yield(recipe_ldata: dict) -> int:
|
||||
if "recipeYield" in recipe_ldata:
|
||||
yield_vals = recipe_ldata["recipeYield"]
|
||||
|
|
@ -74,7 +48,7 @@ def find_yield(recipe_ldata: dict) -> int:
|
|||
return 4
|
||||
|
||||
|
||||
async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by) -> Recipe:
|
||||
async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person) -> Recipe:
|
||||
ingredients = [
|
||||
parse_ingredient_from_nlp(ingredient) for ingredient in ldata["recipeIngredient"]
|
||||
]
|
||||
|
|
@ -92,12 +66,6 @@ async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by) -> Rec
|
|||
if isinstance(images, str):
|
||||
images = [images]
|
||||
|
||||
# Ensure created_by is a MemberRef (not a full User) to satisfy model typing
|
||||
mref = (
|
||||
MemberRef(id=created_by.id, display_name=created_by.display_name)
|
||||
if created_by is not None
|
||||
else None
|
||||
)
|
||||
return Recipe(
|
||||
id=-1,
|
||||
name=name,
|
||||
|
|
@ -105,6 +73,6 @@ async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by) -> Rec
|
|||
serves=serves,
|
||||
image_urls=images,
|
||||
ingredients=ingredients,
|
||||
created_by=mref,
|
||||
created_by_id=created_by.id if created_by is not None else -1,
|
||||
created_by=created_by,
|
||||
created_by_id=created_by.id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from pydantic import Field
|
|||
|
||||
from common import ApiModel
|
||||
from ingredients import Ingredient
|
||||
from api.dtos import MemberRef
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
class Recipe(ApiModel):
|
||||
|
|
@ -37,9 +37,8 @@ class Recipe(ApiModel):
|
|||
default_factory=lambda: datetime.datetime.now().astimezone()
|
||||
)
|
||||
created_by_id: int
|
||||
# Domain keeps id; optional outward mapping can attach a MemberRef
|
||||
created_by: Optional[MemberRef] = None
|
||||
created_by: Optional[Person] = None
|
||||
|
||||
date_hidden: Optional[datetime.datetime] = None
|
||||
hidden_by_id: Optional[int] = None
|
||||
hidden_by: Optional[MemberRef] = None
|
||||
hidden_by: Optional[Person] = None
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import json
|
|||
from typing import Any, AsyncIterator, Iterable, List, Optional, Tuple, cast
|
||||
|
||||
from ingredients import find_ingredients_by_recipe_id
|
||||
from persons.models import Person
|
||||
from recipes.models import Recipe
|
||||
|
||||
|
||||
|
|
@ -22,11 +23,10 @@ async def create(conn):
|
|||
|
||||
date_hidden DATETIME DEFAULT NULL,
|
||||
hidden_by_id INTEGER DEFAULT NULL,
|
||||
household_id INTEGER,
|
||||
|
||||
FOREIGN KEY (based_on_recipe) REFERENCES Recipe(id)
|
||||
FOREIGN KEY (created_by_id) REFERENCES User(id)
|
||||
FOREIGN KEY (hidden_by_id) REFERENCES User(id)
|
||||
FOREIGN KEY (created_by_id) REFERENCES Person(id)
|
||||
FOREIGN KEY (hidden_by_id) REFERENCES Person(id)
|
||||
);"""
|
||||
)
|
||||
# Useful indexes for filtering/pagination
|
||||
|
|
@ -36,10 +36,6 @@ async def create(conn):
|
|||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_recipe_name_hidden_id ON Recipe(name, date_hidden, id);"
|
||||
)
|
||||
# Composite index for fast household-scoped pagination
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_recipe_household_id_id ON Recipe(household_id, id);"
|
||||
)
|
||||
|
||||
|
||||
def _as_insert_field(recipe: Recipe, name: str):
|
||||
|
|
@ -65,51 +61,15 @@ async def insert_recipe(conn, recipe: Recipe):
|
|||
recipe.id = cursor.lastrowid
|
||||
|
||||
|
||||
# V2 scoped helpers (preserve v1 signatures)
|
||||
async def insert_recipe_scoped(conn, recipe: Recipe, household_id: int):
|
||||
fields_to_insert = [k for k in Recipe.KEYS if k not in Recipe.NON_INSERT_KEYS]
|
||||
actual_values = [_as_insert_field(recipe, k) for k in fields_to_insert]
|
||||
|
||||
insert_stmt = f"""
|
||||
INSERT INTO Recipe ({",".join(fields_to_insert)}, household_id)
|
||||
VALUES ({",".join(["?"] * len(fields_to_insert))}, ?)
|
||||
"""
|
||||
async with conn.execute(insert_stmt, (*actual_values, household_id)) as cursor:
|
||||
recipe.id = cursor.lastrowid
|
||||
|
||||
|
||||
async def hide_recipe_scoped(conn, recipe_id: int, household_id: int) -> bool:
|
||||
"""Soft-delete a recipe by household for v2.
|
||||
|
||||
Returns True if updated, False if not found or not in household.
|
||||
"""
|
||||
async with conn.execute(
|
||||
"""
|
||||
UPDATE Recipe
|
||||
SET date_hidden = ?
|
||||
WHERE id = ? AND household_id = ?
|
||||
""",
|
||||
(datetime.datetime.now().astimezone().isoformat(), recipe_id, household_id),
|
||||
) as cur:
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
async def hide_recipe_scoped_with_actor(
|
||||
conn, recipe_id: int, household_id: int, user_id: int
|
||||
) -> bool:
|
||||
"""Soft-delete a recipe within a household and record the hiding user.
|
||||
|
||||
Returns True if updated, False if not found or out-of-scope.
|
||||
"""
|
||||
async with conn.execute(
|
||||
async def hide_recipe(conn, recipe_id: int, person: Person):
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE Recipe
|
||||
SET date_hidden = ?, hidden_by_id = ?
|
||||
WHERE id = ? AND household_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(datetime.datetime.now().astimezone().isoformat(), user_id, recipe_id, household_id),
|
||||
) as cur:
|
||||
return cur.rowcount > 0
|
||||
(datetime.datetime.now().astimezone().isoformat(), person.id, recipe_id),
|
||||
)
|
||||
|
||||
|
||||
def row_to_recipe(col_tuples: Iterable[Tuple[str, object]]) -> Recipe:
|
||||
|
|
@ -135,20 +95,6 @@ async def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]:
|
|||
return None
|
||||
|
||||
|
||||
async def find_recipe_by_id_scoped(conn, recipe_id: int, household_id: int) -> Optional[Recipe]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {",".join(Recipe.KEYS)} FROM Recipe
|
||||
WHERE id = ? AND household_id = ? AND date_hidden IS NULL
|
||||
LIMIT 1
|
||||
""",
|
||||
(recipe_id, household_id),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
return row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||
return None
|
||||
|
||||
|
||||
async def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
|
|
@ -171,24 +117,6 @@ async def get_all(conn) -> AsyncIterator[Recipe]:
|
|||
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||
|
||||
|
||||
async def get_all_paged_scoped(
|
||||
conn, after_id: Optional[int], limit: int, household_id: int
|
||||
) -> AsyncIterator[Recipe]:
|
||||
after = after_id if after_id is not None else -1
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {",".join(Recipe.KEYS)}
|
||||
FROM Recipe
|
||||
WHERE date_hidden IS NULL AND id > ? AND household_id = ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""",
|
||||
(after, household_id, limit),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||
|
||||
|
||||
async def load_recipe_ingredients(conn, recipe: Recipe) -> None:
|
||||
async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id):
|
||||
recipe.ingredients.append(ingredient)
|
||||
|
|
@ -229,24 +157,6 @@ async def find_recipes_by_name_paged(
|
|||
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||
|
||||
|
||||
async def find_recipes_by_name_paged_scoped(
|
||||
conn, name: str, after_id: Optional[int], limit: int, household_id: int
|
||||
) -> AsyncIterator[Recipe]:
|
||||
after = after_id if after_id is not None else -1
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {",".join(Recipe.KEYS)}
|
||||
FROM Recipe
|
||||
WHERE name LIKE ? AND date_hidden IS NULL AND id > ? AND household_id = ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""",
|
||||
(f"%{name}%", after, household_id, limit),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||
|
||||
|
||||
async def compute_prev_cursor(
|
||||
conn, first_id: int, limit: int, name: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
|
|
@ -299,19 +209,6 @@ async def count_all(conn) -> int:
|
|||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def count_all_scoped(conn, household_id: int) -> int:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM Recipe
|
||||
WHERE date_hidden IS NULL AND household_id = ?
|
||||
""",
|
||||
(household_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def count_by_name(conn, name: str) -> int:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
|
|
@ -323,16 +220,3 @@ async def count_by_name(conn, name: str) -> int:
|
|||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def count_by_name_scoped(conn, name: str, household_id: int) -> int:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM Recipe
|
||||
WHERE name LIKE ? AND date_hidden IS NULL AND household_id = ?
|
||||
""",
|
||||
(f"%{name}%", household_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
|
|
|||
|
|
@ -1,759 +1,91 @@
|
|||
import json
|
||||
from typing import Optional, Iterable, List, Tuple, Dict
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
import html as _html
|
||||
|
||||
# POLICY: Strict ld+json-only recipe extraction
|
||||
# ---------------------------------------------
|
||||
# This scraper MUST NOT perform complex HTML-based ingredient parsing.
|
||||
# Only two things are allowed when parsing HTML:
|
||||
# 1) Discover additional, more-friendly variants of the same page (e.g., amp/print)
|
||||
# 2) Locate and parse <script type="application/ld+json"> blocks that contain a Recipe
|
||||
# Do NOT extract from application/json, __NEXT_DATA__, plugin markup, microdata, or generic
|
||||
# DOM heuristics. This keeps behavior predictable and aligned with sites' structured data.
|
||||
|
||||
# Determine brotli support to avoid unreadable responses when the runtime lacks a decoder.
|
||||
_HAS_BROTLI = False
|
||||
try: # brotli or brotlicffi
|
||||
import brotli as _brotli # type: ignore
|
||||
_HAS_BROTLI = True
|
||||
except Exception:
|
||||
try:
|
||||
import brotlicffi as _brotlicffi # type: ignore
|
||||
_HAS_BROTLI = True
|
||||
except Exception:
|
||||
_HAS_BROTLI = False
|
||||
|
||||
# Base headers shared across profiles; specific profiles add UA and optional fetch headers.
|
||||
DEFAULT_HEADERS = {
|
||||
"Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.8",
|
||||
# Only advertise br when we can decode it, otherwise prefer gzip/deflate for reliability
|
||||
"Accept-Encoding": "gzip, deflate, br" if _HAS_BROTLI else "gzip, deflate",
|
||||
"Connection": "keep-alive",
|
||||
}
|
||||
|
||||
# Header profiles: start with an automation-forward identity, then fall back to browser-like
|
||||
HEADER_PROFILES = [
|
||||
{
|
||||
"name": "automation",
|
||||
"headers": {
|
||||
**DEFAULT_HEADERS,
|
||||
"User-Agent": "MunchEaseRecipeBot/1.0 (+https://example.com/bot)",
|
||||
"From": "bot@example.com",
|
||||
},
|
||||
"add_referer": False,
|
||||
},
|
||||
{
|
||||
"name": "chrome-desktop",
|
||||
"headers": {
|
||||
**DEFAULT_HEADERS,
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
# Client hints
|
||||
"sec-ch-ua": '"Chromium";v="127", "Not=A?Brand";v="24", "Google Chrome";v="127"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Linux"',
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/127.0.0.0 Safari/537.36"
|
||||
),
|
||||
},
|
||||
"add_referer": True,
|
||||
},
|
||||
{
|
||||
"name": "chrome-mobile",
|
||||
"headers": {
|
||||
**DEFAULT_HEADERS,
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"sec-ch-ua": '"Chromium";v="127", "Not=A?Brand";v="24", "Google Chrome";v="127"',
|
||||
"sec-ch-ua-mobile": "?1",
|
||||
"sec-ch-ua-platform": '"Android"',
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Linux; Android 12; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/127.0.0.0 Mobile Safari/537.36"
|
||||
),
|
||||
},
|
||||
"add_referer": True,
|
||||
},
|
||||
{
|
||||
"name": "chrome-full",
|
||||
"headers": {
|
||||
**DEFAULT_HEADERS,
|
||||
HEADERS = {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"sec-ch-ua": '"Chromium";v="127", "Google Chrome";v="127", ";Not A Brand";v="99"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Linux"',
|
||||
"sec-ch-ua-platform-version": '"6.8.0"',
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"DNT": "1",
|
||||
"Priority": "u=0, i",
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/127.0.0.0 Safari/537.36"
|
||||
),
|
||||
},
|
||||
"add_referer": True,
|
||||
},
|
||||
{
|
||||
"name": "safari-mac",
|
||||
"headers": {
|
||||
**DEFAULT_HEADERS,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
||||
),
|
||||
},
|
||||
"add_referer": True,
|
||||
},
|
||||
{
|
||||
"name": "firefox-desktop",
|
||||
"headers": {
|
||||
**DEFAULT_HEADERS,
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
|
||||
),
|
||||
},
|
||||
"add_referer": True,
|
||||
},
|
||||
{
|
||||
"name": "safari-ios",
|
||||
"headers": {
|
||||
**DEFAULT_HEADERS,
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
||||
),
|
||||
},
|
||||
"add_referer": True,
|
||||
},
|
||||
{
|
||||
"name": "edge-desktop",
|
||||
"headers": {
|
||||
**DEFAULT_HEADERS,
|
||||
"Sec-GPC": "1",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"sec-ch-ua": '"Chromium";v="127", "Not=A?Brand";v="24", "Microsoft Edge";v="127"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Linux"',
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0"
|
||||
),
|
||||
},
|
||||
"add_referer": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def looks_like_jsonld(html: str) -> bool:
|
||||
"""Lightweight check for presence of JSON-LD Recipe data in an HTML string.
|
||||
|
||||
We only check for ld+json tags or @context+schema.org signatures.
|
||||
This is an optimization hint and does not parse JSON.
|
||||
"""
|
||||
if not html:
|
||||
return False
|
||||
low = html.lower()
|
||||
if "application/ld+json" in low:
|
||||
return True
|
||||
if "@context" in low and "schema.org" in low:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _force_safe_accept_encoding(headers: Dict[str, str]) -> Dict[str, str]:
|
||||
"""Return a copy of headers where Accept-Encoding excludes brotli.
|
||||
|
||||
Use this in contexts where brotli support may be missing to avoid unreadable payloads.
|
||||
"""
|
||||
h = dict(headers)
|
||||
h["Accept-Encoding"] = "gzip, deflate"
|
||||
return h
|
||||
|
||||
|
||||
async def fetch_first_2xx_html(
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
log=None,
|
||||
safe_accept_encoding: bool = True,
|
||||
) -> Tuple[Optional[str], Dict[str, Optional[str]]]:
|
||||
"""Try header profiles against a URL and return the first 2xx HTML and metadata.
|
||||
|
||||
Returns (html, meta) where meta includes:
|
||||
- status, final_url, content_type, content_encoding
|
||||
- profile (name), request_headers (effective request headers)
|
||||
If no profile returns 2xx, returns (None, meta_with_last_error_or_status).
|
||||
"""
|
||||
def _log(msg: str) -> None:
|
||||
if log:
|
||||
try:
|
||||
log(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}/"
|
||||
last_meta: Dict[str, Optional[str]] = {
|
||||
"status": None,
|
||||
"final_url": None,
|
||||
"content_type": None,
|
||||
"content_encoding": None,
|
||||
"profile": None,
|
||||
"error": None,
|
||||
"request_headers": None,
|
||||
"Priority": "u=1",
|
||||
"Pragma": "no-cache",
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
last_html: Optional[str] = None
|
||||
for prof in HEADER_PROFILES:
|
||||
headers = dict(prof["headers"]) # copy
|
||||
if prof.get("add_referer"):
|
||||
headers.setdefault("Referer", origin)
|
||||
if safe_accept_encoding:
|
||||
headers = _force_safe_accept_encoding(headers)
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, follow_redirects=True)
|
||||
except Exception as e:
|
||||
last_meta.update({"error": f"{type(e).__name__}: {e}", "profile": prof["name"]})
|
||||
_log(f"GET[{prof['name']}] {url} -> EXC {type(e).__name__}: {e}")
|
||||
continue
|
||||
_log(f"GET[{prof['name']}] {url} -> {resp.status_code}")
|
||||
meta = {
|
||||
"status": str(resp.status_code),
|
||||
"final_url": str(resp.url),
|
||||
"content_type": resp.headers.get("content-type"),
|
||||
"content_encoding": resp.headers.get("content-encoding"),
|
||||
"profile": prof["name"],
|
||||
"error": None,
|
||||
"request_headers": json.dumps(headers),
|
||||
}
|
||||
last_meta = meta
|
||||
if 200 <= resp.status_code < 300:
|
||||
html = resp.text
|
||||
last_html = html
|
||||
# Prefer early return if JSON-LD signature seems present
|
||||
if looks_like_jsonld(html):
|
||||
return html, meta
|
||||
# else keep searching other profiles for a more suitable variant
|
||||
continue
|
||||
return last_html, last_meta
|
||||
|
||||
|
||||
def _is_recipe_ldata(ldata_node) -> bool:
|
||||
"""Return True when a JSON-LD node represents a Recipe.
|
||||
|
||||
Handles cases where @type is a string or a list (order-insensitive).
|
||||
"""
|
||||
if "@type" not in ldata_node:
|
||||
return False
|
||||
if "@type" in ldata_node:
|
||||
typ = ldata_node["@type"]
|
||||
if isinstance(typ, str):
|
||||
return typ.lower() == "recipe"
|
||||
if isinstance(typ, list):
|
||||
for t in typ:
|
||||
if isinstance(t, str) and t.lower() == "recipe":
|
||||
typ = typ[0]
|
||||
|
||||
if isinstance(typ, str) and typ.lower() == "recipe":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _fallback_urls(url: str) -> Iterable[str]:
|
||||
"""Generate fallback URLs to try if the primary request is blocked.
|
||||
|
||||
Strategy:
|
||||
- original URL
|
||||
- add `?output=amp` if no existing query
|
||||
- add `&output=amp` if query exists
|
||||
- try `/amp` path suffix if not already present
|
||||
- try `?amp=1` and bare `?amp` which some sites honor as AMP toggles
|
||||
"""
|
||||
yield url
|
||||
try:
|
||||
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
|
||||
|
||||
parsed = urlparse(url)
|
||||
q = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
if q.get("output") != "amp":
|
||||
q["output"] = "amp"
|
||||
amp_url = urlunparse(parsed._replace(query=urlencode(q, doseq=True)))
|
||||
if amp_url != url:
|
||||
yield amp_url
|
||||
|
||||
# Try a path-based AMP fallback
|
||||
if not parsed.path.endswith("/amp"):
|
||||
amp_path = parsed.path.rstrip("/") + "/amp"
|
||||
amp2 = urlunparse(parsed._replace(path=amp_path))
|
||||
if amp2 != url:
|
||||
yield amp2
|
||||
# Additional common AMP toggles
|
||||
q_amp = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
if q_amp.get("amp") != "1":
|
||||
q_amp["amp"] = "1"
|
||||
amp1 = urlunparse(parsed._replace(query=urlencode(q_amp, doseq=True)))
|
||||
if amp1 != url:
|
||||
yield amp1
|
||||
if "amp" not in (q_amp or {}):
|
||||
bare = urlunparse(parsed._replace(query=(parsed.query + ("&" if parsed.query else "") + "amp")))
|
||||
if bare != url:
|
||||
yield bare
|
||||
|
||||
# HTTP scheme fallbacks when original is HTTPS (helps sites with misconfigured TLS)
|
||||
if parsed.scheme == "https":
|
||||
http_base = urlunparse(parsed._replace(scheme="http"))
|
||||
if http_base != url:
|
||||
yield http_base
|
||||
# http + output=amp
|
||||
q2 = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
if q2.get("output") != "amp":
|
||||
q2["output"] = "amp"
|
||||
http_amp = urlunparse(parsed._replace(scheme="http", query=urlencode(q2, doseq=True)))
|
||||
if http_amp != url:
|
||||
yield http_amp
|
||||
# http + amp=1
|
||||
q3 = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
if q3.get("amp") != "1":
|
||||
q3["amp"] = "1"
|
||||
http_amp1 = urlunparse(parsed._replace(scheme="http", query=urlencode(q3, doseq=True)))
|
||||
if http_amp1 != url:
|
||||
yield http_amp1
|
||||
# http path amp
|
||||
if not parsed.path.endswith("/amp"):
|
||||
http_amp_path = urlunparse(parsed._replace(scheme="http", path=parsed.path.rstrip("/") + "/amp"))
|
||||
if http_amp_path != url:
|
||||
yield http_amp_path
|
||||
except Exception:
|
||||
# Be conservative if URL parsing fails
|
||||
pass
|
||||
|
||||
|
||||
BLOCK_STATUSES = {403, 406, 429, 460}
|
||||
|
||||
|
||||
async def scrape_recipe_ldata(url: str, log=None, dump_dir: Optional[str] = None) -> Optional[dict]:
|
||||
"""Return best-effort recipe JSON-LD (or heuristic dict) for the URL.
|
||||
|
||||
If 'log' is provided (callable taking a string), diagnostic messages are emitted
|
||||
during scraping. No environment toggles are used; behavior matches production.
|
||||
"""
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
if log:
|
||||
try:
|
||||
log(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(http2=True, timeout=20.0) as client:
|
||||
visited: set[str] = set()
|
||||
|
||||
# Queue of URLs to try, seeded with simple fallbacks; we'll append amp/print variants we discover.
|
||||
to_try: List[str] = list(_fallback_urls(url))
|
||||
while to_try:
|
||||
candidate = to_try.pop(0)
|
||||
if candidate in visited:
|
||||
continue
|
||||
visited.add(candidate)
|
||||
# Try each header profile and attempt extraction per profile
|
||||
any_2xx = False
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
parsed = urlparse(candidate)
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}/"
|
||||
for prof in HEADER_PROFILES:
|
||||
prof_name = prof["name"]
|
||||
headers = dict(prof["headers"]) # copy
|
||||
if prof.get("add_referer"):
|
||||
headers.setdefault("Referer", origin)
|
||||
try:
|
||||
resp = await client.get(candidate, headers=headers, follow_redirects=True)
|
||||
except Exception as e:
|
||||
_log(f"GET[{prof_name}] {candidate} -> EXC {type(e).__name__}: {e}")
|
||||
continue
|
||||
_log(f"GET[{prof_name}] {candidate} -> {resp.status_code}")
|
||||
if resp.status_code in BLOCK_STATUSES or resp.status_code >= 300:
|
||||
continue
|
||||
any_2xx = True
|
||||
html = resp.text
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
# Optional debug dump
|
||||
if dump_dir:
|
||||
try:
|
||||
_dump_response(dump_dir, candidate, prof_name, resp)
|
||||
except Exception as e:
|
||||
_log(f"dump error: {type(e).__name__}: {e}")
|
||||
# Detect common bot protection pages early
|
||||
if (soup.title and "just a moment" in _extract_text(soup.title).lower()) or "challenge-platform" in html:
|
||||
_log("Detected Cloudflare-like challenge; trying next profile")
|
||||
continue
|
||||
# FRIENDLY DISCOVERY: follow declared amphtml and obvious print links
|
||||
try:
|
||||
# rel="amphtml"
|
||||
amp_link = None
|
||||
for link in soup.find_all("link"):
|
||||
rel = link.get("rel")
|
||||
href = link.get("href")
|
||||
if not href:
|
||||
continue
|
||||
if isinstance(rel, list) and any(r.lower() == "amphtml" for r in rel):
|
||||
amp_link = href
|
||||
break
|
||||
if isinstance(rel, str) and rel.lower() == "amphtml":
|
||||
amp_link = href
|
||||
break
|
||||
if amp_link:
|
||||
from urllib.parse import urljoin
|
||||
amp_abs = urljoin(candidate, amp_link)
|
||||
if amp_abs not in visited and amp_abs not in to_try:
|
||||
to_try.append(amp_abs)
|
||||
_log(f"discovered amphtml link: {amp_abs}")
|
||||
|
||||
# print links (plugin or generic)
|
||||
from urllib.parse import urljoin, parse_qsl, urlencode
|
||||
for a in soup.find_all("a", href=True):
|
||||
href = a["href"]
|
||||
low = href.lower()
|
||||
if any(k in low for k in ["print", "wprm-print", "tasty-recipes-print", "/print/"]):
|
||||
absu = urljoin(candidate, href)
|
||||
if absu not in visited and absu not in to_try:
|
||||
to_try.append(absu)
|
||||
# linked JSON-LD files
|
||||
for link in soup.find_all("link"):
|
||||
rel = link.get("rel")
|
||||
typ = (link.get("type") or link.get("as") or "").lower()
|
||||
href = link.get("href")
|
||||
if not href:
|
||||
continue
|
||||
if (isinstance(rel, list) and any(r.lower() == "alternate" for r in rel)) or (
|
||||
isinstance(rel, str) and rel.lower() == "alternate"
|
||||
):
|
||||
if "ld+json" in typ:
|
||||
from urllib.parse import urljoin
|
||||
absu = urljoin(candidate, href)
|
||||
if absu not in visited and absu not in to_try:
|
||||
to_try.append(absu)
|
||||
_log(f"discovered linked ld+json: {absu}")
|
||||
# query param prints: add print=1 once if missing
|
||||
parsed_url = parsed
|
||||
q_items = list(parse_qsl(parsed_url.query, keep_blank_values=True))
|
||||
has_print = any(k == "print" for k, _ in q_items)
|
||||
if not has_print:
|
||||
q_items.append(("print", "1"))
|
||||
print_url = urlunparse(parsed_url._replace(query=urlencode(q_items, doseq=True)))
|
||||
if print_url not in visited and print_url not in to_try:
|
||||
to_try.append(print_url)
|
||||
_log(f"queued print variant: {print_url}")
|
||||
except Exception as e:
|
||||
_log(f"discovery error: {type(e).__name__}: {e}")
|
||||
|
||||
found = _extract_ldata_from_soup(soup, candidate, _log)
|
||||
if found:
|
||||
return found
|
||||
|
||||
if not any_2xx:
|
||||
_log("All header profiles blocked or non-2xx; trying next fallback")
|
||||
continue
|
||||
|
||||
_log("No recipe data found after all fallbacks")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log(f"EXC during scraping: {type(e).__name__}: {e}")
|
||||
async def scrape_recipe_ldata(url: str) -> Optional[dict]:
|
||||
# Load the requested URL with headers
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=HEADERS, follow_redirects=True)
|
||||
if response.status_code >= 300:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_ldata_from_soup(soup: BeautifulSoup, candidate: str, log=None) -> Optional[dict]:
|
||||
def _log(msg: str) -> None:
|
||||
if log:
|
||||
# Extract the recipe ld+json data
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
for ld in soup.find_all("script", type="application/ld+json"):
|
||||
try:
|
||||
log(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import re as _re
|
||||
|
||||
def _strip_to_json_candidate(text: str) -> str:
|
||||
# Remove HTML/JS comment wrappers and CDATA markers
|
||||
t = text.strip()
|
||||
t = _re.sub(r"<!--|-->", "", t)
|
||||
t = _re.sub(r"/\*.*?\*/", "", t, flags=_re.S)
|
||||
t = _re.sub(r"(^|\s)//.*$", "", t, flags=_re.M)
|
||||
# Trim to outermost JSON-like braces/brackets
|
||||
first_brace = t.find("{")
|
||||
first_brack = t.find("[")
|
||||
starts = [i for i in [first_brace, first_brack] if i != -1]
|
||||
if not starts:
|
||||
return t
|
||||
start = min(starts)
|
||||
last_brace = t.rfind("}")
|
||||
last_brack = t.rfind("]")
|
||||
ends = [i for i in [last_brace, last_brack] if i != -1]
|
||||
if not ends:
|
||||
return t
|
||||
end = max(ends)
|
||||
return t[start : end + 1]
|
||||
|
||||
def _remove_trailing_commas(s: str) -> str:
|
||||
prev = None
|
||||
curr = s
|
||||
# Iteratively remove trailing commas before } or ]
|
||||
for _ in range(3): # limit passes
|
||||
prev = curr
|
||||
curr = _re.sub(r",\s*([}\]])", r"\1", curr)
|
||||
if curr == prev:
|
||||
break
|
||||
return curr
|
||||
|
||||
def _parse_json_lenient(text: str) -> Optional[object]:
|
||||
# Try strict first
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
pass
|
||||
# Clean wrappers and comments
|
||||
t = _strip_to_json_candidate(text)
|
||||
# Sometimes multiple roots are concatenated; try to form a list conservatively
|
||||
if t.count("{") > 1 and "}\n{" in t:
|
||||
parts = [p for p in t.split("\n") if p.strip()]
|
||||
maybe = "[" + ",".join(parts) + "]"
|
||||
try:
|
||||
return json.loads(maybe)
|
||||
except Exception:
|
||||
pass
|
||||
# Remove trailing commas
|
||||
t2 = _remove_trailing_commas(t)
|
||||
try:
|
||||
return json.loads(t2)
|
||||
except Exception:
|
||||
pass
|
||||
# As a last resort, if no double quotes exist but single quotes do, try naive conversion
|
||||
if '"' not in t2 and "'" in t2:
|
||||
t3 = t2.replace("'", '"')
|
||||
try:
|
||||
return json.loads(t3)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
# 1) JSON-LD scripts (strict mode: only source of truth)
|
||||
ld_nodes = soup.find_all("script", attrs={"type": _re.compile(r"ld\+json", _re.I)})
|
||||
_log(f"Found {len(ld_nodes)} ld+json scripts")
|
||||
for idx, ld in enumerate(ld_nodes):
|
||||
try:
|
||||
text = ld.text.strip()
|
||||
data = None
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.decoder.JSONDecodeError:
|
||||
# Try lenient parsing strategies
|
||||
data = _parse_json_lenient(text)
|
||||
if data is not None:
|
||||
_log(f"ld[{idx}]: parsed with lenient JSON repair")
|
||||
else:
|
||||
raise
|
||||
|
||||
if isinstance(data, dict) and _is_recipe_ldata(data):
|
||||
_log(f"ld[{idx}]: direct @type Recipe found")
|
||||
data = json.loads(ld.text)
|
||||
# _dump_json_data_to_log(data)
|
||||
if _is_recipe_ldata(data):
|
||||
return data
|
||||
|
||||
if isinstance(data, dict) and "@graph" in data:
|
||||
for gidx, item in enumerate(data["@graph"]):
|
||||
if "@graph" in data:
|
||||
for item in data["@graph"]:
|
||||
if _is_recipe_ldata(item):
|
||||
_log(f"ld[{idx}]: @graph item {gidx} is Recipe")
|
||||
return item
|
||||
|
||||
if isinstance(data, list):
|
||||
for lidx, item in enumerate(data):
|
||||
for item in data:
|
||||
if _is_recipe_ldata(item):
|
||||
_log(f"ld[{idx}]: list item {lidx} is Recipe")
|
||||
return item
|
||||
|
||||
except (json.decoder.JSONDecodeError, KeyError) as e:
|
||||
_log(f"ld[{idx}]: JSON parse/Key error: {type(e).__name__}: {e}")
|
||||
except (json.decoder.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# 1b) JSON-LD within <noscript> blocks
|
||||
try:
|
||||
ns_count = 0
|
||||
for ns in soup.find_all("noscript"):
|
||||
payload = ns.get_text(strip=False) or ns.decode_contents(formatter="html") or ""
|
||||
if not payload:
|
||||
continue
|
||||
nsoup = BeautifulSoup(payload, "html.parser")
|
||||
nodes = nsoup.find_all("script", attrs={"type": _re.compile(r"ld\+json", _re.I)})
|
||||
ns_count += len(nodes)
|
||||
for nidx, node in enumerate(nodes):
|
||||
try:
|
||||
text = (node.text or node.get_text() or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
data = _parse_json_lenient(text) or {}
|
||||
if isinstance(data, dict) and _is_recipe_ldata(data):
|
||||
_log(f"noscript.ld[{nidx}]: direct Recipe found")
|
||||
return data
|
||||
if isinstance(data, dict) and "@graph" in data:
|
||||
for gidx, item in enumerate(data.get("@graph") or []):
|
||||
if _is_recipe_ldata(item):
|
||||
_log(f"noscript.ld[{nidx}]: @graph item {gidx} is Recipe")
|
||||
return item
|
||||
if isinstance(data, list):
|
||||
for lidx, item in enumerate(data):
|
||||
if _is_recipe_ldata(item):
|
||||
_log(f"noscript.ld[{nidx}]: list item {lidx} is Recipe")
|
||||
return item
|
||||
except Exception as e:
|
||||
_log(f"noscript.ld[{nidx}]: parse error: {type(e).__name__}: {e}")
|
||||
if ns_count:
|
||||
_log(f"noscript: scanned {ns_count} ld+json scripts")
|
||||
except Exception as e:
|
||||
_log(f"noscript scan error: {type(e).__name__}: {e}")
|
||||
|
||||
# 1c) JSON-LD by content signature in any script or HTML when type is missing
|
||||
def _extract_json_objects_around(text: str) -> List[str]:
|
||||
out: List[str] = []
|
||||
if not text:
|
||||
return out
|
||||
t = _html.unescape(text)
|
||||
key = "@context"
|
||||
pos = 0
|
||||
while True:
|
||||
at = t.find(key, pos)
|
||||
if at == -1:
|
||||
break
|
||||
# find preceding '{'
|
||||
lb = t.rfind("{", 0, at)
|
||||
if lb == -1:
|
||||
pos = at + len(key)
|
||||
continue
|
||||
i = lb
|
||||
depth = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
end = -1
|
||||
while i < len(t):
|
||||
ch = t[i]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif ch == "\\":
|
||||
esc = True
|
||||
elif ch == '"':
|
||||
in_str = False
|
||||
else:
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
elif ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i
|
||||
break
|
||||
i += 1
|
||||
if end != -1:
|
||||
out.append(t[lb:end+1])
|
||||
pos = end + 1
|
||||
else:
|
||||
pos = at + len(key)
|
||||
return out
|
||||
|
||||
def _try_parse_jsonld_candidates(cands: List[str], origin: str) -> Optional[dict]:
|
||||
for cidx, cand in enumerate(cands):
|
||||
data = _parse_json_lenient(cand)
|
||||
if data is None:
|
||||
continue
|
||||
if isinstance(data, dict) and _is_recipe_ldata(data):
|
||||
_log(f"{origin}[{cidx}]: Recipe found")
|
||||
return data
|
||||
if isinstance(data, dict) and "@graph" in data:
|
||||
for gidx, item in enumerate(data.get("@graph") or []):
|
||||
if _is_recipe_ldata(item):
|
||||
_log(f"{origin}[{cidx}]: @graph item {gidx} is Recipe")
|
||||
return item
|
||||
if isinstance(data, list):
|
||||
for lidx, item in enumerate(data):
|
||||
if _is_recipe_ldata(item):
|
||||
_log(f"{origin}[{cidx}]: list item {lidx} is Recipe")
|
||||
return item
|
||||
return None
|
||||
|
||||
# Scan all <script> tags for JSON-LD signature when nothing found
|
||||
try:
|
||||
texts: List[str] = []
|
||||
for sc in soup.find_all("script"):
|
||||
txt = (sc.string or sc.get_text() or "").strip()
|
||||
if not txt:
|
||||
continue
|
||||
if "@context" not in txt or "schema.org" not in txt:
|
||||
continue
|
||||
texts.append(txt)
|
||||
if texts:
|
||||
cands: List[str] = []
|
||||
for t in texts:
|
||||
cands.extend(_extract_json_objects_around(t))
|
||||
found = _try_parse_jsonld_candidates(cands, origin="script-scan")
|
||||
if found:
|
||||
return found
|
||||
except Exception as e:
|
||||
_log(f"script content scan error: {type(e).__name__}: {e}")
|
||||
|
||||
# Finally, scan the full HTML string
|
||||
try:
|
||||
html_str = str(soup)
|
||||
if "@context" in html_str and "schema.org" in html_str:
|
||||
cands = _extract_json_objects_around(html_str)
|
||||
found = _try_parse_jsonld_candidates(cands, origin="html-scan")
|
||||
if found:
|
||||
return found
|
||||
except Exception as e:
|
||||
_log(f"html scan error: {type(e).__name__}: {e}")
|
||||
# Fallback return to satisfy static analysis
|
||||
return None
|
||||
|
||||
|
||||
def _dump_response(dump_dir: str, url: str, prof: str, resp: httpx.Response) -> None:
|
||||
def _dump_json_data_to_log(data: dict) -> str:
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
os.makedirs(dump_dir, exist_ok=True)
|
||||
p = urlparse(url)
|
||||
base = f"{p.netloc}{p.path}"
|
||||
if not base or base.endswith("/"):
|
||||
base += "index"
|
||||
safe = base.replace("/", "_").replace("?", "_").replace("&", "_")
|
||||
fname = f"{safe}__{prof}__{resp.status_code}.html"
|
||||
meta = f"{safe}__{prof}__{resp.status_code}.meta"
|
||||
fpath = os.path.join(dump_dir, fname)
|
||||
mpath = os.path.join(dump_dir, meta)
|
||||
with open(fpath, "w", encoding=resp.encoding or "utf-8", errors="ignore") as f:
|
||||
f.write(resp.text)
|
||||
with open(mpath, "w", encoding="utf-8") as f:
|
||||
f.write(f"URL: {url}\n")
|
||||
f.write(f"Profile: {prof}\n")
|
||||
f.write(f"Status: {resp.status_code}\n")
|
||||
f.write(f"Content-Type: {resp.headers.get('content-type','')}\n")
|
||||
import re
|
||||
|
||||
dir = "./data/dump"
|
||||
if not os.path.exists(dir):
|
||||
os.makedirs(dir)
|
||||
|
||||
def scrape_recipe_ldata_from_html(html: str, base_url: str, log=None) -> Optional[dict]:
|
||||
"""Extract recipe JSON-LD from raw HTML (strict ld+json-only).
|
||||
|
||||
base_url is used for relative URL resolution and as a fallback name/link context.
|
||||
"""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
return _extract_ldata_from_soup(soup, base_url, log)
|
||||
|
||||
|
||||
|
||||
def _extract_text(el) -> str:
|
||||
return " ".join(el.get_text(" ", strip=True).split()) if el else ""
|
||||
|
||||
prefix = "ldata_"
|
||||
suffix = ".json"
|
||||
file_ids = [
|
||||
int(re.findall(r"\d+", f)[0])
|
||||
for f in os.listdir(dir)
|
||||
if re.match(prefix + r"\d+" + suffix, f)
|
||||
]
|
||||
id = max(file_ids) + 1 if file_ids else 0
|
||||
filename = f"{prefix}{id}{suffix}"
|
||||
full_path = os.path.join(dir, filename)
|
||||
with open(full_path, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
return full_path
|
||||
|
|
|
|||
|
|
@ -4,4 +4,3 @@ httpx==0.27.2
|
|||
ingredient-parser-nlp==1.1.2
|
||||
beautifulsoup4==4.12.3
|
||||
aiosqlite==0.20.0
|
||||
argon2-cffi==23.1.0
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
"""
|
||||
Generate expected Recipe models from saved HTML snapshots (offline).
|
||||
|
||||
Reads HTML files from tests/sample_files/recipes and writes expected
|
||||
Recipe JSONs into tests/sample_files/recipes/expected with matching slugs.
|
||||
|
||||
Run:
|
||||
python -m scripts.generate_expected_from_snapshots
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from types import SimpleNamespace
|
||||
|
||||
from db import connect, create
|
||||
from recipes import parse_recipe_from_html
|
||||
|
||||
|
||||
SNAP_DIR = Path("tests/sample_files/recipes")
|
||||
|
||||
|
||||
def _write_expected(slug: str, recipe) -> None:
|
||||
# Write <slug>.recipe.json at SNAP_DIR root (canonical)
|
||||
new_out = SNAP_DIR / f"{slug}.recipe.json"
|
||||
if recipe is None:
|
||||
new_out.write_text("null")
|
||||
else:
|
||||
new_out.write_text(recipe.model_dump_json(by_alias=True, indent=2))
|
||||
|
||||
|
||||
async def _run() -> None:
|
||||
html_files = sorted(p for p in SNAP_DIR.glob("*.html"))
|
||||
if not html_files:
|
||||
print("no HTML snapshots found; run scripts.save_recipe_pages first")
|
||||
return
|
||||
|
||||
# Minimal created_by object the parser expects (id, display_name)
|
||||
created_by = SimpleNamespace(id=1, display_name="Snapshot Generator")
|
||||
conn = await connect()
|
||||
await create(conn)
|
||||
try:
|
||||
for p in html_files:
|
||||
slug = p.stem
|
||||
url = None
|
||||
meta_path = SNAP_DIR / f"{slug}.meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text())
|
||||
url = meta.get("final_url") or meta.get("url")
|
||||
except Exception:
|
||||
pass
|
||||
base_url = url or slug
|
||||
html = p.read_text()
|
||||
recipe = await parse_recipe_from_html(conn, created_by, base_url, html)
|
||||
_write_expected(slug, recipe)
|
||||
print(f"wrote expected for {slug}: {'ok' if recipe else 'none'}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(_run())
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
"""
|
||||
Manual harness to exercise recipes.parse_recipe against specific public URLs.
|
||||
|
||||
This WILL NOT run in CI or with `make test` — run it explicitly:
|
||||
|
||||
make install # first time
|
||||
python -m scripts.manual_parse_recipes
|
||||
|
||||
Notes:
|
||||
- Creates/bootstraps a local SQLite DB (./data/doof.sqlite) for lookups.
|
||||
- Does not persist recipes; it just prints parsed results.
|
||||
- Network access required to fetch pages.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from db import connect, create
|
||||
from recipes import parse_recipe
|
||||
from scripts.recipe_urls import URLS
|
||||
import os
|
||||
|
||||
# This harness streams results as they complete and keeps per-URL logs grouped.
|
||||
|
||||
|
||||
def _fmt(v) -> str:
|
||||
if v is None:
|
||||
return "-"
|
||||
return str(v)
|
||||
|
||||
|
||||
def _print_recipe(r) -> None:
|
||||
print("\n=== Parsed Recipe ===")
|
||||
print(f"name: {_fmt(getattr(r, 'name', None))}")
|
||||
print(f"serves: {_fmt(getattr(r, 'serves', None))}")
|
||||
print(f"link: {_fmt(getattr(r, 'link', None))}")
|
||||
imgs = getattr(r, "image_urls", []) or []
|
||||
if imgs:
|
||||
print(f"images[0]: {imgs[0]}")
|
||||
ings = getattr(r, "ingredients", []) or []
|
||||
print(f"ingredients: {len(ings)}")
|
||||
for ing in ings[:10]:
|
||||
# Each is an Ingredient model with line/name/quantity/unit
|
||||
n = getattr(ing, "name", "")
|
||||
q = getattr(ing, "quantity", "")
|
||||
u = getattr(ing, "unit", "")
|
||||
line = getattr(ing, "line", "")
|
||||
print(f" - {n} ({q} {u}) :: {line}")
|
||||
|
||||
|
||||
async def _parse_one(conn, url: str, dump_dir: str | None) -> Dict[str, Any]:
|
||||
# Minimal created_by object the parser expects (id, display_name)
|
||||
created_by = SimpleNamespace(id=1, display_name="Manual Tester")
|
||||
logs: List[str] = []
|
||||
def _log(msg: str):
|
||||
# Collect per-URL logs; we'll print them later in a grouped section
|
||||
logs.append(msg)
|
||||
try:
|
||||
r = await parse_recipe(conn, created_by, url, log=_log, dump_dir=dump_dir)
|
||||
except Exception as e:
|
||||
return {"url": url, "recipe": None, "logs": logs, "error": str(e)}
|
||||
return {"url": url, "recipe": r, "logs": logs, "error": None}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
conn = await connect()
|
||||
# Ensure schema exists for product matching in ingredient parsing
|
||||
await create(conn)
|
||||
try:
|
||||
dump_dir = os.environ.get("SCRAPER_DUMP_DIR") or None
|
||||
concurrency = min(8, len(URLS))
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
# Consume all available slots and stagger release initially to avoid bursts
|
||||
for _ in range(concurrency):
|
||||
await sem.acquire()
|
||||
# Release all slots with slight delays to avoid thundering herd
|
||||
for i in range(concurrency):
|
||||
asyncio.get_event_loop().call_later(i * 0.5, sem.release)
|
||||
|
||||
async def _worker(u: str):
|
||||
async with sem:
|
||||
return await _parse_one(conn, u, dump_dir)
|
||||
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
tasks = [asyncio.create_task(_worker(url)) for url in URLS]
|
||||
|
||||
|
||||
# Stream results as they arrive; print per-URL blocks to avoid jumbled output
|
||||
totals = {"total": 0, "parsed": 0, "errors": 0, "no_recipe": 0}
|
||||
for task in asyncio.as_completed(tasks):
|
||||
res = await task
|
||||
url = res["url"]
|
||||
totals["total"] += 1
|
||||
print("\n==============================")
|
||||
print(f"Result: {url}")
|
||||
print("==============================")
|
||||
if res.get("error"):
|
||||
totals["errors"] += 1
|
||||
print(f"ERROR: failed to parse {url}:")
|
||||
print(f" {res['error']}")
|
||||
r = res.get("recipe")
|
||||
if r is None and not res.get("error"):
|
||||
totals["no_recipe"] += 1
|
||||
print(f"WARN: no recipe data found at {url}")
|
||||
if r is not None:
|
||||
totals["parsed"] += 1
|
||||
_print_recipe(r)
|
||||
|
||||
# Logs
|
||||
logs: List[str] = res.get("logs") or []
|
||||
# Print logs for failures only to reduce noise; clip to a reasonable limit
|
||||
if (r is None or res.get("error")) and logs:
|
||||
clip = logs[:200]
|
||||
print("-- logs --")
|
||||
for line in clip:
|
||||
print(f" dbg: {line}")
|
||||
if len(logs) > len(clip):
|
||||
print(f" .. ({len(logs) - len(clip)} more lines clipped) ..")
|
||||
|
||||
print("\n=== Summary ===")
|
||||
print(f"Total URLs processed: {totals['total']}")
|
||||
print(f"Successfully parsed: {totals['parsed']}")
|
||||
print(f"Errors: {totals['errors']}")
|
||||
print(f"No recipe found: {totals['no_recipe']}")
|
||||
print(f"Time elapsed: {asyncio.get_event_loop().time() - start_time:.2f} seconds")
|
||||
print(f"Remaining URLs: {len(URLS) - totals['total']}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
"""
|
||||
Central list of recipe URLs used by manual harnesses and snapshot tools.
|
||||
|
||||
Keep this list focused on stable, public pages across a variety of sites.
|
||||
"""
|
||||
|
||||
URLS = [
|
||||
# ld+json: YES (Recipe present under @graph)
|
||||
"https://cookieandkate.com/favorite-fried-eggs-recipe/",
|
||||
# ld+json: UNKNOWN (blocked/SSL issues in our environment)
|
||||
"https://www.taste.com.au/recipes/classic-chewy-brownie/f0960a83-1fa9-4e3e-b616-a995182613e0",
|
||||
# ld+json: PRESENT but MALFORMED (first ld+json is Recipe but not valid JSON)
|
||||
"https://www.inspiredtaste.net/24412/cocoa-brownies-recipe/",
|
||||
# ld+json: YES (Recipe present)
|
||||
"https://www.simplyrecipes.com/recipes/french_toast/",
|
||||
# Additional diverse sources
|
||||
# ld+json: YES (Recipe present)
|
||||
"https://pinchofyum.com/lemon-chicken-piccata-with-grilled-bread",
|
||||
# ld+json: YES (Recipe present)
|
||||
"https://www.bbcgoodfood.com/recipes/chicken-tikka-masala",
|
||||
# ld+json: YES (Recipe present)
|
||||
"https://www.recipetineats.com/beef-stroganoff/",
|
||||
# ld+json: YES (Recipe present)
|
||||
"https://sallysbakingaddiction.com/triple-chocolate-layer-cake/",
|
||||
# ld+json: YES (Recipe present)
|
||||
"https://downshiftology.com/recipes/shakshuka/",
|
||||
# ld+json: UNKNOWN (403 blocked by CDN)
|
||||
"https://damndelicious.net/2025/08/01/corn-salsa/",
|
||||
# ld+json: UNKNOWN (403 blocked by CDN)
|
||||
"https://www.thekitchn.com/chicken-tamale-pie-23752740",
|
||||
# ld+json: YES (Recipe present)
|
||||
"https://www.simplyrecipes.com/recipes/perfect_guacamole/",
|
||||
# ld+json: YES (Recipe present)
|
||||
"https://www.kingarthurbaking.com/recipes/banana-bread-recipe",
|
||||
# ld+json: YES (Recipe present; multiple ld+json, only the first has Recipe)
|
||||
"https://tasty.co/recipe/one-pot-garlic-parmesan-pasta",
|
||||
]
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
"""
|
||||
Snapshot recipe pages locally for offline inspection and future parsing tests.
|
||||
|
||||
Run explicitly (not part of CI by default):
|
||||
|
||||
python -m scripts.save_recipe_pages
|
||||
|
||||
This will download each URL in scripts/recipe_urls.py and save into:
|
||||
tests/sample_files/recipes/
|
||||
|
||||
For each URL, it writes:
|
||||
- <slug>.html The raw page HTML
|
||||
- <slug>.meta.json JSON metadata (url, fetched_at, status, final_url, error)
|
||||
|
||||
Notes:
|
||||
- Uses the same browser-like headers as our scraper.
|
||||
- Respects redirects; stores final_url.
|
||||
- Skips writing HTML on hard errors but still writes a .meta.json with the error.
|
||||
- Creates parent folders as needed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from scripts.recipe_urls import URLS
|
||||
from recipes.scraping import DEFAULT_HEADERS, HEADER_PROFILES, _fallback_urls, fetch_first_2xx_html, looks_like_jsonld
|
||||
|
||||
|
||||
OUT_DIR = Path("tests/sample_files/recipes")
|
||||
|
||||
|
||||
def _safe_slug_from_url(url: str) -> str:
|
||||
# Keep domain and last path segment(s) for readability; replace non-word with '-'
|
||||
from urllib.parse import urlparse
|
||||
p = urlparse(url)
|
||||
host = p.netloc.replace(":", "-")
|
||||
path = p.path.rstrip("/")
|
||||
if not path:
|
||||
seg = "index"
|
||||
else:
|
||||
# use last two segments if available to avoid collisions like /recipe/ vs /recipe-2/
|
||||
parts = [s for s in path.split("/") if s]
|
||||
seg = "-".join(parts[-2:]) if len(parts) >= 2 else parts[-1]
|
||||
raw = f"{host}-{seg}".lower()
|
||||
slug = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-")
|
||||
return slug or "page"
|
||||
|
||||
|
||||
async def _fetch_best_html(client: httpx.AsyncClient, url: str) -> Tuple[str, Dict]:
|
||||
meta: Dict = {
|
||||
"url": url,
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"status": None,
|
||||
"final_url": None,
|
||||
"error": None,
|
||||
"profile": None,
|
||||
"content_type": None,
|
||||
"content_encoding": None,
|
||||
"candidate": None,
|
||||
}
|
||||
visited: set[str] = set()
|
||||
candidates = list(_fallback_urls(url))
|
||||
for cand in candidates:
|
||||
if cand in visited:
|
||||
continue
|
||||
visited.add(cand)
|
||||
html, prof_meta = await fetch_first_2xx_html(client, cand, safe_accept_encoding=True)
|
||||
# Merge selected metadata
|
||||
meta.update({
|
||||
"status": int(prof_meta.get("status") or 0) or None,
|
||||
"final_url": prof_meta.get("final_url"),
|
||||
"content_type": prof_meta.get("content_type"),
|
||||
"content_encoding": prof_meta.get("content_encoding"),
|
||||
"profile": prof_meta.get("profile"),
|
||||
"request_headers": prof_meta.get("request_headers"),
|
||||
"candidate": cand,
|
||||
"error": prof_meta.get("error"),
|
||||
})
|
||||
if html and looks_like_jsonld(html):
|
||||
return html, meta
|
||||
# If html is present, keep as a fallback in case later candidates fail
|
||||
if html:
|
||||
fallback_html = html
|
||||
fallback_meta = dict(meta)
|
||||
continue
|
||||
# Nothing succeeded; return empty payload with last meta
|
||||
return "", meta
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
async with httpx.AsyncClient(http2=True) as client:
|
||||
sem = asyncio.Semaphore(6)
|
||||
|
||||
async def worker(u: str):
|
||||
async with sem:
|
||||
html, meta = await _fetch_best_html(client, u)
|
||||
slug = _safe_slug_from_url(u)
|
||||
html_path = OUT_DIR / f"{slug}.html"
|
||||
meta_path = OUT_DIR / f"{slug}.meta.json"
|
||||
try:
|
||||
# Always write metadata
|
||||
meta_path.write_text(json.dumps(meta, indent=2))
|
||||
# Only persist HTML on successful fetch
|
||||
if meta.get("status") and 200 <= int(meta["status"]) < 300 and html:
|
||||
html_path.write_text(html)
|
||||
print(f"saved: {u} -> {html_path.name} (status={meta.get('status')}, error={meta.get('error')})")
|
||||
except Exception as e:
|
||||
print(f"failed to save for {u}: {type(e).__name__}: {e}")
|
||||
|
||||
await asyncio.gather(*(worker(u) for u in URLS))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
96
security.py
96
security.py
|
|
@ -1,96 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(data: str) -> bytes:
|
||||
padding = "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(data + padding)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JwtConfig:
|
||||
issuer: str
|
||||
audience: str
|
||||
access_secret: bytes
|
||||
refresh_secret: bytes
|
||||
access_ttl_seconds: int
|
||||
refresh_ttl_seconds: int
|
||||
|
||||
|
||||
def _sign(secret: bytes, msg: bytes) -> str:
|
||||
sig = hmac.new(secret, msg, sha256).digest()
|
||||
return _b64url_encode(sig)
|
||||
|
||||
|
||||
def _encode_header() -> str:
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
return _b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
|
||||
|
||||
|
||||
def _encode_payload(claims: Dict[str, Any]) -> str:
|
||||
return _b64url_encode(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
|
||||
|
||||
|
||||
def create_jwt(
|
||||
config: JwtConfig, subject: str, kind: str = "access", extra: Dict[str, Any] | None = None
|
||||
) -> str:
|
||||
now = int(time.time())
|
||||
ttl = config.access_ttl_seconds if kind == "access" else config.refresh_ttl_seconds
|
||||
secret = config.access_secret if kind == "access" else config.refresh_secret
|
||||
claims: Dict[str, Any] = {
|
||||
"iss": config.issuer,
|
||||
"aud": config.audience,
|
||||
"sub": subject,
|
||||
"iat": now,
|
||||
"exp": now + ttl,
|
||||
"typ": kind,
|
||||
}
|
||||
if extra:
|
||||
claims.update(extra)
|
||||
header = _encode_header()
|
||||
payload = _encode_payload(claims)
|
||||
signing_input = f"{header}.{payload}".encode("ascii")
|
||||
signature = _sign(secret, signing_input)
|
||||
return f"{header}.{payload}.{signature}"
|
||||
|
||||
|
||||
def verify_jwt(
|
||||
config: JwtConfig, token: str, expected_kind: str = "access"
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
try:
|
||||
header_b64, payload_b64, sig = token.split(".")
|
||||
except ValueError:
|
||||
raise ValueError("Invalid token format")
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
|
||||
header = json.loads(_b64url_decode(header_b64))
|
||||
if header.get("alg") != "HS256" or header.get("typ") != "JWT":
|
||||
raise ValueError("Unsupported JWT header")
|
||||
payload = json.loads(_b64url_decode(payload_b64))
|
||||
kind = payload.get("typ")
|
||||
secret = config.access_secret if kind == "access" else config.refresh_secret
|
||||
if not hmac.compare_digest(sig, _sign(secret, signing_input)):
|
||||
raise ValueError("Invalid signature")
|
||||
now = int(time.time())
|
||||
if payload.get("iss") != config.issuer or payload.get("aud") != config.audience:
|
||||
raise ValueError("Invalid claims")
|
||||
if kind != expected_kind:
|
||||
raise ValueError("Invalid token type")
|
||||
if int(payload.get("exp", 0)) < now:
|
||||
raise ValueError("Token expired")
|
||||
return header, payload
|
||||
|
||||
|
||||
def random_secret(n: int = 32) -> bytes:
|
||||
return os.urandom(n)
|
||||
11
settings.py
11
settings.py
|
|
@ -9,7 +9,6 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -21,15 +20,7 @@ class Settings:
|
|||
prod: bool = os.environ.get("DOOF_PROD", "false").lower() in {"1", "true", "yes"}
|
||||
|
||||
# Frontend dev server for reverse proxy in non-prod
|
||||
frontend_dev_url: str = os.environ.get("FRONTEND_DEV_URL", "http://localhost:8000/")
|
||||
|
||||
# JWT settings
|
||||
jwt_issuer: str = os.environ.get("DOOF_JWT_ISSUER", "doof-backend")
|
||||
jwt_audience: str = os.environ.get("DOOF_JWT_AUDIENCE", "doof-web")
|
||||
access_ttl_seconds: int = int(os.environ.get("DOOF_JWT_ACCESS_TTL", "900")) # 15 minutes
|
||||
refresh_ttl_seconds: int = int(os.environ.get("DOOF_JWT_REFRESH_TTL", "2592000")) # 30 days
|
||||
access_secret_b64: Optional[str] = os.environ.get("DOOF_JWT_ACCESS_SECRET_B64")
|
||||
refresh_secret_b64: Optional[str] = os.environ.get("DOOF_JWT_REFRESH_SECRET_B64")
|
||||
frontend_dev_url: str = os.environ.get("FRONTEND_DEV_URL", "http://localhost:8080/")
|
||||
|
||||
|
||||
# A module-level singleton for convenience imports
|
||||
|
|
|
|||
|
|
@ -6,16 +6,9 @@ import recipes
|
|||
from shopping.models import ShoppingList as ShoppingList, ShoppingListItem as ShoppingListItem
|
||||
from shopping.repository import (
|
||||
find_items_by_list_id as _find_items_by_list_id,
|
||||
find_items_by_list_id_scoped as _find_items_by_list_id_scoped,
|
||||
get_purchased_ingredients as _get_purchased_ingredients,
|
||||
get_purchased_ingredients_scoped as _get_purchased_ingredients_scoped,
|
||||
is_requested as is_requested,
|
||||
request_meal_scoped as request_meal_scoped,
|
||||
request_ingredient_scoped as request_ingredient_scoped,
|
||||
remove_meal_request_scoped as remove_meal_request_scoped,
|
||||
remove_ingredient_request_scoped as remove_ingredient_request_scoped,
|
||||
load_shopping_list as load_shopping_list,
|
||||
load_shopping_list_scoped as load_shopping_list_scoped,
|
||||
purchase as purchase,
|
||||
remove_request as remove_request,
|
||||
request as request,
|
||||
|
|
@ -145,55 +138,3 @@ async def get_outstanding_requests(
|
|||
recipes_lookup,
|
||||
ingredients_lookup,
|
||||
)
|
||||
|
||||
|
||||
async def get_outstanding_requests_scoped(
|
||||
conn,
|
||||
household_id: int,
|
||||
) -> Tuple[
|
||||
List[ShoppingListItem],
|
||||
List[ShoppingListItem],
|
||||
List[ShoppingListItem],
|
||||
Dict[int, Any],
|
||||
Dict[int, Any],
|
||||
Dict[int, Any],
|
||||
]:
|
||||
current_requests = [r async for r in _find_items_by_list_id_scoped(conn, None, household_id)]
|
||||
meal_requests = [r for r in current_requests if r.meal_id is not None and r.meal_id > 0]
|
||||
|
||||
# Get lookups for meals to enable flattening
|
||||
meals_lookup, recipes_lookup, ingredients_lookup = await to_lookups(conn, current_requests)
|
||||
|
||||
meal_ids = [r.meal_id for r in meal_requests if r.meal_id]
|
||||
purchased_ingredients = {
|
||||
(r.ingredient_id, r.meal_id, r.recipe_id): r
|
||||
async for r in _get_purchased_ingredients_scoped(conn, meal_ids, household_id)
|
||||
}
|
||||
|
||||
outstanding_items = []
|
||||
purchased_items = []
|
||||
flattened = list(flatten_items(current_requests, meals_lookup))
|
||||
|
||||
# Now ensure that all ingredients from the flattened items are in the lookup
|
||||
await _ensure_lookups_populated(
|
||||
conn, flattened, meals_lookup, recipes_lookup, ingredients_lookup
|
||||
)
|
||||
|
||||
for r in flattened:
|
||||
# Meal ingredients may have already been purchased (by list in the same household)
|
||||
if r.meal_id is not None and r.meal_id > 0:
|
||||
purchased_item = purchased_ingredients.get((r.ingredient_id, r.meal_id, r.recipe_id))
|
||||
if purchased_item:
|
||||
purchased_items.append(purchased_item)
|
||||
continue
|
||||
|
||||
outstanding_items.append(r)
|
||||
|
||||
return (
|
||||
outstanding_items,
|
||||
purchased_items,
|
||||
meal_requests,
|
||||
meals_lookup,
|
||||
recipes_lookup,
|
||||
ingredients_lookup,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from typing import ClassVar, List, Optional
|
|||
from pydantic import Field
|
||||
|
||||
from common import BaseLinkedModel
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
class ShoppingListItem(BaseLinkedModel):
|
||||
|
|
@ -43,6 +44,5 @@ class ShoppingList(BaseLinkedModel):
|
|||
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
|
||||
store_name: StoreEnum = StoreEnum.home
|
||||
purchased_by_id: int = -1
|
||||
# Optional holder for outward mapping; any object with id/display_name is acceptable
|
||||
purchased_by: Optional[object] = None
|
||||
purchased_by: Optional[Person] = None
|
||||
items: List[ShoppingListItem] = Field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ async def create(conn):
|
|||
created_date DATETIME NOT NULL,
|
||||
store_name TEXT NOT NULL,
|
||||
purchased_by_id INTEGER,
|
||||
household_id INTEGER,
|
||||
FOREIGN KEY(purchased_by_id) REFERENCES User(id)
|
||||
FOREIGN KEY(purchased_by_id) REFERENCES Person(id)
|
||||
);"""
|
||||
)
|
||||
|
||||
|
|
@ -26,10 +25,9 @@ async def create(conn):
|
|||
meal_id INTEGER,
|
||||
recipe_id INTEGER,
|
||||
created_date DATETIME NOT NULL,
|
||||
household_id INTEGER,
|
||||
FOREIGN KEY(ingredient_id) REFERENCES Ingredient(id),
|
||||
FOREIGN KEY(list_id) REFERENCES ShoppingList(id),
|
||||
FOREIGN KEY(person_id) REFERENCES User(id),
|
||||
FOREIGN KEY(person_id) REFERENCES Person(id),
|
||||
FOREIGN KEY(meal_id) REFERENCES Meal(id),
|
||||
FOREIGN KEY(recipe_id) REFERENCES Recipe(id)
|
||||
);"""
|
||||
|
|
@ -44,16 +42,6 @@ async def create(conn):
|
|||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_shopping_item_person_null_list ON ShoppingListItem(person_id, ingredient_id) WHERE list_id IS NULL;"
|
||||
)
|
||||
# Household indices for scoped queries
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_shopping_list_household_id ON ShoppingList(household_id);"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_shopping_item_household_id ON ShoppingListItem(household_id);"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_shopping_item_household_person_ing_null_list ON ShoppingListItem(household_id, person_id, ingredient_id) WHERE list_id IS NULL;"
|
||||
)
|
||||
|
||||
|
||||
def validate_request(request: ShoppingListItem) -> None:
|
||||
|
|
@ -152,42 +140,6 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
|
|||
await update_purchased_meals(conn, meal_ids)
|
||||
|
||||
|
||||
async def get_outstanding_requests_scoped(conn, household_id: int) -> List[ShoppingListItem]:
|
||||
# outstanding items are those that are not purchased and either have no meal or have a meal that has not been consumed
|
||||
# and is not part of a shopping list that has been purchased.
|
||||
# The subquery for `active_meal_ids` finds meals that are not consumed and not part of a purchased list.
|
||||
# The main query then selects items linked to these active meals OR items with no meal link at all.
|
||||
rows = await conn.execute_fetchall(
|
||||
"""
|
||||
WITH active_meal_ids AS (
|
||||
SELECT m.id
|
||||
FROM meals m
|
||||
LEFT JOIN shopping_list_items sli ON sli.meal_id = m.id
|
||||
LEFT JOIN shopping_lists sl ON sl.id = sli.shopping_list_id
|
||||
WHERE
|
||||
m.household_id = :household_id
|
||||
AND m.consumed_date IS NULL
|
||||
AND (sl.id IS NULL OR sl.purchased_by_id IS NULL)
|
||||
GROUP BY m.id
|
||||
)
|
||||
SELECT
|
||||
sli.id,
|
||||
sli.meal_id,
|
||||
sli.ingredient_id,
|
||||
sli.quantity,
|
||||
sli.unit,
|
||||
sli.added_by_id
|
||||
FROM shopping_list_items sli
|
||||
WHERE
|
||||
sli.household_id = :household_id
|
||||
AND sli.purchased_at IS NULL
|
||||
AND (sli.meal_id IN (SELECT id FROM active_meal_ids) OR sli.meal_id IS NULL);
|
||||
""",
|
||||
{"household_id": household_id},
|
||||
)
|
||||
return [_to_shopping_list_item(r) for r in rows]
|
||||
|
||||
|
||||
async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
|
||||
if not meal_ids:
|
||||
return
|
||||
|
|
@ -228,18 +180,6 @@ async def is_requested(conn, meal) -> bool:
|
|||
return row[0] > 0
|
||||
|
||||
|
||||
async def is_requested_scoped(conn, meal_id: int, household_id: int) -> bool:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM ShoppingListItem
|
||||
WHERE meal_id = ? AND list_id IS NULL AND household_id = ?
|
||||
""",
|
||||
(meal_id, household_id),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return row[0] > 0
|
||||
|
||||
|
||||
async def request(
|
||||
conn, person, ingredient: Optional[Any] = None, meal: Optional[Any] = None
|
||||
) -> ShoppingListItem:
|
||||
|
|
@ -279,68 +219,6 @@ async def request(
|
|||
return item
|
||||
|
||||
|
||||
async def request_meal_scoped(
|
||||
conn, meal: Any, household_id: int, person_id: int
|
||||
) -> ShoppingListItem:
|
||||
if meal is None or getattr(meal, "id", -1) < 0:
|
||||
raise ValueError("Meal must have a valid id")
|
||||
if await is_requested_scoped(conn, meal.id, household_id):
|
||||
raise ValueError("Meal is already requested")
|
||||
|
||||
# Require a valid person id
|
||||
if person_id is None or person_id < 0:
|
||||
raise ValueError("Meal request must have a valid person id")
|
||||
|
||||
item = ShoppingListItem(ingredient_id=None, person_id=person_id, meal_id=meal.id)
|
||||
|
||||
async with conn.execute(
|
||||
"""
|
||||
INSERT INTO ShoppingListItem (ingredient_id, person_id, meal_id, created_date, household_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(None, person_id, meal.id, item.created_date.isoformat(), household_id),
|
||||
) as cursor:
|
||||
item.id = cursor.lastrowid
|
||||
|
||||
return item
|
||||
|
||||
|
||||
async def request_ingredient_scoped(
|
||||
conn, ingredient: Any, household_id: int, person_id: int
|
||||
) -> ShoppingListItem:
|
||||
if ingredient is None or getattr(ingredient, "id", -1) < 0:
|
||||
raise ValueError("Ingredient must have a valid id")
|
||||
|
||||
# If already requested by this person and not yet purchased in this household, return existing
|
||||
where = "WHERE list_id IS NULL AND meal_id IS NULL AND ingredient_id = ? AND person_id = ? AND household_id = ?"
|
||||
params = (ingredient.id, person_id, household_id)
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT id, ingredient_id, list_id, person_id, meal_id, recipe_id, created_date
|
||||
FROM ShoppingListItem
|
||||
{where}
|
||||
LIMIT 1
|
||||
""",
|
||||
params,
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})
|
||||
|
||||
item = ShoppingListItem(ingredient_id=ingredient.id, person_id=person_id, meal_id=None)
|
||||
|
||||
async with conn.execute(
|
||||
"""
|
||||
INSERT INTO ShoppingListItem (ingredient_id, person_id, meal_id, created_date, household_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(ingredient.id, person_id, None, item.created_date.isoformat(), household_id),
|
||||
) as cursor:
|
||||
item.id = cursor.lastrowid
|
||||
|
||||
return item
|
||||
|
||||
|
||||
async def remove_request(
|
||||
conn,
|
||||
person: Optional[Any] = None,
|
||||
|
|
@ -370,40 +248,6 @@ async def remove_request(
|
|||
raise ValueError("Must specify either a meal or an ingredient to remove")
|
||||
|
||||
|
||||
async def remove_meal_request_scoped(conn, meal_id: int, household_id: int) -> bool:
|
||||
async with conn.execute(
|
||||
"""
|
||||
DELETE FROM ShoppingListItem
|
||||
WHERE list_id IS NULL AND meal_id = ? AND household_id = ?
|
||||
""",
|
||||
(meal_id, household_id),
|
||||
) as cursor:
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def remove_ingredient_request_scoped(
|
||||
conn, ingredient_id: int, person_id: int, household_id: int
|
||||
) -> bool:
|
||||
"""Remove an ad-hoc ingredient request for a specific user within a household.
|
||||
|
||||
This targets items that are not yet purchased (list_id IS NULL), have no meal/recipe linkage
|
||||
(pure personal request), and match the provided ingredient/person/household ids.
|
||||
"""
|
||||
async with conn.execute(
|
||||
"""
|
||||
DELETE FROM ShoppingListItem
|
||||
WHERE list_id IS NULL
|
||||
AND meal_id IS NULL
|
||||
AND recipe_id IS NULL
|
||||
AND ingredient_id = ?
|
||||
AND person_id = ?
|
||||
AND household_id = ?
|
||||
""",
|
||||
(ingredient_id, person_id, household_id),
|
||||
) as cursor:
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListItem]:
|
||||
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]
|
||||
|
||||
|
|
@ -426,33 +270,6 @@ async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[S
|
|||
yield request
|
||||
|
||||
|
||||
async def find_items_by_list_id_scoped(
|
||||
conn, list_id: Optional[int], household_id: int
|
||||
) -> AsyncIterator[ShoppingListItem]:
|
||||
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]
|
||||
|
||||
select = f"""
|
||||
SELECT {",".join(request_cols)}
|
||||
FROM ShoppingListItem
|
||||
"""
|
||||
|
||||
where: str
|
||||
params: tuple[Any, ...]
|
||||
where, params = (" WHERE list_id IS NULL AND household_id = ?", (household_id,))
|
||||
if list_id is not None:
|
||||
where, params = (
|
||||
" WHERE list_id = ? AND household_id = ?",
|
||||
(list_id, household_id),
|
||||
)
|
||||
|
||||
cursor = await conn.execute(select + where, params)
|
||||
|
||||
async for row in cursor:
|
||||
request_map = {k: v for k, v in zip(ShoppingListItem.KEYS, row)}
|
||||
request = ShoppingListItem(**request_map)
|
||||
yield request
|
||||
|
||||
|
||||
async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
|
||||
shopping_list: Optional[ShoppingList] = None
|
||||
async with conn.execute(
|
||||
|
|
@ -474,41 +291,6 @@ async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
|
|||
return shopping_list
|
||||
|
||||
|
||||
async def load_shopping_list_scoped(conn, id: int, household_id: int) -> Optional[ShoppingList]:
|
||||
shopping_list: Optional[ShoppingList] = None
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {",".join(ShoppingList.KEYS)}, (
|
||||
SELECT display_name FROM User u WHERE u.id = ShoppingList.purchased_by_id
|
||||
) as purchased_by_name
|
||||
FROM ShoppingList
|
||||
WHERE id = ? AND household_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(id, household_id),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
base = {k: v for k, v in zip(ShoppingList.KEYS, row[: len(ShoppingList.KEYS)])}
|
||||
shopping_list = ShoppingList(**base)
|
||||
# Attach a lightweight purchased_by with display_name if available
|
||||
try:
|
||||
display_name = row[len(ShoppingList.KEYS)]
|
||||
if display_name and shopping_list.purchased_by_id is not None:
|
||||
# Store a minimal object; api layer will map to MemberRef
|
||||
shopping_list.purchased_by = type("_PB", (), {})()
|
||||
setattr(shopping_list.purchased_by, "id", int(shopping_list.purchased_by_id))
|
||||
setattr(shopping_list.purchased_by, "display_name", display_name)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
if shopping_list:
|
||||
async for item in find_items_by_list_id_scoped(conn, shopping_list.id, household_id):
|
||||
shopping_list.items.append(item)
|
||||
|
||||
return shopping_list
|
||||
|
||||
|
||||
async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ShoppingListItem]:
|
||||
if not meal_ids:
|
||||
return
|
||||
|
|
@ -523,26 +305,3 @@ async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[
|
|||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})
|
||||
|
||||
|
||||
async def get_purchased_ingredients_scoped(
|
||||
conn, meal_ids: List[int], household_id: int
|
||||
) -> AsyncIterator[ShoppingListItem]:
|
||||
if not meal_ids:
|
||||
return
|
||||
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {",".join(ShoppingListItem.KEYS)}
|
||||
FROM ShoppingListItem
|
||||
WHERE meal_id IN ({",".join(["?"] * len(meal_ids))}) AND list_id IS NOT NULL AND household_id = ?
|
||||
""",
|
||||
(*meal_ids, household_id),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})
|
||||
|
||||
|
||||
def _to_shopping_list_item(row: Any) -> ShoppingListItem:
|
||||
# Row is a tuple in the order of columns selected; map via KEYS
|
||||
return ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
# Recipe page snapshots
|
||||
|
||||
This folder contains raw HTML snapshots and metadata for public recipe pages, saved for offline inspection and to support unit tests without hitting third-party sites.
|
||||
|
||||
Generated by:
|
||||
- `python -m scripts.save_recipe_pages` (downloads pages listed in `scripts/recipe_urls.py`)
|
||||
- `python -m scripts.generate_expected_from_snapshots` (parses HTML into structured recipe JSON)
|
||||
|
||||
File layout per URL:
|
||||
- `<slug>.html` — raw page HTML (only for successful 2xx responses)
|
||||
- `<slug>.meta.json` — metadata with original URL, final URL, status code, fetch time, and any error
|
||||
- `<slug>.recipe.json` — expected parsed Recipe model (Pydantic-serialized, by_alias=True). This is the canonical expectation file used by offline tests.
|
||||
|
||||
Notes:
|
||||
- Some domains may block requests (e.g., 403) or fail TLS verification. In those cases, only the `.meta.json` is written.
|
||||
- Keep `scripts/recipe_urls.py` curated to stable, public URLs to minimize churn.
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,91 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "Cheese Omelette",
|
||||
"link": "cheese-omelette",
|
||||
"serves": 1,
|
||||
"imageUrls": [
|
||||
"https://www.allrecipes.com/thmb/JS43mD2rA6_cCs9eTlXNGRHT5oQ=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/5143634-ac5ad80b28f44c53bd0fd6d570f61f0d.jpg"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "eggs",
|
||||
"line": "3 large eggs",
|
||||
"unit": "Items",
|
||||
"quantity": 3.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "milk",
|
||||
"line": "1 tablespoon milk, or as needed",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "salt and white pepper",
|
||||
"line": "salt and freshly ground white pepper to taste",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "freshly ground",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "butter",
|
||||
"line": "2 tablespoons butter",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": 4,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 4,
|
||||
"productId": "712251",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/712251/western-star-unsalted-butter-chef-s-choice",
|
||||
"name": "Western Star Unsalted Butter Chef's Choice",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/712251.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "Emmentaler cheese",
|
||||
"line": "0.25 cup shredded Emmentaler cheese",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.25,
|
||||
"preparation": "shredded",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:29.300361+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://cookieandkate.com/favorite-fried-eggs-recipe/",
|
||||
"fetched_at": "2025-11-04T07:23:56.579020+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://cookieandkate.com/favorite-fried-eggs-recipe/",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html; charset=UTF-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://cookieandkate.com/favorite-fried-eggs-recipe/",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "Favorite Fried Eggs",
|
||||
"link": "https://cookieandkate.com/favorite-fried-eggs-recipe/",
|
||||
"serves": 1,
|
||||
"imageUrls": [
|
||||
"https://cookieandkate.com/images/2018/09/crispy-fried-egg-recipe-225x225.jpg",
|
||||
"https://cookieandkate.com/images/2018/09/crispy-fried-egg-recipe-260x195.jpg",
|
||||
"https://cookieandkate.com/images/2018/09/crispy-fried-egg-recipe-320x180.jpg",
|
||||
"https://cookieandkate.com/images/2018/09/crispy-fried-egg-recipe.jpg"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "extra-virgin olive oil",
|
||||
"line": "1 tablespoon extra-virgin olive oil",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "egg",
|
||||
"line": "1 egg",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:29.367540+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://damndelicious.net/2025/08/01/corn-salsa/",
|
||||
"fetched_at": "2025-11-04T07:23:56.680123+00:00",
|
||||
"status": 403,
|
||||
"final_url": "https://damndelicious.net/2025/08/01/corn-salsa/amp",
|
||||
"error": null,
|
||||
"profile": "edge-desktop",
|
||||
"content_type": "text/html; charset=UTF-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "http://damndelicious.net/2025/08/01/corn-salsa/amp",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"Upgrade-Insecure-Requests\": \"1\", \"Sec-Fetch-Dest\": \"document\", \"Sec-Fetch-Mode\": \"navigate\", \"Sec-Fetch-Site\": \"none\", \"Sec-Fetch-User\": \"?1\", \"sec-ch-ua\": \"\\\"Chromium\\\";v=\\\"127\\\", \\\"Not=A?Brand\\\";v=\\\"24\\\", \\\"Microsoft Edge\\\";v=\\\"127\\\"\", \"sec-ch-ua-mobile\": \"?0\", \"sec-ch-ua-platform\": \"\\\"Linux\\\"\", \"User-Agent\": \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0\", \"Referer\": \"http://damndelicious.net/\"}"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://downshiftology.com/recipes/shakshuka/",
|
||||
"fetched_at": "2025-11-04T07:23:56.673481+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://downshiftology.com/recipes/shakshuka/",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html; charset=UTF-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://downshiftology.com/recipes/shakshuka/",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "Shakshuka Recipe (Easy & Traditional)",
|
||||
"link": "https://downshiftology.com/recipes/shakshuka/",
|
||||
"serves": 6,
|
||||
"imageUrls": [
|
||||
"https://i2.wp.com/www.downshiftology.com/wp-content/uploads/2023/12/Shakshuka-main-1.jpg",
|
||||
"https://downshiftology.com/wp-content/uploads/2023/12/Shakshuka-main-1-500x500.jpg",
|
||||
"https://downshiftology.com/wp-content/uploads/2023/12/Shakshuka-main-1-500x375.jpg",
|
||||
"https://downshiftology.com/wp-content/uploads/2023/12/Shakshuka-main-1-480x270.jpg"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "olive oil",
|
||||
"line": "2 tablespoons olive oil",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "onion",
|
||||
"line": "1 medium onion (diced)",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "(diced)",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "red bell pepper",
|
||||
"line": "1 red bell pepper (seeded and diced)",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "(seeded and diced)",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "garlic",
|
||||
"line": "4 garlic cloves (finely chopped)",
|
||||
"unit": "Items",
|
||||
"quantity": 4.0,
|
||||
"preparation": "(finely chopped)",
|
||||
"productId": 2,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 2,
|
||||
"productId": "294517",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/294517/la-famiglia-garlic-bread",
|
||||
"name": "La Famiglia Garlic Bread",
|
||||
"quantity": 1,
|
||||
"unit": "Loaf",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/294517.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/294517.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "paprika",
|
||||
"line": "2 teaspoon paprika",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "cumin",
|
||||
"line": "1 teaspoon cumin",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "chili powder",
|
||||
"line": "¼ teaspoon chili powder",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 0.25,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "whole peeled tomatoes",
|
||||
"line": "1 (28-ounce can) whole peeled tomatoes",
|
||||
"unit": "Ounce",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "eggs",
|
||||
"line": "6 large eggs",
|
||||
"unit": "Items",
|
||||
"quantity": 6.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "salt and pepper",
|
||||
"line": "salt and pepper (to taste)",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "fresh cilantro",
|
||||
"line": "1 small bunch fresh cilantro (chopped)",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "(chopped)",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "fresh parsley",
|
||||
"line": "1 small bunch fresh parsley (chopped)",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "(chopped)",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:29.453255+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://pinchofyum.com/lemon-chicken-piccata-with-grilled-bread",
|
||||
"fetched_at": "2025-11-04T07:23:56.582663+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://pinchofyum.com/lemon-chicken-piccata-with-grilled-bread",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html; charset=UTF-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://pinchofyum.com/lemon-chicken-piccata-with-grilled-bread",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "Lemon Chicken Piccata with Grilled Bread",
|
||||
"link": "https://pinchofyum.com/lemon-chicken-piccata-with-grilled-bread",
|
||||
"serves": 4,
|
||||
"imageUrls": [
|
||||
"https://pinchofyum.com/wp-content/uploads/Chicken-Piccata-Recipe-225x225.jpg",
|
||||
"https://pinchofyum.com/wp-content/uploads/Chicken-Piccata-Recipe-260x195.jpg",
|
||||
"https://pinchofyum.com/wp-content/uploads/Chicken-Piccata-Recipe-320x180.jpg",
|
||||
"https://pinchofyum.com/wp-content/uploads/Chicken-Piccata-Recipe.jpg"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "boneless skinless chicken breasts",
|
||||
"line": "1 pound boneless skinless chicken breasts",
|
||||
"unit": "Pound",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "flour",
|
||||
"line": "1/2 cup flour",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.5,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "salt and pepper",
|
||||
"line": "salt and pepper",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "LAND O LAKES® European Style Super Premium Salted Butter",
|
||||
"line": "4 tablespoons LAND O LAKES® European Style Super Premium Salted Butter",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 4.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "olive oil",
|
||||
"line": "2 tablespoons olive oil",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "white wine",
|
||||
"line": "1/2 cup white wine",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.5,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "chicken broth",
|
||||
"line": "1 1/2 cups chicken broth",
|
||||
"unit": "Cup",
|
||||
"quantity": 1.5,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "lemon",
|
||||
"line": "1 large lemon, sliced thinly (leave about 1/4 of the lemon intact for the juice)",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "sliced thinly",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "jarred capers",
|
||||
"line": "1/4 cup jarred capers",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.25,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "fresh parsley",
|
||||
"line": "fresh parsley",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:29.545344+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://sallysbakingaddiction.com/triple-chocolate-layer-cake/",
|
||||
"fetched_at": "2025-11-04T07:23:56.660551+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://sallysbakingaddiction.com/triple-chocolate-layer-cake/",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html; charset=UTF-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://sallysbakingaddiction.com/triple-chocolate-layer-cake/",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "Deliciously Moist Chocolate Layer Cake",
|
||||
"link": "https://sallysbakingaddiction.com/triple-chocolate-layer-cake/",
|
||||
"serves": 12,
|
||||
"imageUrls": [
|
||||
"https://sallysbakingaddiction.com/wp-content/uploads/2013/04/triple-chocolate-cake-4-225x225.jpg",
|
||||
"https://sallysbakingaddiction.com/wp-content/uploads/2013/04/triple-chocolate-cake-4-260x195.jpg",
|
||||
"https://sallysbakingaddiction.com/wp-content/uploads/2013/04/triple-chocolate-cake-4-320x180.jpg",
|
||||
"https://sallysbakingaddiction.com/wp-content/uploads/2013/04/triple-chocolate-cake-4.jpg"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "all-purpose flour",
|
||||
"line": "1 and 3/4 cups (219g) all-purpose flour (spooned & leveled)",
|
||||
"unit": "Cup",
|
||||
"quantity": 1.75,
|
||||
"preparation": "spooned &",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "unsweetened natural cocoa powder",
|
||||
"line": "3/4 cup (62g) unsweetened natural cocoa powder",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.75,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "granulated sugar",
|
||||
"line": "1 and 3/4 cups (350g) granulated sugar",
|
||||
"unit": "Cup",
|
||||
"quantity": 1.75,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "baking soda",
|
||||
"line": "2 teaspoons baking soda",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "baking powder",
|
||||
"line": "1 teaspoon baking powder",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "salt",
|
||||
"line": "1 teaspoon salt",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": 5,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 5,
|
||||
"productId": "33245",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/33245/saxa-iodised-table-salt-shaker",
|
||||
"name": "Saxa Iodised Table Salt Shaker",
|
||||
"quantity": 750,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/033245.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/033245.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "espresso powder",
|
||||
"line": "2 teaspoons espresso powder (optional)",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "vegetable oil",
|
||||
"line": "1/2 cup (120ml) vegetable oil (or canola oil or melted coconut oil)",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.5,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "eggs",
|
||||
"line": "2 large eggs, at room temperature",
|
||||
"unit": "Items",
|
||||
"quantity": 2.0,
|
||||
"preparation": "at room temperature",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "pure vanilla extract",
|
||||
"line": "2 teaspoons pure vanilla extract",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "buttermilk",
|
||||
"line": "1 cup (240ml) buttermilk, at room temperature",
|
||||
"unit": "Cup",
|
||||
"quantity": 1.0,
|
||||
"preparation": "at room temperature",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "strong hot coffee",
|
||||
"line": "1 cup (240ml) freshly brewed strong hot coffee (regular or decaf)",
|
||||
"unit": "Cup",
|
||||
"quantity": 1.0,
|
||||
"preparation": "freshly brewed",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "unsalted butter",
|
||||
"line": "1 and 1/4 cups (282g) unsalted butter, softened to room temperature",
|
||||
"unit": "Cup",
|
||||
"quantity": 1.25,
|
||||
"preparation": "softened to room temperature",
|
||||
"productId": 4,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 4,
|
||||
"productId": "712251",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/712251/western-star-unsalted-butter-chef-s-choice",
|
||||
"name": "Western Star Unsalted Butter Chef's Choice",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/712251.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "confectioners’ sugar",
|
||||
"line": "3 and 1/2 cups (420g) confectioners’ sugar",
|
||||
"unit": "Cup",
|
||||
"quantity": 3.5,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "unsweetened cocoa powder",
|
||||
"line": "3/4 cup (62g) unsweetened cocoa powder (natural or dutch process)",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.75,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "heavy cream",
|
||||
"line": "3-5 Tablespoons (45-75ml) heavy cream (or half-and-half or milk), at room temperature",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 3.0,
|
||||
"preparation": "at room temperature",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "salt",
|
||||
"line": "1/4 teaspoon salt",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 0.25,
|
||||
"preparation": "",
|
||||
"productId": 5,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 5,
|
||||
"productId": "33245",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/33245/saxa-iodised-table-salt-shaker",
|
||||
"name": "Saxa Iodised Table Salt Shaker",
|
||||
"quantity": 750,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/033245.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/033245.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "pure vanilla extract",
|
||||
"line": "1 teaspoon pure vanilla extract",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "semi-sweet chocolate chips",
|
||||
"line": "optional for decoration: semi-sweet chocolate chips",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:29.632664+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://tasty.co/recipe/one-pot-garlic-parmesan-pasta",
|
||||
"fetched_at": "2025-11-04T07:23:56.855490+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://tasty.co/recipe/one-pot-garlic-parmesan-pasta",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html; charset=utf-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://tasty.co/recipe/one-pot-garlic-parmesan-pasta",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "One-Pot Garlic Parmesan Pasta Recipe by Tasty",
|
||||
"link": "https://tasty.co/recipe/one-pot-garlic-parmesan-pasta",
|
||||
"serves": 4,
|
||||
"imageUrls": [
|
||||
"https://img.buzzfeed.com/thumbnailer-prod-us-east-1/f69a7f4192b94d8395757b365ac6d866/GarlicParmPasta.jpg?resize=1200:*"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "unsalted butter",
|
||||
"line": "2 tablespoons unsalted butter",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": 4,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 4,
|
||||
"productId": "712251",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/712251/western-star-unsalted-butter-chef-s-choice",
|
||||
"name": "Western Star Unsalted Butter Chef's Choice",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/712251.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "garlic",
|
||||
"line": "4 cloves garlic, minced",
|
||||
"unit": "Items",
|
||||
"quantity": 4.0,
|
||||
"preparation": "minced",
|
||||
"productId": 2,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 2,
|
||||
"productId": "294517",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/294517/la-famiglia-garlic-bread",
|
||||
"name": "La Famiglia Garlic Bread",
|
||||
"quantity": 1,
|
||||
"unit": "Loaf",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/294517.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/294517.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "chicken broth",
|
||||
"line": "2 cups chicken broth",
|
||||
"unit": "Cup",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "milk",
|
||||
"line": "1 cup milk",
|
||||
"unit": "Cup",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "fettuccine",
|
||||
"line": "8 oz fettuccine",
|
||||
"unit": "Ounce",
|
||||
"quantity": 8.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "salt",
|
||||
"line": "salt, to taste",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": 5,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 5,
|
||||
"productId": "33245",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/33245/saxa-iodised-table-salt-shaker",
|
||||
"name": "Saxa Iodised Table Salt Shaker",
|
||||
"quantity": 750,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/033245.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/033245.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "pepper",
|
||||
"line": "pepper, to taste",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": 6,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 6,
|
||||
"productId": "75194",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/75194/mckenzie-s-pepper-black-ground",
|
||||
"name": "Mckenzie's Pepper Black Ground",
|
||||
"quantity": 100,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/075194.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/075194.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "parmesan cheese",
|
||||
"line": "¼ cup grated parmesan cheese",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.25,
|
||||
"preparation": "grated",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "fresh parsley",
|
||||
"line": "2 tablespoons fresh parsley, chopped",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "chopped",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:29.662183+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://www.bbcgoodfood.com/recipes/chicken-tikka-masala",
|
||||
"fetched_at": "2025-11-04T07:23:56.582856+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://www.bbcgoodfood.com/recipes/chicken-tikka-masala",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html; charset=utf-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://www.bbcgoodfood.com/recipes/chicken-tikka-masala",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "Chicken tikka masala",
|
||||
"link": "https://www.bbcgoodfood.com/recipes/chicken-tikka-masala",
|
||||
"serves": 10,
|
||||
"imageUrls": [
|
||||
"https://images.immediate.co.uk/production/volatile/sites/30/2020/08/recipe-image-legacy-id-202451_12-50a0c95.jpg?resize=440,400"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "vegetable oil",
|
||||
"line": "4 tbsp vegetable oil",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 4.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "butter",
|
||||
"line": "25g butter",
|
||||
"unit": "Gram",
|
||||
"quantity": 25.0,
|
||||
"preparation": "",
|
||||
"productId": 4,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 4,
|
||||
"productId": "712251",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/712251/western-star-unsalted-butter-chef-s-choice",
|
||||
"name": "Western Star Unsalted Butter Chef's Choice",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/712251.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "onions",
|
||||
"line": "4 onions roughly chopped",
|
||||
"unit": "Items",
|
||||
"quantity": 4.0,
|
||||
"preparation": "roughly chopped",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "chicken tikka masala paste",
|
||||
"line": "6 tbsp chicken tikka masala paste (use shop-bought or make your own – see recipe, below)",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 6.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "red peppers",
|
||||
"line": "2 red peppers deseeded and cut into chunks",
|
||||
"unit": "Items",
|
||||
"quantity": 2.0,
|
||||
"preparation": "deseeded and cut into chunks",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "boneless, skinless chicken breasts",
|
||||
"line": "8 boneless, skinless chicken breasts cut into 2.5cm cubes",
|
||||
"unit": "Items",
|
||||
"quantity": 8.0,
|
||||
"preparation": "cut into 2.5 cm cubes",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "chopped tomatoes",
|
||||
"line": "2 x 400g cans chopped tomatoes",
|
||||
"unit": "Gram",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "tomato purée",
|
||||
"line": "4 tbsp tomato purée",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 4.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "mango chutney",
|
||||
"line": "2-3 tbsp mango chutney",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "double cream",
|
||||
"line": "150ml double cream",
|
||||
"unit": "Items",
|
||||
"quantity": 150.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "natural yogurt",
|
||||
"line": "150ml natural yogurt",
|
||||
"unit": "Items",
|
||||
"quantity": 150.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "coriander leaves",
|
||||
"line": "chopped coriander leaves, to serve",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "chopped",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:29.711728+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://www.inspiredtaste.net/24412/cocoa-brownies-recipe/",
|
||||
"fetched_at": "2025-11-04T07:23:56.582248+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://www.inspiredtaste.net/24412/cocoa-brownies-recipe/",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html; charset=UTF-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://www.inspiredtaste.net/24412/cocoa-brownies-recipe/",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
null
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://www.kingarthurbaking.com/recipes/banana-bread-recipe",
|
||||
"fetched_at": "2025-11-04T07:23:56.767749+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://www.kingarthurbaking.com/recipes/banana-bread-recipe",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html; charset=UTF-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://www.kingarthurbaking.com/recipes/banana-bread-recipe",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "Banana Bread",
|
||||
"link": "https://www.kingarthurbaking.com/recipes/banana-bread-recipe",
|
||||
"serves": 18,
|
||||
"imageUrls": [
|
||||
"https://www.kingarthurbaking.com/sites/default/files/recipe_legacy/5-3-large.jpg"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "unsalted butter",
|
||||
"line": "8 tablespoons (113g) unsalted butter, at cool room temperature",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 8.0,
|
||||
"preparation": "at cool room temperature",
|
||||
"productId": 4,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 4,
|
||||
"productId": "712251",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/712251/western-star-unsalted-butter-chef-s-choice",
|
||||
"name": "Western Star Unsalted Butter Chef's Choice",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/712251.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "light brown sugar or dark brown sugar",
|
||||
"line": "2/3 cup (142g) light brown sugar or dark brown sugar, packed",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.667,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "King Arthur Pure Vanilla Extract",
|
||||
"line": "1 teaspoon King Arthur Pure Vanilla Extract",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "cinnamon",
|
||||
"line": "1 teaspoon cinnamon",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "nutmeg",
|
||||
"line": "1/4 teaspoon nutmeg",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 0.25,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "baking soda",
|
||||
"line": "1 teaspoon baking soda",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "baking powder",
|
||||
"line": "1 teaspoon baking powder",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "table salt",
|
||||
"line": "1 teaspoon table salt",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "bananas",
|
||||
"line": "1 1/2 cups (340g) bananas, mashed",
|
||||
"unit": "Cup",
|
||||
"quantity": 1.5,
|
||||
"preparation": "mashed",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "apricot jam or orange marmalade",
|
||||
"line": "3 tablespoons (64g) apricot jam or orange marmalade, optional but tasty",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 3.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "honey",
|
||||
"line": "1/4 cup (85g) honey",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.25,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "eggs",
|
||||
"line": "2 large eggs",
|
||||
"unit": "Items",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "King Arthur Unbleached All-Purpose Flour",
|
||||
"line": "2 1/4 cups (270g) King Arthur Unbleached All-Purpose Flour",
|
||||
"unit": "Cup",
|
||||
"quantity": 2.25,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "walnuts",
|
||||
"line": "1/2 cup (57g) chopped walnuts, optional",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.5,
|
||||
"preparation": "chopped",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:29.834444+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://www.recipetineats.com/beef-stroganoff/",
|
||||
"fetched_at": "2025-11-04T07:23:56.628726+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://www.recipetineats.com/beef-stroganoff/",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html; charset=UTF-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://www.recipetineats.com/beef-stroganoff/",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "Beef Stroganoff",
|
||||
"link": "https://www.recipetineats.com/beef-stroganoff/",
|
||||
"serves": 4,
|
||||
"imageUrls": [
|
||||
"https://www.recipetineats.com/tachyon/2018/01/Beef-Stroganoff_2-1-1.jpg",
|
||||
"https://www.recipetineats.com/tachyon/2018/01/Beef-Stroganoff_2-1-1.jpg?resize=500%2C500",
|
||||
"https://www.recipetineats.com/tachyon/2018/01/Beef-Stroganoff_2-1-1.jpg?resize=500%2C375",
|
||||
"https://www.recipetineats.com/tachyon/2018/01/Beef-Stroganoff_2-1-1.jpg?resize=480%2C270"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "scotch fillet steak / boneless rib eye",
|
||||
"line": "600 g / 1.2 lb scotch fillet steak / boneless rib eye ((Note 1))",
|
||||
"unit": "Gram",
|
||||
"quantity": 600.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "vegetable oil",
|
||||
"line": "2 tbsp vegetable oil (, divided)",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "(, divided)",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "onion",
|
||||
"line": "1 large onion ((or 2 small onions), sliced)",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "sliced",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "mushrooms",
|
||||
"line": "300 g / 10 oz mushrooms (, sliced (not too thin))",
|
||||
"unit": "Gram",
|
||||
"quantity": 300.0,
|
||||
"preparation": ", sliced",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "butter",
|
||||
"line": "40 g / 3 tbsp butter",
|
||||
"unit": "Gram",
|
||||
"quantity": 40.0,
|
||||
"preparation": "",
|
||||
"productId": 4,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 4,
|
||||
"productId": "712251",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/712251/western-star-unsalted-butter-chef-s-choice",
|
||||
"name": "Western Star Unsalted Butter Chef's Choice",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/712251.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "flour",
|
||||
"line": "2 tbsp flour ((Note 2))",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "beef broth",
|
||||
"line": "2 cups / 500 ml beef broth (, preferably salt reduced)",
|
||||
"unit": "Cup",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "Dijon mustard",
|
||||
"line": "1 tbsp Dijon mustard",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "sour cream",
|
||||
"line": "150 ml / 2/3 cup sour cream",
|
||||
"unit": "Cup",
|
||||
"quantity": 150.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "Salt and pepper",
|
||||
"line": "Salt and pepper",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "pasta or egg noodles of choice",
|
||||
"line": "250 - 300 g / 8 - 10 oz pasta or egg noodles of choice ((Note 3))",
|
||||
"unit": "Gram",
|
||||
"quantity": 250.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "chives",
|
||||
"line": "Chopped chives (, for garnish (optional))",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "Chopped",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:29.900537+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://www.simplyrecipes.com/recipes/french_toast/",
|
||||
"fetched_at": "2025-11-04T07:23:56.582449+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://www.simplyrecipes.com/recipes/french_toast/",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html;charset=utf-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://www.simplyrecipes.com/recipes/french_toast/",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "The Best French Toast",
|
||||
"link": "https://www.simplyrecipes.com/recipes/french_toast/",
|
||||
"serves": 4,
|
||||
"imageUrls": [
|
||||
"https://www.simplyrecipes.com/thmb/34kTh59L8NjsXOB8nqw3hdYIKbs=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/Simply-Recipes-Best-French-Toast-LEAD-4-ce3d4ce3d69b4c79a7bb3d9b83c8c3fc.jpg"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "eggs",
|
||||
"line": "4 eggs",
|
||||
"unit": "Items",
|
||||
"quantity": 4.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "milk",
|
||||
"line": "2/3 cup milk",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.667,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "cinnamon",
|
||||
"line": "2 teaspoons cinnamon",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "2-day-old bread",
|
||||
"line": "8 thick slices 2-day-old bread (better if slightly stale)",
|
||||
"unit": "Items",
|
||||
"quantity": 8.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "Butter",
|
||||
"line": "Butter (can sub vegetable oil)",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": 4,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 4,
|
||||
"productId": "712251",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/712251/western-star-unsalted-butter-chef-s-choice",
|
||||
"name": "Western Star Unsalted Butter Chef's Choice",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/712251.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "Maple syrup",
|
||||
"line": "Maple syrup",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "orange zest",
|
||||
"line": "2 teaspoons freshly grated orange zest",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "freshly grated",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "Triple Sec",
|
||||
"line": "1/4 cup Triple Sec",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.25,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "Fresh berries",
|
||||
"line": "Fresh berries",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:29.962968+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://www.simplyrecipes.com/recipes/perfect_guacamole/",
|
||||
"fetched_at": "2025-11-04T07:23:56.744212+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://www.simplyrecipes.com/recipes/perfect_guacamole/",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html;charset=utf-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://www.simplyrecipes.com/recipes/perfect_guacamole/",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "How to Make the Best Guacamole",
|
||||
"link": "https://www.simplyrecipes.com/recipes/perfect_guacamole/",
|
||||
"serves": 4,
|
||||
"imageUrls": [
|
||||
"https://www.simplyrecipes.com/thmb/J4kA2m6jKMgkQwZhG-RYpjZBeFQ=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/Guacamole-LEAD-6-2-64cfcca253c8421dad4e3fad830219f6.jpg"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "ripe avocados",
|
||||
"line": "2 ripe avocados",
|
||||
"unit": "Items",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "salt",
|
||||
"line": "1/4 teaspoon salt, plus more to taste",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 0.25,
|
||||
"preparation": "",
|
||||
"productId": 5,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 5,
|
||||
"productId": "33245",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/33245/saxa-iodised-table-salt-shaker",
|
||||
"name": "Saxa Iodised Table Salt Shaker",
|
||||
"quantity": 750,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/033245.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/033245.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "fresh lime or lemon juice",
|
||||
"line": "1 tablespoon fresh lime or lemon juice",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "red onion or thinly sliced green onion",
|
||||
"line": "2-4 tablespoons minced red onion or thinly sliced green onion",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "minced",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "serrano (or jalapeño) chiles",
|
||||
"line": "1-2 serrano (or jalapeño) chiles, stems and seeds removed, minced",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "stems and seeds removed, minced",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "cilantro",
|
||||
"line": "2 tablespoons cilantro (leaves and tender stems), finely chopped",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "finely chopped",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "black pepper",
|
||||
"line": "Pinch freshly ground black pepper",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "freshly ground",
|
||||
"productId": 6,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 6,
|
||||
"productId": "75194",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/75194/mckenzie-s-pepper-black-ground",
|
||||
"name": "Mckenzie's Pepper Black Ground",
|
||||
"quantity": 100,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/075194.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/075194.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "ripe tomato",
|
||||
"line": "1/2 ripe tomato, chopped (optional)",
|
||||
"unit": "Items",
|
||||
"quantity": 0.5,
|
||||
"preparation": "chopped",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "Red radish or jicama slices",
|
||||
"line": "Red radish or jicama slices for garnish (optional)",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "Tortilla chips",
|
||||
"line": "Tortilla chips , to serve",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:30.070234+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://www.taste.com.au/recipes/classic-chewy-brownie/f0960a83-1fa9-4e3e-b616-a995182613e0",
|
||||
"fetched_at": "2025-11-04T07:23:56.581964+00:00",
|
||||
"status": 403,
|
||||
"final_url": "http://www.taste.com.au/recipes/classic-chewy-brownie/f0960a83-1fa9-4e3e-b616-a995182613e0/amp",
|
||||
"error": "SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1016)",
|
||||
"profile": "edge-desktop",
|
||||
"content_type": "text/html",
|
||||
"content_encoding": null,
|
||||
"candidate": "http://www.taste.com.au/recipes/classic-chewy-brownie/f0960a83-1fa9-4e3e-b616-a995182613e0/amp",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"url": "https://www.thekitchn.com/chicken-tamale-pie-23752740",
|
||||
"fetched_at": "2025-11-04T07:23:56.686401+00:00",
|
||||
"status": 200,
|
||||
"final_url": "https://www.thekitchn.com/chicken-tamale-pie-23752740",
|
||||
"error": null,
|
||||
"profile": "automation",
|
||||
"content_type": "text/html; charset=utf-8",
|
||||
"content_encoding": "gzip",
|
||||
"candidate": "https://www.thekitchn.com/chicken-tamale-pie-23752740",
|
||||
"request_headers": "{\"Accept\": \"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8\", \"Accept-Language\": \"en-US,en;q=0.8\", \"Accept-Encoding\": \"gzip, deflate\", \"Connection\": \"keep-alive\", \"User-Agent\": \"MunchEaseRecipeBot/1.0 (+https://example.com/bot)\", \"From\": \"bot@example.com\"}"
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
{
|
||||
"id": -1,
|
||||
"name": "Cheesy Chicken Tamale Pie",
|
||||
"link": "https://www.thekitchn.com/chicken-tamale-pie-23752740",
|
||||
"serves": 6,
|
||||
"imageUrls": [
|
||||
"https://cdn.apartmenttherapy.info/image/upload/f_jpg,q_auto:eco,c_fill,g_auto,w_1500,ar_16:9/tk%2Fphoto%2F2025%2F10-2025%2F2025-10-chicken-tamale-pie%2Fchicken-tamale-pie-0",
|
||||
"https://cdn.apartmenttherapy.info/image/upload/f_jpg,q_auto:eco,c_fill,g_auto,w_1500,ar_4:3/tk%2Fphoto%2F2025%2F10-2025%2F2025-10-chicken-tamale-pie%2Fchicken-tamale-pie-0",
|
||||
"https://cdn.apartmenttherapy.info/image/upload/f_jpg,q_auto:eco,c_fill,g_auto,w_1500,ar_1:1/tk%2Fphoto%2F2025%2F10-2025%2F2025-10-chicken-tamale-pie%2Fchicken-tamale-pie-0"
|
||||
],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "unsalted butter",
|
||||
"line": "3 tablespoons unsalted butter, divided",
|
||||
"unit": "Tablespoon",
|
||||
"quantity": 3.0,
|
||||
"preparation": "divided",
|
||||
"productId": 4,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 4,
|
||||
"productId": "712251",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/712251/western-star-unsalted-butter-chef-s-choice",
|
||||
"name": "Western Star Unsalted Butter Chef's Choice",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/712251.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "corn muffin mix",
|
||||
"line": "1 (8.5-ounce) box corn muffin mix, such as Jiffy",
|
||||
"unit": "Ounce",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "egg",
|
||||
"line": "1 large egg",
|
||||
"unit": "Items",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "sour cream",
|
||||
"line": "1/2 cup sour cream, plus more for serving",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.5,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "scallions",
|
||||
"line": "4 medium scallions, thinly sliced (about 1/2 cup), plus more for garnish",
|
||||
"unit": "Cup",
|
||||
"quantity": 4.0,
|
||||
"preparation": "thinly sliced",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "garlic",
|
||||
"line": "2 cloves garlic, minced",
|
||||
"unit": "Items",
|
||||
"quantity": 2.0,
|
||||
"preparation": "minced",
|
||||
"productId": 2,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": {
|
||||
"id": 2,
|
||||
"productId": "294517",
|
||||
"shopCode": "woolworths",
|
||||
"link": "https://www.woolworths.com.au/shop/productdetails/294517/la-famiglia-garlic-bread",
|
||||
"name": "La Famiglia Garlic Bread",
|
||||
"quantity": 1,
|
||||
"unit": "Loaf",
|
||||
"imgSmall": "https://cdn0.woolworths.media/content/wowproductimages/small/294517.jpg",
|
||||
"imgLarge": "https://cdn0.woolworths.media/content/wowproductimages/large/294517.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "chili powder",
|
||||
"line": "2 teaspoons chili powder",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 2.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "ground cumin",
|
||||
"line": "1/2 teaspoon ground cumin",
|
||||
"unit": "Teaspoon",
|
||||
"quantity": 0.5,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "chicken",
|
||||
"line": "3 cups shredded, cooked chicken (from 1/2 rotisserie chicken, about 10 ounces)",
|
||||
"unit": "Cup",
|
||||
"quantity": 3.0,
|
||||
"preparation": "shredded, cooked",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "red enchilada sauce",
|
||||
"line": "1 (10-ounce) can red enchilada sauce, or 1 1/4 cups homemade enchilada sauce",
|
||||
"unit": "Ounce",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "frozen corn kernels",
|
||||
"line": "1 cup frozen corn kernels, preferably fire-roasted (do not thaw)",
|
||||
"unit": "Cup",
|
||||
"quantity": 1.0,
|
||||
"preparation": "",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "pickled jalapeños",
|
||||
"line": "1/2 cup drained sliced pickled jalapeños, coarsely chopped, plus more for serving",
|
||||
"unit": "Cup",
|
||||
"quantity": 0.5,
|
||||
"preparation": "drained sliced, coarsely chopped",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "sharp cheddar cheese",
|
||||
"line": "4 ounces sharp cheddar cheese, shredded (about 1 cup)",
|
||||
"unit": "Ounce",
|
||||
"quantity": 4.0,
|
||||
"preparation": "shredded",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"name": "Monterey Jack cheese",
|
||||
"line": "4 ounces shredded Monterey Jack cheese, shredded (about 1 cup)",
|
||||
"unit": "Ounce",
|
||||
"quantity": 4.0,
|
||||
"preparation": "shredded",
|
||||
"productId": -1,
|
||||
"recipeId": null,
|
||||
"mealId": null,
|
||||
"product": null
|
||||
}
|
||||
],
|
||||
"basedOnRecipe": null,
|
||||
"dateCreated": "2025-11-04T18:24:30.117818+11:00",
|
||||
"createdById": 1,
|
||||
"createdBy": {
|
||||
"id": 1,
|
||||
"displayName": "Snapshot Generator"
|
||||
},
|
||||
"dateHidden": null,
|
||||
"hiddenById": null,
|
||||
"hiddenBy": null
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestAuthAndHouseholdsV2(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_register_and_login_and_households(self):
|
||||
# Register a user
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "test@example.com", "password": "pw", "displayName": "Test"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
token = body["accessToken"]
|
||||
# Expect a JWT (three segments separated by '.')
|
||||
assert token.count(".") == 2
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
# List households (migration created default household 'default' and membership set to admin)
|
||||
r = self.client.get("/api/v1/users/me/households", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Create a new household
|
||||
r = self.client.post("/api/v1/households", headers=headers, json={"name": "Family"})
|
||||
assert r.status_code == 200, r.text
|
||||
created = r.json()
|
||||
assert created["slug"].startswith("family")
|
||||
|
||||
def test_refresh_flow(self):
|
||||
# Register to set refresh cookie
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "refresh@test.com", "password": "pw", "displayName": "Ref"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
# Call refresh endpoint; cookie should be sent automatically by TestClient
|
||||
r2 = self.client.post("/api/v1/auth/refresh")
|
||||
assert r2.status_code == 200, r2.text
|
||||
new_access = r2.json()["accessToken"]
|
||||
assert new_access.count(".") == 2
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
from api.dtos import MemberRef
|
||||
import persons
|
||||
|
||||
|
||||
class MemberRefs:
|
||||
# Fixture MemberRef objects (id/display_name). Backcompat: .name property returns display_name.
|
||||
jacob = MemberRef(id=1, display_name="Jacob")
|
||||
class Persons:
|
||||
jacob = persons.Person(id=1, name="Jacob")
|
||||
|
||||
ryan = MemberRef(id=2, display_name="Ryan")
|
||||
ryan = persons.Person(id=2, name="Ryan")
|
||||
|
||||
ellie = MemberRef(id=3, display_name="Ellie")
|
||||
ellie = persons.Person(id=3, name="Ellie")
|
||||
|
||||
chris = MemberRef(id=4, display_name="Chris")
|
||||
chris = persons.Person(id=4, name="Chris")
|
||||
|
||||
|
||||
import products
|
||||
|
|
@ -26,6 +25,7 @@ class Products:
|
|||
link="https://www.woolworths.com.au/shop/productdetails/134681/fresh-broccoli",
|
||||
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/134681.jpg",
|
||||
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/134681.jpg",
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
garlic_bread = products.Product(
|
||||
|
|
@ -38,6 +38,7 @@ class Products:
|
|||
link="https://www.woolworths.com.au/shop/productdetails/294517/la-famiglia-garlic-bread",
|
||||
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/294517.jpg",
|
||||
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/294517.jpg",
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
beans_round = products.Product(
|
||||
|
|
@ -50,6 +51,7 @@ class Products:
|
|||
link="https://www.woolworths.com.au/shop/productdetails/134072/beans-round",
|
||||
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/134072.jpg",
|
||||
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/134072.jpg",
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
western_star_unsalted_butter_chefs_choice = products.Product(
|
||||
|
|
@ -62,6 +64,7 @@ class Products:
|
|||
link="https://www.woolworths.com.au/shop/productdetails/712251/western-star-unsalted-butter-chef-s-choice",
|
||||
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/712251.jpg",
|
||||
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg",
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
saxa_iodised_table_salt_shaker = products.Product(
|
||||
|
|
@ -74,6 +77,7 @@ class Products:
|
|||
link="https://www.woolworths.com.au/shop/productdetails/33245/saxa-iodised-table-salt-shaker",
|
||||
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/033245.jpg",
|
||||
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/033245.jpg",
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
mckenzies_pepper_black_ground = products.Product(
|
||||
|
|
@ -86,6 +90,7 @@ class Products:
|
|||
link="https://www.woolworths.com.au/shop/productdetails/75194/mckenzie-s-pepper-black-ground",
|
||||
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/075194.jpg",
|
||||
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/075194.jpg",
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
apple = products.Product(
|
||||
|
|
@ -98,6 +103,7 @@ class Products:
|
|||
link="https://www.woolworths.com.au/shop/productdetails/0/apple",
|
||||
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/0.jpg",
|
||||
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/0.jpg",
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
banana = products.Product(
|
||||
|
|
@ -110,6 +116,7 @@ class Products:
|
|||
link="https://www.woolworths.com.au/shop/productdetails/0/banana",
|
||||
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/0.jpg",
|
||||
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/0.jpg",
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
_tags = {
|
||||
|
|
@ -142,7 +149,7 @@ class Ingredients:
|
|||
line="1 Apple",
|
||||
name="Apple",
|
||||
unit="Items",
|
||||
quantity=1.0,
|
||||
quantity="1",
|
||||
preparation="",
|
||||
product=Products.apple,
|
||||
)
|
||||
|
|
@ -152,7 +159,7 @@ class Ingredients:
|
|||
line="1kg Broccoli, Chopped",
|
||||
name="Broccoli",
|
||||
unit="kg",
|
||||
quantity=1.0,
|
||||
quantity="1",
|
||||
preparation="Chopped",
|
||||
product=Products.broccoli,
|
||||
)
|
||||
|
|
@ -162,7 +169,7 @@ class Ingredients:
|
|||
line="1 Loaf Garlic Bread",
|
||||
name="Garlic Bread",
|
||||
unit="Loaf",
|
||||
quantity=1.0,
|
||||
quantity="1",
|
||||
preparation="",
|
||||
product=Products.garlic_bread,
|
||||
)
|
||||
|
|
@ -221,7 +228,7 @@ class Recipes:
|
|||
"https://www.bbcgoodfood.com/sites/default/files/styles/recipe/public/recipe/recipe-image/2018/10/broccoli-soup.jpg"
|
||||
],
|
||||
ingredients=[Ingredients.broccoli_chopped_1kg],
|
||||
created_by_id=MemberRefs.jacob.id,
|
||||
created_by_id=Persons.jacob.id,
|
||||
)
|
||||
|
||||
how_to_steam_green_beans = recipes.Recipe(
|
||||
|
|
@ -238,7 +245,7 @@ class Recipes:
|
|||
Ingredients.salt,
|
||||
Ingredients.freshly_ground_black_pepper,
|
||||
],
|
||||
created_by_id=MemberRefs.jacob.id,
|
||||
created_by_id=Persons.jacob.id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -249,13 +256,12 @@ from datetime import datetime
|
|||
class Meals:
|
||||
broccoli_soup_for_jacob = meals_db.Meal(
|
||||
id=0,
|
||||
create_date=datetime(2021, 12, 25),
|
||||
created_by=Persons.jacob,
|
||||
suggested_date=datetime(2021, 12, 25),
|
||||
chefs=[MemberRef(id=MemberRefs.jacob.id, display_name=MemberRefs.jacob.name)],
|
||||
cleanup=[MemberRef(id=MemberRefs.ryan.id, display_name=MemberRefs.ryan.name)],
|
||||
consumers=[
|
||||
MemberRef(id=MemberRefs.ellie.id, display_name=MemberRefs.ellie.name),
|
||||
MemberRef(id=MemberRefs.chris.id, display_name=MemberRefs.chris.name),
|
||||
],
|
||||
chefs=[Persons.jacob],
|
||||
cleanup=[Persons.ryan],
|
||||
consumers=[Persons.ellie, Persons.chris],
|
||||
recipes=[
|
||||
meals_db.MealRecipe(meal_id=-1, recipe_id=-1, servings=2, recipe=Recipes.broccoli_soup)
|
||||
],
|
||||
|
|
@ -268,21 +274,8 @@ def class_fields(obj):
|
|||
|
||||
|
||||
async def create_persons(conn):
|
||||
# Seed explicit User rows for fixture MemberRefs (ids 1..4) without any legacy Person references
|
||||
try:
|
||||
from users.repository import insert_user_with_id
|
||||
|
||||
users = [
|
||||
(MemberRefs.jacob.id, MemberRefs.jacob.display_name),
|
||||
(MemberRefs.ryan.id, MemberRefs.ryan.display_name),
|
||||
(MemberRefs.ellie.id, MemberRefs.ellie.display_name),
|
||||
(MemberRefs.chris.id, MemberRefs.chris.display_name),
|
||||
]
|
||||
for uid, name in users:
|
||||
await insert_user_with_id(conn, uid, f"{name.lower()}@example.com", name)
|
||||
except Exception:
|
||||
# If users repo/tables aren't available in some minimal contexts, ignore
|
||||
pass
|
||||
for person in class_fields(Persons).values():
|
||||
await persons.insert_person(conn, person)
|
||||
|
||||
|
||||
async def create_test_data(conn):
|
||||
|
|
@ -306,7 +299,7 @@ async def create_test_data(conn):
|
|||
"""
|
||||
import re
|
||||
def to_name(thing):
|
||||
return re.sub(r"\\W", "", thing["name"].lower().replace(" ", "_"))
|
||||
return re.sub(r'\W', '', thing['name'].lower().replace(' ', '_'))
|
||||
|
||||
products_order= ['id', 'name', 'product_id', 'link', 'tags', 'img_small', 'img_large', 'raw_data']
|
||||
ingredients_order = 'id line name unit quantity preparation product'.split(' ')
|
||||
|
|
|
|||
|
|
@ -28,17 +28,12 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
|
||||
# Always act as an authenticated user for tests that require auth
|
||||
async def override_cookie_person():
|
||||
return test_data.Persons.jacob
|
||||
|
||||
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||
self.client = TestClient(main.app)
|
||||
# Register user and create a household
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "loc@test.com", "password": "pw", "displayName": "Loc"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
self.headers = {"Authorization": f"Bearer {r.json()['accessToken']}"}
|
||||
r2 = self.client.post("/api/v1/households", headers=self.headers, json={"name": "Locals"})
|
||||
assert r2.status_code == 200, r2.text
|
||||
self.slug = r2.json()["slug"]
|
||||
return await super().asyncSetUp()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
|
|
@ -52,8 +47,8 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
|
|||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
def test_location_headers_on_create(self):
|
||||
# Use the registered user id placeholder for v2 meal participants
|
||||
person = {"id": 1, "displayName": "Loc"}
|
||||
# Use an existing seeded person from test data (avoids cross-request transaction issues)
|
||||
person_id = test_data.Persons.jacob.id
|
||||
|
||||
# Skip recipe endpoint complexity here; covered by other tests
|
||||
|
||||
|
|
@ -61,9 +56,9 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
|
|||
meal_body = {
|
||||
"id": -1,
|
||||
"suggestedDate": "2024-06-01T18:00:00+00:00",
|
||||
"chefs": [person],
|
||||
"cleanup": [person],
|
||||
"consumers": [person],
|
||||
"chefs": [{"id": person_id, "name": "Jacob"}],
|
||||
"cleanup": [{"id": person_id, "name": "Jacob"}],
|
||||
"consumers": [{"id": person_id, "name": "Jacob"}],
|
||||
"recipes": [],
|
||||
"extraIngredients": [
|
||||
{
|
||||
|
|
@ -76,8 +71,6 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
|
|||
}
|
||||
],
|
||||
}
|
||||
resp_meal = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=meal_body
|
||||
)
|
||||
resp_meal = self.client.post("/api/v1/meals", json=meal_body)
|
||||
assert resp_meal.status_code == 200
|
||||
assert "Location" in resp_meal.headers
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestHouseholdMembersV2(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
# Register user and create a household
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "members@test.com", "password": "pw", "displayName": "Member User"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
token = r.json()["accessToken"]
|
||||
self.headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "My Fam"})
|
||||
assert r.status_code == 200, r.text
|
||||
self.slug = r.json()["slug"]
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_list_members_returns_creator_admin(self):
|
||||
r = self.client.get(f"/api/v1/households/{self.slug}/members", headers=self.headers)
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()
|
||||
assert isinstance(items, list)
|
||||
assert len(items) == 1
|
||||
assert items[0]["displayName"] == "Member User"
|
||||
assert items[0]["role"] == "admin"
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestHouseholdScoping(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
# Register a user and capture token
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "h@test.com", "password": "pw", "displayName": "H"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
self.token = r.json()["accessToken"]
|
||||
self.headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_scoped_whoami_forbidden_on_default_when_not_member(self):
|
||||
r = self.client.get("/api/v1/households/default/whoami", headers=self.headers)
|
||||
assert r.status_code in (403, 404) # may be 404 if default household missing
|
||||
|
||||
def test_scoped_whoami_ok_after_creating_household(self):
|
||||
r = self.client.post("/api/v1/households", json={"name": "Family"}, headers=self.headers)
|
||||
assert r.status_code == 200, r.text
|
||||
slug = r.json()["slug"]
|
||||
r = self.client.get(f"/api/v1/households/{slug}/whoami", headers=self.headers)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["householdSlug"] == slug
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestIngredientsParseApiV2(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
# Register user and create household
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "ing@test.com", "password": "pw", "displayName": "Ing"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
token = r.json()["accessToken"]
|
||||
self.headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
r2 = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H"})
|
||||
assert r2.status_code == 200, r2.text
|
||||
self.slug = r2.json()["slug"]
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_parse_multiple_ingredients(self):
|
||||
lines = [
|
||||
"14oz milk powder",
|
||||
"2 cups flour",
|
||||
"1 tsp salt",
|
||||
"egg", # defaults to 1 Items
|
||||
]
|
||||
parsed = []
|
||||
for line in lines:
|
||||
r = self.client.get(
|
||||
f"/api/v1/households/{self.slug}/ingredients/parse",
|
||||
params={"line": line},
|
||||
headers=self.headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
parsed.append(r.json())
|
||||
|
||||
# Basic shape checks
|
||||
for item in parsed:
|
||||
assert "name" in item and isinstance(item["name"], str)
|
||||
assert "line" in item and isinstance(item["line"], str)
|
||||
assert "quantity" in item
|
||||
assert "unit" in item and isinstance(item["unit"], str)
|
||||
# Quantity should be a positive number
|
||||
assert float(item["quantity"]) > 0
|
||||
# The original sentence should round-trip into line
|
||||
assert len(item["line"]) >= len(item["name"]) >= 1
|
||||
|
||||
# Spot checks for unit/quantity normalization
|
||||
# 14oz milk powder
|
||||
oz, cups, tsp, egg = parsed
|
||||
assert float(oz["quantity"]) == 14.0
|
||||
assert oz["unit"] == "Ounce"
|
||||
assert "milk" in oz["name"].lower()
|
||||
|
||||
assert float(cups["quantity"]) == 2.0
|
||||
assert cups["unit"] == "Cup"
|
||||
assert cups["name"].lower() == "flour"
|
||||
|
||||
assert float(tsp["quantity"]) == 1.0
|
||||
assert tsp["unit"] == "Teaspoon"
|
||||
assert tsp["name"].lower() == "salt"
|
||||
|
||||
assert float(egg["quantity"]) == 1.0
|
||||
assert egg["unit"] == "Items"
|
||||
assert egg["name"].lower() == "egg"
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestIngredientsParseMultipleV2(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
# Register user and create household
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "ing2@test.com", "password": "pw", "displayName": "Ing2"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
token = r.json()["accessToken"]
|
||||
self.headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
r2 = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H2"})
|
||||
assert r2.status_code == 200, r2.text
|
||||
self.slug = r2.json()["slug"]
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_parse_multiple_lines_with_lines_param(self):
|
||||
lines = [
|
||||
"14oz milk powder",
|
||||
"2 cups flour",
|
||||
"1 tsp salt",
|
||||
"egg",
|
||||
]
|
||||
r = self.client.get(
|
||||
f"/api/v1/households/{self.slug}/ingredients/parse",
|
||||
params=[("lines", line) for line in lines],
|
||||
headers=self.headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()
|
||||
assert isinstance(items, list)
|
||||
assert len(items) == 4
|
||||
# spot check
|
||||
oz, cups, tsp, egg = items
|
||||
assert float(oz["quantity"]) == 14.0 and oz["unit"] == "Ounce"
|
||||
assert cups["name"].lower() == "flour"
|
||||
assert tsp["unit"] == "Teaspoon"
|
||||
assert egg["name"].lower() == "egg"
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestInvitationsV2(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
# Register inviter
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "owner@test.com", "password": "pw", "displayName": "Owner"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
self.owner_headers = {"Authorization": f"Bearer {r.json()['accessToken']}"}
|
||||
|
||||
# Create household
|
||||
r = self.client.post(
|
||||
"/api/v1/households", headers=self.owner_headers, json={"name": "Team"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
self.slug = r.json()["slug"]
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_create_invitation_and_accept(self):
|
||||
# Create invitation
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/invitations",
|
||||
headers=self.owner_headers,
|
||||
json={"email": "invitee@test.com"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
invite_link = body.get("invite_link")
|
||||
assert isinstance(invite_link, str) and "/invitations/accept?token=" in invite_link
|
||||
|
||||
# Extract token from invite_link
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
qs = parse_qs(urlparse(invite_link).query)
|
||||
token_list = qs.get("token", [])
|
||||
assert token_list and isinstance(token_list[0], str)
|
||||
token = token_list[0]
|
||||
assert isinstance(token, str) and len(token) >= 16
|
||||
|
||||
# Register invitee
|
||||
r2 = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "invitee@test.com", "password": "pw", "displayName": "Invitee"},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
invitee_headers = {"Authorization": f"Bearer {r2.json()['accessToken']}"}
|
||||
|
||||
# Accept invitation
|
||||
r3 = self.client.post(
|
||||
"/api/v1/invitations/accept", headers=invitee_headers, json={"token": token}
|
||||
)
|
||||
assert r3.status_code == 200, r3.text
|
||||
body = r3.json()
|
||||
assert body["status"] == "accepted"
|
||||
|
||||
# Invitee should now see household in their list
|
||||
r4 = self.client.get("/api/v1/users/me/households", headers=invitee_headers)
|
||||
assert r4.status_code == 200, r4.text
|
||||
households = r4.json()
|
||||
assert any(h["slug"] == self.slug for h in households)
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestLogoutV2(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_logout_clears_refresh_cookie(self):
|
||||
# Register to receive refresh cookie and access token
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "logout@test.com", "password": "pw", "displayName": "User"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Refresh should succeed (cookie automatically sent by TestClient)
|
||||
r2 = self.client.post("/api/v1/auth/refresh")
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert "accessToken" in r2.json()
|
||||
|
||||
# Call logout to clear refresh cookie
|
||||
r3 = self.client.post("/api/v1/auth/logout")
|
||||
assert r3.status_code == 200, r3.text
|
||||
|
||||
# Subsequent refresh should fail with 401 (cookie cleared)
|
||||
r4 = self.client.post("/api/v1/auth/refresh")
|
||||
assert r4.status_code == 401, r4.text
|
||||
1140
tests/test_main.py
Normal file
1140
tests/test_main.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,7 @@ from db import connect, create
|
|||
import meals
|
||||
import meals.repository as meals_db
|
||||
from meals.models import Meal, MealRecipe
|
||||
import persons
|
||||
import recipes
|
||||
import ingredients
|
||||
import products
|
||||
|
|
@ -38,9 +39,9 @@ class TestMealsModels(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test basic Meal creation"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 1, 1, 18, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie, test_data.MemberRefs.chris],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie, test_data.Persons.chris],
|
||||
)
|
||||
|
||||
self.assertEqual(meal.id, -1) # Default ID
|
||||
|
|
@ -80,9 +81,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test inserting a basic meal with participants"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 1, 15, 19, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -111,9 +112,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 2, 1, 18, 30),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie, test_data.MemberRefs.chris],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie, test_data.Persons.chris],
|
||||
recipes=[meal_recipe],
|
||||
)
|
||||
|
||||
|
|
@ -160,9 +161,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 3, 1, 19, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
extra_ingredients=[extra_ingredient],
|
||||
)
|
||||
|
||||
|
|
@ -186,9 +187,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
# Create and insert initial meal
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 4, 1, 18, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -196,12 +197,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
# Update the meal
|
||||
meal.suggested_date = datetime(2024, 4, 2, 19, 0)
|
||||
meal.chefs = [test_data.MemberRefs.ryan] # Change chef
|
||||
meal.cleanup = [test_data.MemberRefs.ellie] # Change cleanup
|
||||
meal.consumers = [
|
||||
test_data.MemberRefs.jacob,
|
||||
test_data.MemberRefs.chris,
|
||||
] # Change consumers
|
||||
meal.chefs = [test_data.Persons.ryan] # Change chef
|
||||
meal.cleanup = [test_data.Persons.ellie] # Change cleanup
|
||||
meal.consumers = [test_data.Persons.jacob, test_data.Persons.chris] # Change consumers
|
||||
|
||||
await meals_db.update_meal(self.conn, meal)
|
||||
|
||||
|
|
@ -221,9 +219,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test marking a meal as consumed"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 5, 1, 18, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -243,9 +241,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test marking a meal as purchased"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 6, 1, 18, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -265,9 +263,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test soft deleting a meal"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 7, 1, 18, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -297,32 +295,32 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
# Create several meals with different dates
|
||||
meal1 = Meal(
|
||||
suggested_date=datetime(2024, 8, 1, 18, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
meal2 = Meal(
|
||||
suggested_date=datetime(2024, 8, 15, 18, 0),
|
||||
chefs=[test_data.MemberRefs.ryan],
|
||||
cleanup=[test_data.MemberRefs.jacob],
|
||||
consumers=[test_data.MemberRefs.chris],
|
||||
chefs=[test_data.Persons.ryan],
|
||||
cleanup=[test_data.Persons.jacob],
|
||||
consumers=[test_data.Persons.chris],
|
||||
)
|
||||
|
||||
meal3 = Meal(
|
||||
suggested_date=datetime(2024, 9, 1, 18, 0),
|
||||
chefs=[test_data.MemberRefs.ellie],
|
||||
cleanup=[test_data.MemberRefs.chris],
|
||||
consumers=[test_data.MemberRefs.jacob],
|
||||
chefs=[test_data.Persons.ellie],
|
||||
cleanup=[test_data.Persons.chris],
|
||||
consumers=[test_data.Persons.jacob],
|
||||
)
|
||||
|
||||
# Create a consumed meal (should not appear in upcoming)
|
||||
consumed_meal = Meal(
|
||||
suggested_date=datetime(2024, 8, 10, 18, 0),
|
||||
consumed_date=datetime(2024, 8, 10, 19, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal1)
|
||||
|
|
@ -368,15 +366,15 @@ class TestMealParticipants(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test syncing meal participants"""
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 10, 1, 18, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
||||
# Update participants
|
||||
new_chefs = [test_data.MemberRefs.ryan, test_data.MemberRefs.ellie]
|
||||
new_chefs = [test_data.Persons.ryan, test_data.Persons.ellie]
|
||||
await meals_db.sync_meal_participants(self.conn, meal.id, new_chefs, "chef")
|
||||
|
||||
# Verify participants were updated
|
||||
|
|
@ -426,9 +424,9 @@ class TestMealRecipes(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 11, 1, 18, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -477,9 +475,9 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 12, 1, 18, 0),
|
||||
chefs=[test_data.MemberRefs.jacob],
|
||||
cleanup=[test_data.MemberRefs.ryan],
|
||||
consumers=[test_data.MemberRefs.ellie],
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
import datetime
|
||||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestMealsConsumedV2(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
# Register a user and create two households
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "s@test.com", "password": "pw", "displayName": "S"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
token = r.json()["accessToken"]
|
||||
self.headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H1"})
|
||||
assert r.status_code == 200, r.text
|
||||
self.h1 = r.json()["slug"]
|
||||
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H2"})
|
||||
assert r.status_code == 200, r.text
|
||||
self.h2 = r.json()["slug"]
|
||||
|
||||
# Lookup household ids
|
||||
async with self.conn.execute("SELECT id FROM Household WHERE slug = ?", (self.h1,)) as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
self.h1_id = int(row[0])
|
||||
async with self.conn.execute("SELECT id FROM Household WHERE slug = ?", (self.h2,)) as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
self.h2_id = int(row[0])
|
||||
|
||||
# Seed two meals in different households
|
||||
await self.conn.execute(
|
||||
"INSERT INTO Meal (suggested_date, household_id) VALUES (?, ?)",
|
||||
(datetime.datetime.now().astimezone().isoformat(), self.h1_id),
|
||||
)
|
||||
async with self.conn.execute("SELECT last_insert_rowid()") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
self.meal_h1 = int(row[0])
|
||||
|
||||
await self.conn.execute(
|
||||
"INSERT INTO Meal (suggested_date, household_id) VALUES (?, ?)",
|
||||
(datetime.datetime.now().astimezone().isoformat(), self.h2_id),
|
||||
)
|
||||
async with self.conn.execute("SELECT last_insert_rowid()") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
self.meal_h2 = int(row[0])
|
||||
|
||||
# Seed outstanding meal requests in both households
|
||||
await self.conn.execute(
|
||||
"INSERT INTO ShoppingListItem (ingredient_id, meal_id, person_id, created_date, household_id) VALUES (NULL, ?, 1, datetime('now'), ?)",
|
||||
(self.meal_h1, self.h1_id),
|
||||
)
|
||||
await self.conn.execute(
|
||||
"INSERT INTO ShoppingListItem (ingredient_id, meal_id, person_id, created_date, household_id) VALUES (NULL, ?, 1, datetime('now'), ?)",
|
||||
(self.meal_h2, self.h2_id),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_mark_consumed_scoped(self):
|
||||
body = {"consumedDate": datetime.datetime.now().astimezone().isoformat()}
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.h1}/meals/{self.meal_h1}/consumed",
|
||||
headers=self.headers,
|
||||
json=body,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
meal = r.json()
|
||||
assert meal["id"] == self.meal_h1
|
||||
assert meal["consumedDate"] is not None
|
||||
|
||||
# H1 meal request should be gone; H2 remains
|
||||
r1 = self.client.get(f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers)
|
||||
assert r1.status_code == 200
|
||||
cur1 = r1.json()
|
||||
assert all(i.get("mealId") != self.meal_h1 for i in cur1["requestedMeals"]) # none for h1
|
||||
|
||||
r2 = self.client.get(f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers)
|
||||
assert r2.status_code == 200
|
||||
cur2 = r2.json()
|
||||
assert any(i.get("mealId") == self.meal_h2 for i in cur2["requestedMeals"]) # still present
|
||||
|
||||
async def test_mark_consumed_requires_timezone(self):
|
||||
# Create another meal in H1
|
||||
await self.conn.execute(
|
||||
"INSERT INTO Meal (suggested_date, household_id) VALUES (datetime('now'), ?)",
|
||||
(self.h1_id,),
|
||||
)
|
||||
async with self.conn.execute("SELECT last_insert_rowid()") as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
meal_id = int(row[0])
|
||||
# Missing tzinfo should 400
|
||||
body = {"consumedDate": datetime.datetime.now().replace(tzinfo=None).isoformat()}
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.h1}/meals/{meal_id}/consumed",
|
||||
headers=self.headers,
|
||||
json=body,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "timezone" in r.json()["title"].lower()
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import datetime
|
||||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestMealsHouseholdV2(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
# Register a user and create two households
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "m@test.com", "password": "pw", "displayName": "M"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
token = r.json()["accessToken"]
|
||||
self.headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H1"})
|
||||
assert r.status_code == 200, r.text
|
||||
self.h1 = r.json()["slug"]
|
||||
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H2"})
|
||||
assert r.status_code == 200, r.text
|
||||
self.h2 = r.json()["slug"]
|
||||
|
||||
# Insert meals directly with household_id to seed data
|
||||
now = datetime.datetime.utcnow()
|
||||
# Raw inserts
|
||||
async with self.conn.execute("SELECT id FROM Household WHERE slug = ?", (self.h1,)) as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
self.h1_id = int(row[0])
|
||||
async with self.conn.execute("SELECT id FROM Household WHERE slug = ?", (self.h2,)) as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
self.h2_id = int(row[0])
|
||||
|
||||
await self.conn.execute(
|
||||
"INSERT INTO Meal (suggested_date, consumed_date, deleted_date, purchase_date, household_id) VALUES (?, NULL, NULL, NULL, ?)",
|
||||
(now + datetime.timedelta(days=1), self.h1_id),
|
||||
)
|
||||
await self.conn.execute(
|
||||
"INSERT INTO Meal (suggested_date, consumed_date, deleted_date, purchase_date, household_id) VALUES (?, NULL, NULL, NULL, ?)",
|
||||
(now + datetime.timedelta(days=2), self.h2_id),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
# Capture meal ids for each household
|
||||
async with self.conn.execute(
|
||||
"SELECT id FROM Meal WHERE household_id = ? ORDER BY id LIMIT 1", (self.h1_id,)
|
||||
) as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
self.meal_h1_id = int(row[0])
|
||||
async with self.conn.execute(
|
||||
"SELECT id FROM Meal WHERE household_id = ? ORDER BY id LIMIT 1", (self.h2_id,)
|
||||
) as c:
|
||||
row = await c.fetchone()
|
||||
assert row is not None
|
||||
self.meal_h2_id = int(row[0])
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_upcoming_meals_are_scoped(self):
|
||||
now = datetime.datetime.utcnow()
|
||||
params = {
|
||||
"from": (now - datetime.timedelta(days=1)).isoformat() + "Z",
|
||||
"to": (now + datetime.timedelta(days=7)).isoformat() + "Z",
|
||||
}
|
||||
r1 = self.client.get(
|
||||
f"/api/v1/households/{self.h1}/meals/upcoming", headers=self.headers, params=params
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
m1 = r1.json()
|
||||
assert isinstance(m1, list)
|
||||
assert len(m1) == 1
|
||||
|
||||
r2 = self.client.get(
|
||||
f"/api/v1/households/{self.h2}/meals/upcoming", headers=self.headers, params=params
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
m2 = r2.json()
|
||||
assert isinstance(m2, list)
|
||||
assert len(m2) == 1
|
||||
# Ensure different meal ids per household
|
||||
assert m1[0]["id"] != m2[0]["id"]
|
||||
|
||||
def test_get_meal_scoped(self):
|
||||
# Correct household should succeed
|
||||
r_ok = self.client.get(
|
||||
f"/api/v1/households/{self.h1}/meals/{self.meal_h1_id}", headers=self.headers
|
||||
)
|
||||
assert r_ok.status_code == 200, r_ok.text
|
||||
assert r_ok.json()["id"] == self.meal_h1_id
|
||||
|
||||
# Cross-household should 404
|
||||
r_404 = self.client.get(
|
||||
f"/api/v1/households/{self.h2}/meals/{self.meal_h1_id}", headers=self.headers
|
||||
)
|
||||
assert r_404.status_code == 404
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
import datetime
|
||||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestMealsWriteV2(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
# Auth user and create household
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "w@test.com", "password": "pw", "displayName": "W"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
token = r.json()["accessToken"]
|
||||
self.headers = {"Authorization": f"Bearer {token}"}
|
||||
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H"})
|
||||
assert r.status_code == 200, r.text
|
||||
self.slug = r.json()["slug"]
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_create_update_delete_scoped(self):
|
||||
# Create a meal with suggested date and one extra ingredient
|
||||
body = {
|
||||
"suggestedDate": datetime.datetime.now().astimezone().isoformat(),
|
||||
"chefs": [{"id": 1, "displayName": "A"}],
|
||||
"cleanup": [{"id": 1, "displayName": "A"}],
|
||||
"consumers": [{"id": 1, "displayName": "A"}],
|
||||
"recipes": [],
|
||||
"extraIngredients": [
|
||||
{"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""}
|
||||
],
|
||||
}
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/meals",
|
||||
headers=self.headers,
|
||||
json=body,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
created = r.json()
|
||||
meal_id = created["id"]
|
||||
|
||||
# Update: add an extra ingredient
|
||||
created["extraIngredients"].append(
|
||||
{"name": "Pepper", "line": "Pepper", "unit": "Items", "quantity": 1, "preparation": ""}
|
||||
)
|
||||
r2 = self.client.put(
|
||||
f"/api/v1/households/{self.slug}/meals/{meal_id}", headers=self.headers, json=created
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
updated = r2.json()
|
||||
assert len(updated["extraIngredients"]) == 2
|
||||
|
||||
# Delete
|
||||
r3 = self.client.delete(
|
||||
f"/api/v1/households/{self.slug}/meals/{meal_id}", headers=self.headers
|
||||
)
|
||||
assert r3.status_code == 200, r3.text
|
||||
|
||||
def test_create_meal_validation_errors(self):
|
||||
# Base valid body
|
||||
base = {
|
||||
"suggestedDate": datetime.datetime.now().astimezone().isoformat(),
|
||||
"chefs": [{"id": 1, "displayName": "A"}],
|
||||
"cleanup": [{"id": 1, "displayName": "A"}],
|
||||
"consumers": [{"id": 1, "displayName": "A"}],
|
||||
"recipes": [],
|
||||
"extraIngredients": [
|
||||
{"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""}
|
||||
],
|
||||
}
|
||||
|
||||
# No chefs
|
||||
body = dict(base)
|
||||
body["chefs"] = []
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "chef" in r.json()["title"].lower()
|
||||
|
||||
# No cleanup
|
||||
body = dict(base)
|
||||
body["cleanup"] = []
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "cleanup" in r.json()["title"].lower()
|
||||
|
||||
# No consumers
|
||||
body = dict(base)
|
||||
body["consumers"] = []
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "consumer" in r.json()["title"].lower()
|
||||
|
||||
# No recipes or extra ingredients
|
||||
body = dict(base)
|
||||
body["recipes"] = []
|
||||
body["extraIngredients"] = []
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "recipe" in r.json()["title"].lower() or "ingredient" in r.json()["title"].lower()
|
||||
|
||||
# Zero servings in recipe
|
||||
body = dict(base)
|
||||
body["recipes"] = [{"mealId": -1, "recipeId": 1, "servings": 0}]
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "servings" in r.json()["title"].lower()
|
||||
|
||||
def test_update_id_mismatch_and_not_found_and_delete_not_found(self):
|
||||
# Create a valid meal first
|
||||
body = {
|
||||
"suggestedDate": datetime.datetime.now().astimezone().isoformat(),
|
||||
"chefs": [{"id": 1, "displayName": "A"}],
|
||||
"cleanup": [{"id": 1, "displayName": "A"}],
|
||||
"consumers": [{"id": 1, "displayName": "A"}],
|
||||
"recipes": [],
|
||||
"extraIngredients": [
|
||||
{"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""}
|
||||
],
|
||||
}
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
created = r.json()
|
||||
|
||||
# ID mismatch
|
||||
mismatch = dict(created)
|
||||
wrong_id = created["id"] + 123
|
||||
r_mis = self.client.put(
|
||||
f"/api/v1/households/{self.slug}/meals/{wrong_id}", headers=self.headers, json=mismatch
|
||||
)
|
||||
assert r_mis.status_code == 400
|
||||
|
||||
# Not found update
|
||||
mismatch["id"] = wrong_id
|
||||
r_nf = self.client.put(
|
||||
f"/api/v1/households/{self.slug}/meals/{wrong_id}", headers=self.headers, json=mismatch
|
||||
)
|
||||
assert r_nf.status_code == 404
|
||||
|
||||
# Delete not found
|
||||
r_del_nf = self.client.delete(
|
||||
f"/api/v1/households/{self.slug}/meals/{wrong_id}", headers=self.headers
|
||||
)
|
||||
assert r_del_nf.status_code == 404
|
||||
|
||||
def test_create_meal_invalid_member_id_returns_400(self):
|
||||
# Body with an invalid member id 9999
|
||||
body = {
|
||||
"suggestedDate": datetime.datetime.now().astimezone().isoformat(),
|
||||
"chefs": [{"id": 9999, "displayName": "X"}],
|
||||
"cleanup": [{"id": 1, "displayName": "A"}],
|
||||
"consumers": [{"id": 1, "displayName": "A"}],
|
||||
"recipes": [],
|
||||
"extraIngredients": [
|
||||
{"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""}
|
||||
],
|
||||
}
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "member" in r.json()["title"].lower()
|
||||
|
||||
def test_create_meal_invalid_recipe_id_returns_400(self):
|
||||
# Use a non-existent recipe id; servings > 0
|
||||
body = {
|
||||
"suggestedDate": datetime.datetime.now().astimezone().isoformat(),
|
||||
"chefs": [{"id": 1, "displayName": "A"}],
|
||||
"cleanup": [{"id": 1, "displayName": "A"}],
|
||||
"consumers": [{"id": 1, "displayName": "A"}],
|
||||
"recipes": [{"mealId": -1, "recipeId": 9999, "servings": 1}],
|
||||
"extraIngredients": [],
|
||||
}
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "recipe" in r.json()["title"].lower()
|
||||
|
||||
def test_update_meal_invalid_member_id_returns_400(self):
|
||||
# Create a valid meal first
|
||||
body = {
|
||||
"suggestedDate": datetime.datetime.now().astimezone().isoformat(),
|
||||
"chefs": [{"id": 1, "displayName": "A"}],
|
||||
"cleanup": [{"id": 1, "displayName": "A"}],
|
||||
"consumers": [{"id": 1, "displayName": "A"}],
|
||||
"recipes": [],
|
||||
"extraIngredients": [
|
||||
{"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""}
|
||||
],
|
||||
}
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
created = r.json()
|
||||
meal_id = created["id"]
|
||||
|
||||
# Attempt to update with an invalid member id
|
||||
created["chefs"] = [{"id": 9999, "displayName": "X"}]
|
||||
r2 = self.client.put(
|
||||
f"/api/v1/households/{self.slug}/meals/{meal_id}", headers=self.headers, json=created
|
||||
)
|
||||
assert r2.status_code == 400
|
||||
assert "member" in r2.json()["title"].lower()
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import unittest
|
||||
|
||||
import main
|
||||
|
||||
|
||||
class TestOpenAPISecurity(unittest.TestCase):
|
||||
def test_bearer_auth_included(self):
|
||||
spec = main.app.openapi()
|
||||
comps = spec.get("components", {})
|
||||
sec = comps.get("securitySchemes", {})
|
||||
assert "bearerAuth" in sec
|
||||
bearer = sec["bearerAuth"]
|
||||
assert bearer.get("type") == "http"
|
||||
assert bearer.get("scheme") == "bearer"
|
||||
assert bearer.get("bearerFormat") == "JWT"
|
||||
|
||||
def test_household_routes_require_bearer(self):
|
||||
spec = main.app.openapi()
|
||||
paths = spec.get("paths", {})
|
||||
# Users me households
|
||||
op = paths.get("/api/v1/users/me/households", {}).get("get")
|
||||
assert op and any("bearerAuth" in s for s in op.get("security", []))
|
||||
# and has 403 in responses
|
||||
assert "403" in op.get("responses", {})
|
||||
# Household whoami
|
||||
op = paths.get("/api/v1/households/{householdSlug}/whoami", {}).get("get")
|
||||
assert op and any("bearerAuth" in s for s in op.get("security", []))
|
||||
assert "403" in op.get("responses", {})
|
||||
|
||||
def test_problem_403_component_present(self):
|
||||
spec = main.app.openapi()
|
||||
comps = spec.get("components", {})
|
||||
responses = comps.get("responses", {})
|
||||
assert "Problem403" in responses
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from db import connect, create
|
||||
|
||||
|
||||
class TestRecipesHouseholdV2(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
# Register a user and create two households
|
||||
r = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "r@test.com", "password": "pw", "displayName": "R"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
self.token = r.json()["accessToken"]
|
||||
self.headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H1"})
|
||||
assert r.status_code == 200, r.text
|
||||
self.h1 = r.json()["slug"]
|
||||
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H2"})
|
||||
assert r.status_code == 200, r.text
|
||||
self.h2 = r.json()["slug"]
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
|
||||
def test_create_and_list_scoped_recipes(self):
|
||||
# Create a recipe in H1
|
||||
recipe = {
|
||||
"id": -1,
|
||||
"name": "Soup",
|
||||
"link": "https://example.com/soup",
|
||||
"serves": 2,
|
||||
"imageUrls": [],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": 0,
|
||||
"line": "1 Apple",
|
||||
"name": "Apple",
|
||||
"unit": "Items",
|
||||
"quantity": 1,
|
||||
"preparation": "",
|
||||
"product": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.h1}/recipes", headers=self.headers, json=recipe
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
rid = r.json()["id"]
|
||||
# createdBy fields present and match current user
|
||||
created_by = r.json().get("createdBy")
|
||||
assert created_by is not None
|
||||
assert isinstance(created_by.get("id"), int)
|
||||
assert created_by.get("displayName") == "R"
|
||||
assert r.json().get("createdById") == created_by["id"]
|
||||
|
||||
# List H1 should include
|
||||
r = self.client.get(f"/api/v1/households/{self.h1}/recipes", headers=self.headers)
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert any(it["id"] == rid for it in items)
|
||||
# list items include createdBy
|
||||
found = next(it for it in items if it["id"] == rid)
|
||||
assert "createdBy" in found and "createdById" in found
|
||||
|
||||
# List H2 should not include
|
||||
r = self.client.get(f"/api/v1/households/{self.h2}/recipes", headers=self.headers)
|
||||
assert r.status_code == 200
|
||||
items2 = r.json()["items"]
|
||||
assert not any(it["id"] == rid for it in items2)
|
||||
|
||||
# Get in H2 by id should 404
|
||||
r = self.client.get(f"/api/v1/households/{self.h2}/recipes/{rid}", headers=self.headers)
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_delete_recipe_scoped(self):
|
||||
# Create a recipe in H1
|
||||
recipe = {
|
||||
"id": -1,
|
||||
"name": "ToDelete",
|
||||
"link": "https://example.com/del",
|
||||
"serves": 2,
|
||||
"imageUrls": [],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": 0,
|
||||
"line": "1 Apple",
|
||||
"name": "Apple",
|
||||
"unit": "Items",
|
||||
"quantity": 1,
|
||||
"preparation": "",
|
||||
"product": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.h1}/recipes", headers=self.headers, json=recipe
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
rid = r.json()["id"]
|
||||
# createdBy present
|
||||
assert "createdBy" in r.json() and "createdById" in r.json()
|
||||
|
||||
# Delete it via v2 scoped route
|
||||
r2 = self.client.delete(f"/api/v1/households/{self.h1}/recipes/{rid}", headers=self.headers)
|
||||
assert r2.status_code == 200, r2.text
|
||||
# hiddenBy is populated on delete
|
||||
body_del = r2.json()
|
||||
assert "hiddenBy" in body_del and "hiddenById" in body_del
|
||||
assert body_del["hiddenBy"]["displayName"] == "R"
|
||||
|
||||
# Subsequent get in same household should be 404
|
||||
r3 = self.client.get(f"/api/v1/households/{self.h1}/recipes/{rid}", headers=self.headers)
|
||||
assert r3.status_code == 404
|
||||
|
||||
def test_create_recipe_requires_ingredients_and_cursor_edge(self):
|
||||
# Create without ingredients -> 400
|
||||
bad = {
|
||||
"id": -1,
|
||||
"name": "NoIngr",
|
||||
"link": "https://example.com/no",
|
||||
"serves": 2,
|
||||
"imageUrls": [],
|
||||
"ingredients": [],
|
||||
}
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.h1}/recipes", headers=self.headers, json=bad
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
# Seed two recipes then exercise cursor behavior
|
||||
for i in range(2):
|
||||
good = {
|
||||
"id": -1,
|
||||
"name": f"R{i}",
|
||||
"link": f"https://example.com/r{i}",
|
||||
"serves": 2,
|
||||
"imageUrls": [],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": 0,
|
||||
"line": "1 A",
|
||||
"name": "A",
|
||||
"unit": "Items",
|
||||
"quantity": 1,
|
||||
"preparation": "",
|
||||
"product": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
rr = self.client.post(
|
||||
f"/api/v1/households/{self.h1}/recipes", headers=self.headers, json=good
|
||||
)
|
||||
assert rr.status_code == 200, rr.text
|
||||
|
||||
# invalid cursor -> treated as start
|
||||
r1 = self.client.get(
|
||||
f"/api/v1/households/{self.h1}/recipes?cursor=notanint&limit=1",
|
||||
headers=self.headers,
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
body1 = r1.json()
|
||||
assert "items" in body1
|
||||
# After last
|
||||
all_resp = self.client.get(
|
||||
f"/api/v1/households/{self.h1}/recipes?limit=200", headers=self.headers
|
||||
)
|
||||
items = all_resp.json()["items"]
|
||||
if items:
|
||||
last_id = items[-1]["id"]
|
||||
after = self.client.get(
|
||||
f"/api/v1/households/{self.h1}/recipes?cursor={last_id}&limit=200",
|
||||
headers=self.headers,
|
||||
)
|
||||
body_after = after.json()
|
||||
assert body_after["items"] == [] or body_after.get("nextCursor") is None
|
||||
|
||||
def test_list_recipes_name_filter_q(self):
|
||||
# Seed a uniquely named recipe
|
||||
recipe = {
|
||||
"id": -1,
|
||||
"name": "UniqueNameZZZ",
|
||||
"link": "https://example.com/unique",
|
||||
"serves": 2,
|
||||
"imageUrls": [],
|
||||
"ingredients": [
|
||||
{
|
||||
"id": 0,
|
||||
"line": "1 A",
|
||||
"name": "A",
|
||||
"unit": "Items",
|
||||
"quantity": 1,
|
||||
"preparation": "",
|
||||
"product": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
r = self.client.post(
|
||||
f"/api/v1/households/{self.h1}/recipes", headers=self.headers, json=recipe
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
# Name filter should find it
|
||||
r2 = self.client.get(
|
||||
f"/api/v1/households/{self.h1}/recipes?q=UniqueNameZZZ", headers=self.headers
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
items = r2.json()["items"]
|
||||
assert any(it["name"] == "UniqueNameZZZ" for it in items)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue