api recipe and ingredient, GET and PUT
This commit is contained in:
parent
7e4c1dca7e
commit
4ddb2ea8ff
|
|
@ -0,0 +1,162 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
data/
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
from fastapi import FastAPI, HTTPException
|
||||
from data import Recipe, Ingredient
|
||||
import db
|
||||
from psycopg2.errors import UniqueViolation
|
||||
|
||||
app = FastAPI()
|
||||
conn = db.connect()
|
||||
|
||||
|
||||
@app.put("/recipe")
|
||||
def new_recipe(recipe: Recipe) -> Recipe | None:
|
||||
try:
|
||||
recipe = db.insert_recipe(recipe, conn)
|
||||
except UniqueViolation:
|
||||
raise HTTPException(status_code=400, detail="id conflict")
|
||||
return recipe
|
||||
|
||||
|
||||
@app.get("/recipe/{item_id}")
|
||||
def get_recipe(item_id: int):
|
||||
recipe = db.get_recipe(item_id, conn)
|
||||
if not recipe:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Recipe not found: id = {id}",
|
||||
)
|
||||
return recipe
|
||||
|
||||
|
||||
@app.put("/ingredient")
|
||||
def new_ingredient(ingredient: Ingredient) -> Ingredient | None:
|
||||
try:
|
||||
ingredient = db.insert_ingredient(ingredient, conn)
|
||||
except UniqueViolation:
|
||||
raise HTTPException(status_code=400, detail="id conflict")
|
||||
return ingredient
|
||||
|
||||
|
||||
@app.get("/ingredient/{item_id}")
|
||||
def get_ingredient(id):
|
||||
ingredient = db.get_ingredient(id, conn)
|
||||
print(ingredient)
|
||||
if not ingredient:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Ingredient not found: id = {id}",
|
||||
)
|
||||
return ingredient
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
from pydantic import BaseModel
|
||||
from recipe_scrapers._abstract import AbstractScraper
|
||||
from ingredient_parser.postprocess import ParsedIngredient
|
||||
|
||||
|
||||
class Recipe(BaseModel):
|
||||
id: int | None = None
|
||||
title: str | None
|
||||
url: str | None
|
||||
site_name: str
|
||||
cuisine: str
|
||||
category: str
|
||||
description: str
|
||||
cook_time: float
|
||||
prep_time: float
|
||||
yields: str
|
||||
|
||||
|
||||
class Ingredient(BaseModel):
|
||||
id: int | None = None
|
||||
recipe_id: int | None = None
|
||||
text: str | None = None
|
||||
purpose: str | None = None
|
||||
name: str | None
|
||||
name_conf: float | None
|
||||
amount_quantity: list[str] | None
|
||||
amount_unit: list[str] | None
|
||||
amount_conf: list[float] | None
|
||||
preparation: str | None
|
||||
preparation_conf: float | None
|
||||
comment: str | None
|
||||
comment_conf: float | None
|
||||
|
||||
|
||||
def parsed_ingredient_to_ingredient(
|
||||
parsed_ingredient: ParsedIngredient,
|
||||
purpose=None,
|
||||
recipe_id=None,
|
||||
) -> Ingredient:
|
||||
data = {}
|
||||
data["purpose"] = purpose
|
||||
data["recipe_id"] = recipe_id
|
||||
fields = ["name", "preparation", "comment"]
|
||||
|
||||
data["text"] = parsed_ingredient.sentence
|
||||
|
||||
for field in fields:
|
||||
attr = getattr(parsed_ingredient, field)
|
||||
data[field] = attr.text if attr else None
|
||||
data[field + "_conf"] = attr.confidence if attr else None
|
||||
|
||||
amounts = parsed_ingredient.amount or []
|
||||
if amounts:
|
||||
data["amount_quantity"] = []
|
||||
data["amount_unit"] = []
|
||||
data["amount_conf"] = []
|
||||
for amount in amounts:
|
||||
data["amount_quantity"].append(amount.quantity if amount else "")
|
||||
data["amount_unit"].append(amount.unit if amount else "")
|
||||
data["amount_conf"].append(amount.confidence if amount else 0)
|
||||
else:
|
||||
data["amount_quantity"] = None
|
||||
data["amount_unit"] = None
|
||||
data["amount_conf"] = None
|
||||
|
||||
return Ingredient(**data)
|
||||
|
||||
|
||||
def scraped_reciepe_to_recipe(recipe: AbstractScraper) -> Recipe:
|
||||
site_name = recipe.site_name()
|
||||
if not site_name:
|
||||
site_name = ""
|
||||
site_name = str(site_name)
|
||||
|
||||
return Recipe(
|
||||
title=recipe.title(),
|
||||
site_name=site_name,
|
||||
url=recipe.url,
|
||||
cuisine=recipe.cuisine(),
|
||||
category=recipe.category(),
|
||||
description=recipe.description(),
|
||||
cook_time=recipe.cook_time(),
|
||||
prep_time=recipe.prep_time(),
|
||||
yields=recipe.yields(),
|
||||
)
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
from pydantic import BaseModel
|
||||
import psycopg2 as pg
|
||||
from psycopg2 import sql
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from os import environ as env, stat
|
||||
from functools import wraps
|
||||
from inspect import getfullargspec
|
||||
from typing import Callable, Type
|
||||
from data import Recipe, Ingredient
|
||||
|
||||
|
||||
class NoConnectionException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def connect():
|
||||
return pg.connect(
|
||||
dbname=env.get("POSTGRES_DB"),
|
||||
user=env.get("POSTGRES_USER"),
|
||||
password=env.get("POSTGRES_PASSWORD"),
|
||||
host=env.get("POSTGRES_HOST"),
|
||||
port=env.get("POSTGRES_PORT"),
|
||||
)
|
||||
|
||||
|
||||
def connect_by_default(
|
||||
func: Callable,
|
||||
kword: str = "conn",
|
||||
conn_func: Callable[..., pg.extensions.connection] = connect,
|
||||
) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapped(*args, **kargs):
|
||||
for i, arg in enumerate(args):
|
||||
kargs[getfullargspec(func).args[i]] = arg
|
||||
if not kargs.get(kword):
|
||||
kargs[kword] = conn_func()
|
||||
return func(**kargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@connect_by_default
|
||||
def select_by_id(
|
||||
schema_name: str,
|
||||
table_name: str,
|
||||
id: int,
|
||||
columns: list[str] | None = None,
|
||||
conn: pg.extensions.connection | None = None,
|
||||
) -> dict | None:
|
||||
if not conn:
|
||||
raise NoConnectionException()
|
||||
|
||||
query = sql.SQL(
|
||||
f"""
|
||||
SELECT {'{}' if columns else '*'}
|
||||
FROM {{}}.{{}}
|
||||
WHERE id = %s;"""
|
||||
)
|
||||
if columns:
|
||||
cols = sql.SQL(",").join([sql.Identifier(col) for col in columns])
|
||||
query = query.format(
|
||||
cols, sql.Identifier(schema_name), sql.Identifier(table_name)
|
||||
)
|
||||
else:
|
||||
query = query.format(
|
||||
sql.Identifier(schema_name), sql.Identifier(table_name)
|
||||
)
|
||||
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as curs:
|
||||
curs.execute(query, (id,))
|
||||
data = curs.fetchone()
|
||||
|
||||
if not data:
|
||||
return
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@connect_by_default
|
||||
def insert_model(
|
||||
schema_name: str,
|
||||
table_name: str,
|
||||
model: BaseModel,
|
||||
conn: pg.extensions.connection | None = None,
|
||||
) -> dict | None:
|
||||
if not conn:
|
||||
raise NoConnectionException()
|
||||
|
||||
data = model.model_dump(exclude_none=True)
|
||||
cols = data.keys()
|
||||
vals = data.values()
|
||||
|
||||
query = sql.SQL(
|
||||
f"""
|
||||
INSERT INTO {{schema}}.{{table}} (
|
||||
{{fields}}
|
||||
) VALUES (
|
||||
{", ".join(["%s" for _ in vals])}
|
||||
)
|
||||
RETURNING id;
|
||||
"""
|
||||
)
|
||||
|
||||
with conn.cursor() as curs:
|
||||
try:
|
||||
curs.execute(
|
||||
query.format(
|
||||
schema=sql.Identifier(schema_name),
|
||||
table=sql.Identifier(table_name),
|
||||
fields=sql.SQL(",").join(
|
||||
[sql.Identifier(col) for col in cols]
|
||||
),
|
||||
),
|
||||
(*vals,),
|
||||
)
|
||||
row = curs.fetchone()
|
||||
conn.commit()
|
||||
print(row)
|
||||
except pg.errors.UniqueViolation as error:
|
||||
conn.rollback()
|
||||
raise error
|
||||
|
||||
if not row:
|
||||
return
|
||||
id = row[0]
|
||||
|
||||
data["id"] = id
|
||||
return data
|
||||
|
||||
|
||||
@connect_by_default
|
||||
def insert_recipe(
|
||||
recipe: Recipe,
|
||||
conn: pg.extensions.connection | None = None,
|
||||
) -> Recipe | None:
|
||||
data = insert_model("recipe", "recipe", recipe, conn)
|
||||
if not data:
|
||||
return
|
||||
return Recipe(**data)
|
||||
|
||||
|
||||
@connect_by_default
|
||||
def get_recipe(
|
||||
id: int,
|
||||
conn: pg.extensions.connection | None = None,
|
||||
) -> Recipe | None:
|
||||
data = select_by_id("recipe", "recipe", id, conn=conn)
|
||||
if not data:
|
||||
return
|
||||
|
||||
recipe = Recipe(**data)
|
||||
return recipe
|
||||
|
||||
|
||||
@connect_by_default
|
||||
def insert_ingredient(
|
||||
ingredient: Ingredient,
|
||||
conn: pg.extensions.connection | None = None,
|
||||
) -> Ingredient | None:
|
||||
data = insert_model("recipe", "ingredient", ingredient, conn)
|
||||
if not data:
|
||||
return
|
||||
return Ingredient(**data)
|
||||
|
||||
|
||||
@connect_by_default
|
||||
def get_ingredient(
|
||||
id: int,
|
||||
conn: pg.extensions.connection | None = None,
|
||||
) -> Ingredient | None:
|
||||
data = select_by_id("recipe", "ingredient", id, conn=conn)
|
||||
if not data:
|
||||
return
|
||||
|
||||
ingredient = Ingredient(**data)
|
||||
return ingredient
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
services:
|
||||
psql:
|
||||
image: postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
ports:
|
||||
- "${POSTGRES_PORT}:5432"
|
||||
volumes:
|
||||
- "./data/${COMPOSE_PROJECT_NAME}:/var/lib/postgresql/data"
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
pandas
|
||||
psycopg2-binary
|
||||
fastapi
|
||||
recipe-scrapers
|
||||
ingredient-parser-nlp
|
||||
pydantic
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
[core]
|
||||
engine = pg
|
||||
top_dir = sqitch
|
||||
# plan_file = sqitch/sqitch.plan
|
||||
# [engine "pg"]
|
||||
# target = db:pg:
|
||||
# registry = sqitch
|
||||
# client = psql
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
-- Deploy rsugest:ingredient-table to pg
|
||||
-- requires: recipe-schema
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE recipe.ingredient (
|
||||
id SERIAL PRIMARY KEY,
|
||||
recipe_id INT,
|
||||
text TEXT,
|
||||
purpose TEXT,
|
||||
name TEXT,
|
||||
name_conf FLOAT,
|
||||
amount_quantity TEXT[],
|
||||
amount_unit TEXT[],
|
||||
amount_conf FLOAT[],
|
||||
preparation TEXT,
|
||||
preparation_conf FLOAT,
|
||||
comment TEXT,
|
||||
comment_conf FLOAT);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
-- Deploy rsugest:recipe-schema to pg
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE SCHEMA recipe;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
-- Deploy rsugest:recipe-table to pg
|
||||
-- requires: recipe-schema
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE recipe.recipe (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT,
|
||||
url TEXT,
|
||||
site_name TEXT,
|
||||
cuisine TEXT,
|
||||
category TEXT,
|
||||
description TEXT,
|
||||
cook_time REAL,
|
||||
prep_time REAL,
|
||||
yields TEXT);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert rsugest:ingredient-table from pg
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE recipe.ingredient;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert rsugest:recipe-schema from pg
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP SCHEMA recipe;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert rsugest:recipe-table from pg
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE recipe.recipe;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
%syntax-version=1.0.0
|
||||
%project=rsugest
|
||||
|
||||
recipe-schema 2024-02-07T01:24:03Z Andrei,,, <andrei@bloated-boat> # adding schema for recipes
|
||||
recipe-table [recipe-schema] 2024-02-07T01:42:28Z Andrei,,, <andrei@bloated-boat> # adding table for recipes
|
||||
ingredient-table [recipe-schema] 2024-02-07T03:50:20Z Andrei,,, <andrei@bloated-boat> # adding table for ingredients
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
-- Verify rsugest:ingredient-table on pg
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT id, recipe_id, text, purpose, name, name_conf,
|
||||
amount_quantity, amount_unit, amount_conf, preparation,
|
||||
preparation_conf, comment, comment_conf
|
||||
FROM recipe.ingredient
|
||||
WHERE FALSE;
|
||||
|
||||
ROLLBACK;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
-- Verify rsugest:recipe-schema on pg
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT pg_catalog.has_schema_privilege('recipe', 'usage');
|
||||
|
||||
ROLLBACK;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
-- Verify rsugest:recipe-table on pg
|
||||
|
||||
BEGIN;
|
||||
|
||||
|
||||
select id, title, url, site_name, cuisine, category, description,
|
||||
cook_time, prep_time, yields
|
||||
FROM recipe.recipe
|
||||
WHERE FALSE;
|
||||
|
||||
ROLLBACK;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"id": 0,
|
||||
"title": "string",
|
||||
"url": "string",
|
||||
"site_name": "string",
|
||||
"cuisine": "string",
|
||||
"category": "string",
|
||||
"description": "string",
|
||||
"cook_time": 0,
|
||||
"prep_time": 0,
|
||||
"yields": "string"
|
||||
}
|
||||
Loading…
Reference in New Issue