/
cookit
/
CookItBackend
Обзор
Документация
Войти
/
cookit
/
CookItBackend
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
main.py
687 строк
31 KB
YaroslavIonin
update search_recipes_by_ingredients
18 дек 2025, 20:49
18 дек 2025, 20:49
13cb482
Код
Авторство
О чём код?
import subprocess from collections import defaultdict from fastapi import File, UploadFile, HTTPException,Query import httpx import os import models, schemas, crud from typing import Optional from fastapi.middleware.cors import CORSMiddleware from pagination import PaginationParams, pagination_params from services import translate_batch, apply_pagination from sqlalchemy import func, desc, update from fastapi import FastAPI, Request from starlette.middleware.sessions import SessionMiddleware from authlib.integrations.starlette_client import OAuth from dotenv import load_dotenv from fastapi import FastAPI, Depends, HTTPException from sqlalchemy.orm import Session from database import engine, get_db, Base from models import Recipe, RecipeIngredient, Ingredient, RecipeIngredientGroup from sqlalchemy import text, inspect from fastapi.staticfiles import StaticFiles from typing import Literal from model_manager import MODEL_MANAGER from fastapi.responses import JSONResponse from pathlib import Path from sqlalchemy.sql import exists, and_, select, or_ from routes.ingredients import router as ingredients_routes load_dotenv() print("✅ FastAPI загружается...") app = FastAPI() app.include_router(ingredients_routes) app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET", "supersecret")) app.mount("/images", StaticFiles(directory="images"), name="images") app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000"], allow_credentials=True, allow_methods=["DELETE", "GET", "OPTIONS", "PATCH", "POST", "PUT"], allow_headers=[ "accept", "accept-encoding", "authorization", "content-type", "dnt", "origin", "user-agent", "x-csrftoken", "x-requested-with", ], ) # OAuth oauth = OAuth() oauth.register( name="google", client_id=os.getenv("GOOGLE_CLIENT_ID"), client_secret=os.getenv("GOOGLE_CLIENT_SECRET"), server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", client_kwargs={"scope": "openid email profile"}, ) MODEL_NAMES = Literal[ "amazon/nova-2-lite-v1:free", "nvidia/nemotron-nano-12b-v2-vl:free", "mistralai/mistral-small-3.1-24b-instruct:free" ] BASE_DIR = os.path.dirname(__file__) DROP_FILE = os.path.join(BASE_DIR, "sql", "drop.sql") TABLES_FILE = os.path.join(BASE_DIR, "sql", "tables.sql") INSERT_CATEGORIES_FILE = os.path.join(BASE_DIR, "sql", "insert_categories.sql") INSERT_CUISINES_FILE = os.path.join(BASE_DIR, "sql", "insert_cuisines.sql") INSERT_INGREDIENT_GROUPS_FILE = os.path.join(BASE_DIR, "sql", "insert_ingredient_groups.sql") INSERT_INGREDIENTS_FILE = os.path.join(BASE_DIR, "sql", "insert_ingredients.sql") INSERT_INSTRUCTIONS_FILE = os.path.join(BASE_DIR, "sql", "insert_instructions.sql") INSERT_RECIPE_INGREDIENT_GROUPS_FILE = os.path.join(BASE_DIR, "sql", "insert_recipe_ingredient_groups.sql") INSERT_RECIPE_INGREDIENTS_FILE = os.path.join(BASE_DIR, "sql", "insert_recipe_ingredients.sql") INSERT_RECIPE_TAGS_FILE = os.path.join(BASE_DIR, "sql", "insert_recipe_tags.sql") INSERT_RECIPES_FILE = os.path.join(BASE_DIR, "sql", "insert_recipes.sql") INSERT_TAGS_FILE = os.path.join(BASE_DIR, "sql", "insert_tags.sql") ML_URL = os.getenv("ML_URL", "http://127.0.0.1:8080/predict/") IMAGES_DIR = Path("images/") FOLDERS = [p.name for p in IMAGES_DIR.iterdir() if p.is_dir()] ''' BASE_INGREDIENTS = [ 'вода', 'вода газированная', 'вода минеральная', 'теплая вода', 'холодная вода', 'вода минеральная с газом', 'вода минеральная без газа', 'кипяток', 'кипяченая вода', 'холодная кипяченая вода', 'кипяченая вода', 'минеральная вода', 'газированная вода', # Соль и ее виды 'соль', 'соль морская', 'соль крупная', 'соль чесночная', 'соль с травами', 'соль гималайская розовая', 'соль адыгейская', 'соль каменная', 'соль сванская', # Перец черный, белый, красный и их формы 'перец черный молотый', 'перец черный свежемолотый', 'перец черный горошком', 'перец белый молотый', 'перец белый горошком', 'перец белый свежемолотый', 'перец красный молотый', 'перец красный жгучий', 'перец красный хлопьями', 'перец острый красный', 'перец острый', 'перец чили молотый', 'перец чили хлопьями', 'перец чили сушеный', 'перец кайенский молотый', 'перец острый зеленый', 'перец чили зеленый', 'перец чили красный', 'перец чипотле', 'перец красный', 'перец стручковый красный', 'порошок чили', 'перец лимонный', 'перец розовый молотый', 'перец розовый горошком', 'перец душистый', 'перец душистый горошком', 'перец душистый молотый', # Смеси перцев 'смесь перцев', 'смесь молотых перцев', 'смесь перцев молотых', 'смесь перцев свежемолотая', 'смесь перцев горошком', 'смесь острых перцев', 'смесь четырех перцев', # Паприка и ее виды 'паприка', 'паприка молотая', 'паприка сладкая', 'паприка копченая', 'паприка острая', 'сушеная паприка', # Чеснок и лук сушеные 'чеснок сушеный', 'чеснок сушеный молотый', 'порошок чесночный', 'сухой чеснок', 'чесночный порошок', 'сублимированный чеснок', 'сушеный чеснок', 'лук сушеный молотый', 'сушеный лук', 'луковый порошок', # Лавровый лист 'лавровый лист', 'лавровый лист молотый', # Итальянские и прованские травы 'итальянские травы', 'прованские травы', 'приправа “прованские травы”', 'сушеные прованские травы', # Карри и куркума 'карри', 'паста карри', 'куркума', 'куркума молотая', # Кориандр и зира 'кориандр', 'кориандр молотый', 'молотый кориандр', 'семена кориандра', 'зира', 'зира молотая', 'молотая зира', # Другие травы и специи из категории 'тимьян', 'тимьян сушеный', 'орегано', 'орегано сушеный', 'сухой орегано', 'базилик сушеный', 'сушеный базилик', 'мята сушеная', 'петрушка сушеная', 'укроп сушеный', 'розмарин сушеный', 'майоран сушеный', 'эстрагон сушеный', 'шалфей сушеный', # Универсальные приправы 'специи', 'приправы', 'сухие травы', 'смесь пряных трав', 'смесь сухих приправ', 'приправа для курицы', 'приправа для мяса', 'приправа для рыбы', 'приправа для супа', 'специи для курицы', 'специи для мяса', 'специи для рыбы', 'специи для супа', 'хмели-сунели', 'приправа овощная', 'приправа итальянская', 'средиземноморские травы', 'смесь трав', # Специальные смеси 'смесь для плова', 'приправа для плова', 'приправа для корейской моркови', 'специи для корейской моркови', 'аджика', 'аджика острая', 'гарам масала', 'специи гарам масала', 'смесь тандури масала', 'каджунская смесь специй', 'уцхо-сунели', 'масло растительное', 'масло подсолнечное', 'масло оливковое', 'масло сливочное', 'масло для жарки', 'масло топленое', 'масло рафинированное', 'масло нерафинированное', 'масло оливковое extra virgin', 'масло сливочное 82%', 'масло сливочное соленое', 'масло сливочное несоленое', 'топленое масло', 'сливочное масло', 'маргарин', 'маргарин сливочный', 'жир', 'кулинарный жир', 'смалец', 'жир свиной', 'сало', 'растительный жир', 'масло кукурузное', 'масло рапсовое', 'масло виноградной косточки', 'масло кунжутное', 'масло кокосовое', 'масло арахисовое', 'масло тыквенное', 'масло горчичное', 'масло миндальное', 'масло авокадо', 'масло канолы', 'масло пальмовое', 'оливковое масло', 'подсолнечное масло', 'растительное масло', 'жир для выпечки', 'топленый жир', 'гусиный жир', 'курдючный жир', 'соус соевый', 'соус соевый светлый', 'соус соевый острый', 'уксус', 'уксус столовый', 'уксус яблочный', 'уксус бальзамический', 'уксус винный', 'уксус винный белый', 'уксус винный красный', 'уксус рисовый', 'уксус 9%', 'уксус 6%', 'уксус 3%', 'уксусная кислота', 'уксусная эссенция', 'горчица', 'горчица дижонская', 'горчица зернистая', 'горчица острая', 'горчица сладкая', 'горчица столовая', 'горчица русская', 'горчичный порошок', 'сухая горчица', 'кетчуп', 'кетчуп острый', 'кетчуп томатный', 'майонез', 'майонез домашний', 'майонез оливковый', # Другие распространенные соусы, которые часто считаются "базовыми" 'томатная паста', 'кетчуп чили', 'соус табаско', 'соус чили', 'соус чили сладкий', 'соус барбекю', 'соус терияки', 'соус устричный', 'соус рыбный', 'соус вустерский', 'аджика', 'васаби', 'хрен', 'хрен столовый', 'ткемали', 'соус ткемали', 'сахар', 'сахар белый', 'сахар коричневый', 'сахар тростниковый', 'сахар пальмовый', 'сахар кокосовый', 'сахар ванильный', 'сахарная пудра', 'сахарная пудра ванильная', 'сахарный песок', 'тростниковый сахар', 'коричневый сахар', 'мягкий коричневый сахар', 'сахар мусковадо', 'мед', 'мед жидкий', 'мед гречишный', 'мед светлый', 'мед акациевый', 'патока', 'патока черная', 'кленовый сироп', 'сироп агавы', 'сироп топинамбура', 'сироп стевии', 'стевия', 'фруктоза', 'заменитель сахара', 'сахарозаменитель', 'сахар для выпечки', 'сахарная пудра для глазури', 'мука', 'мука пшеничная', 'мука высшего сорта', 'мука общего назначения', 'мука цельнозерновая', 'мука ржаная', 'мука кукурузная', 'мука гречневая', 'мука овсяная', 'мука нутовая', 'мука рисовая', 'мука миндальная', 'мука кокосовая', 'мука льняная', 'крахмал', 'крахмал картофельный', 'крахмал кукурузный', 'кукурузный крахмал', 'крахмал тапиоковый', 'манка', 'манная крупа', 'манка для пудинга', # Также часто к "базовым сыпучим" относят 'сода', 'сода пищевая', 'сода гашеная', 'разрыхлитель', 'пекарский порошок', 'разрыхлитель теста', 'дрожжи', 'дрожжи сухие', 'дрожжи свежие', 'дрожжи быстродействующие', 'сухие дрожжи', 'прессованные дрожжи', 'желатин', 'желатин быстрорастворимый', 'желатин листовой', 'агар-агар', ] ''' def execute_sql_file(filename, conn): with open(filename, "r", encoding="utf-8", errors="replace") as f: sql_content = f.read() statements = [] current = [] for line in sql_content.splitlines(): line = line.strip() if not line or line.startswith("--") or line.startswith("/*"): continue current.append(line) if line.endswith(";"): stmt = " ".join(current).strip() if stmt: statements.append(stmt) current = [] for stmt in statements: #stmt = stmt.replace("%", "%%") conn.execute(text(stmt)) conn.commit() def table_has_data(conn, table_name): """Возвращает True, если в таблице есть хотя бы одна запись""" result = conn.execute(text(f"SELECT EXISTS (SELECT 1 FROM {table_name} LIMIT 1)")) return result.scalar() def init_db(): try: with engine.connect() as conn: inspector = inspect(engine) tables_created = False print("🔄 Инициализация базы запущена") # иногда нада """ print("💣 Очищаем схему...") execute_sql_file(DROP_FILE, conn) """ # Создаём таблицы, если их нет if not inspector.has_table("recipes") or not inspector.has_table("ingredients"): print("⚒️ Создаём таблицы...") execute_sql_file(TABLES_FILE, conn) tables_created = True # Вставляем данные, только если таблицы пустые if not table_has_data(conn, "recipes"): print("📦 Вставляем данные в таблицы...") execute_sql_file(INSERT_TAGS_FILE, conn) print(" ✅ (1/10) Теги загружены") execute_sql_file(INSERT_CATEGORIES_FILE, conn) print(" ✅ (2/10) Категории №1 загружены") execute_sql_file(INSERT_CUISINES_FILE, conn) print(" ✅ (3/10) Категории №2 загружены") execute_sql_file(INSERT_INGREDIENTS_FILE, conn) print(" ✅ (4/10) Ингридиенты загружены") execute_sql_file(INSERT_INGREDIENT_GROUPS_FILE, conn) print(" ✅ (5/10) Группы ингридиентов загружены") execute_sql_file(INSERT_RECIPES_FILE, conn) print(" ✅ (6/10) Рецепты загружены") execute_sql_file(INSERT_RECIPE_TAGS_FILE, conn) print(" ✅ (7/10) Таблица recipe_tags для связи M2M заполнена") execute_sql_file(INSERT_INSTRUCTIONS_FILE, conn) print(" ✅ (8/10) Инструкции загружены") execute_sql_file(INSERT_RECIPE_INGREDIENT_GROUPS_FILE, conn) print(" ✅ (9/10) Таблица recipe_ingredient_groups для связи M2M заполнена") execute_sql_file(INSERT_RECIPE_INGREDIENTS_FILE, conn) print(" ✅ (10/10) Таблица recipe_ingredients для связи M2M заполнена") else: print("✅ Данные уже есть, пропускаем вставку") print("✅ Инициализация базы завершена") except Exception as e: print(f"❗️❗️❗️ Ошибка при инициализация базы: {e}") print("❌ Инициализация базы прервана") @app.on_event("startup") def on_startup(): init_db() @app.get("/") def root(): return {"message": "Привет! API работает 🚀"} @app.post("/set-models") async def set_models( primary: MODEL_NAMES = Query(..., description="Основная модель"), fallback: MODEL_NAMES = Query(..., description="Запасная модель") ): if primary == fallback: raise HTTPException(status_code=400, detail="Основная модель и запасная одна и та же?") MODEL_MANAGER.set_models(primary, fallback) return {"message": "Модели обновлены"} @app.post("/set-repeat") async def set_repeat(repeat: bool = Query(..., description="Повторять попытки при ошибках")): MODEL_MANAGER.set_repeat(repeat) return {"message": f"Повтор попыток: {'включен' if repeat else 'выключен'}"} @app.get("/settings") async def get_settings(): return MODEL_MANAGER.get_settings() @app.post("/users/", response_model=schemas.UserResponse) def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): return crud.create_user(db, user) def list_users(db: Session = Depends(get_db)): return crud.get_users(db) @app.get("/auth/login") async def login(request: Request): redirect_uri = request.url_for("auth_callback") print("Redirecting to:", redirect_uri) return await oauth.google.authorize_redirect(request, redirect_uri) @app.get("/auth/callback", name="auth_callback") async def auth_callback(request: Request): try: token = await oauth.google.authorize_access_token(request) user_info = token.get("userinfo") print("Google user:", user_info) return user_info except Exception as e: return {"error": str(e)} @app.get("/tags", response_model=list[schemas.TagResponse]) def get_tags(db: Session = Depends(get_db)): return db.query(models.Tag).all() @app.get("/categories", response_model=list[schemas.CategoryResponse]) def get_categories(db: Session = Depends(get_db)): return db.query(models.Category).all() @app.get("/cuisines", response_model=list[schemas.CuisineResponse]) def get_cuisines(db: Session = Depends(get_db)): return db.query(models.Cuisine).all() @app.post("/recipes/search/by-ingredients", response_model=list[schemas.RecipeListResponse]) def search_recipes_by_ingredients( request: schemas.IngredientSearchRequest, forbidden: Optional[schemas.IngredientSearchRequest] = None, db: Session = Depends(get_db), pagination: PaginationParams = Depends(pagination_params), ): ingredient_names = [i.name.strip().lower() for i in request.ingredients if i.name.strip()] #ingredient_names_all = ingredient_names+BASE_INGREDIENTS if forbidden is None: forbidden = schemas.IngredientSearchRequest(ingredients=[]) forbidden_names = [i.name.strip().lower() for i in forbidden.ingredients if i.name.strip()] search_conditions = [ func.lower(RecipeIngredient.name).ilike(f"%{name[:-1]}%") for name in ingredient_names ] ''' search_conditions_all = [ func.lower(RecipeIngredient.name).ilike(f"%{name[:-1]}%") for name in ingredient_names_all ] ''' subq = ( db.query( Recipe.id.label("recipe_id"), func.count(func.distinct(RecipeIngredient.id)).label("match_count") ) .join(Recipe.recipe_ingredient_groups) .join( RecipeIngredient, RecipeIngredient.recipe_group_id == RecipeIngredientGroup.id ) .filter(or_(*search_conditions)) .group_by(Recipe.id) .subquery() ) forbidden_exists_subq = None if forbidden_names: forbidden_conditions = [ func.lower(RecipeIngredient.name).ilike(f"%{name}%") for name in forbidden_names ] forbidden_exists_subq = ( db.query(RecipeIngredient.id) .join( RecipeIngredientGroup, RecipeIngredient.recipe_group_id == RecipeIngredientGroup.id ) .filter( RecipeIngredientGroup.recipe_id == Recipe.id, or_(*forbidden_conditions) ) .exists() ) query = ( db.query(Recipe, subq.c.match_count) .join(subq, Recipe.id == subq.c.recipe_id) .filter(func.regexp_replace(Recipe.source, '.*/([^/]+)/?$', '\\1').in_(FOLDERS)) ) if forbidden_exists_subq is not None: query = query.filter(~forbidden_exists_subq) recipes_with_counts = apply_pagination( query=query.order_by( subq.c.match_count.desc(), Recipe.id.asc() ), offset=pagination.offset, limit=pagination.limit, ).all() recipe_ids = [r.id for r, _ in recipes_with_counts] matched_rows = ( db.query( RecipeIngredientGroup.recipe_id, RecipeIngredient.name ) .join( RecipeIngredientGroup, RecipeIngredient.recipe_group_id == RecipeIngredientGroup.id ) .filter( RecipeIngredientGroup.recipe_id.in_(recipe_ids), or_(*search_conditions) ) .distinct() .all() ) matched_by_recipe = defaultdict(list) for recipe_id, name in matched_rows: matched_by_recipe[recipe_id].append(name) results = [] for r, match_count in recipes_with_counts: results.append( { "id": r.id, "title": r.title, "category_name": r.category_name, "cuisine_name": r.cuisine_name, "poster": r.poster, "difficulty": r.difficulty, "cooktime": r.cooktime, "vegan": r.vegan, "created_at": r.created_at, "match_count": match_count, "matched_ingredients": matched_by_recipe.get(r.id, []), "views": r.views, "likes": r.likes, } ) return results @app.get("/recipes/all", response_model=list[schemas.RecipeListResponse]) def get_recipes( db: Session = Depends(get_db), pagination: PaginationParams = Depends(pagination_params), ): query = ( db.query(models.Recipe) .filter( func.regexp_replace( Recipe.source, '.*/([^/]+)/?$', '\\1' ).in_(FOLDERS) ) .order_by(models.Recipe.id) ) return apply_pagination( query=query, limit=pagination.limit, offset=pagination.offset, ).all() @app.get("/recipes/search/{search_word}", response_model=list[schemas.RecipeListResponse]) def get_recipes_by_word( search_word: str, db: Session = Depends(get_db), pagination: PaginationParams = Depends(pagination_params), ): query = ( db.query(models.Recipe) .filter( models.Recipe.title.ilike(f"%{search_word}%")) .filter( func.regexp_replace(Recipe.source, '.*/([^/]+)/?$', '\\1').in_(FOLDERS) ) .order_by(models.Recipe.id) ) recipes = apply_pagination( query=query, limit=pagination.limit, offset=pagination.offset, ).all() if not recipes: raise HTTPException(status_code=404, detail="Рецепты не найдены") return recipes @app.get("/recipes/by_views", response_model=list[schemas.RecipeListResponse]) def get_recept_by_views( db: Session = Depends(get_db), pagination: PaginationParams = Depends(pagination_params), ): query = ( db.query(models.Recipe) .filter( func.regexp_replace( Recipe.source, '.*/([^/]+)/?$', '\\1' ).in_(FOLDERS) ) .order_by(desc(models.Recipe.views)) ) return apply_pagination( query=query, limit=pagination.limit, offset=pagination.offset, ).all() @app.get("/recipes/by_likes", response_model=list[schemas.RecipeListResponse]) def get_recept_by_likes( db: Session = Depends(get_db), pagination: PaginationParams = Depends(pagination_params), ): query = ( db.query(models.Recipe) .filter( func.regexp_replace( Recipe.source, '.*/([^/]+)/?$', '\\1' ).in_(FOLDERS) ) .order_by(desc(models.Recipe.likes)) ) return apply_pagination( query=query, limit=pagination.limit, offset=pagination.offset, ).all() @app.post("/recipes/{recipe_id}/like", response_model=schemas.RecipeListResponse) def add_like_to_recipe( recipe_id: int, db: Session = Depends(get_db), ): stmt = ( update(models.Recipe) .where(models.Recipe.id == recipe_id) .values(likes=models.Recipe.likes + 1) .returning(models.Recipe) ) result = db.execute(stmt).first() if not result: raise HTTPException(status_code=404, detail="Recipe not found or not in allowed folders") db.commit() return result[0] @app.post("/recipes/{recipe_id}/view", response_model=schemas.RecipeListResponse) def add_view_to_recipe( recipe_id: int, db: Session = Depends(get_db), ): stmt = ( update(models.Recipe) .where(models.Recipe.id == recipe_id) .values(views=models.Recipe.views + 1) .returning(models.Recipe) ) result = db.execute(stmt).first() if not result: raise HTTPException(status_code=404, detail="Recipe not found or not in allowed folders") db.commit() return result[0] @app.get("/recipes/{recept_id}", response_model=schemas.RecipeResponse) def get_recept(recept_id: int, db: Session = Depends(get_db)): recept = db.query(models.Recipe).filter(models.Recipe.id == recept_id).first() if not recept: raise HTTPException(status_code=404, detail="Рецепт не найден") return recept @app.post("/upload-photo/") async def upload_photo(file: UploadFile = File(...), db: Session = Depends(get_db)): if not file.content_type.startswith('image/'): raise HTTPException(status_code=400, detail="Файл должен быть изображением") image_data = await file.read() async with httpx.AsyncClient(timeout=30.0) as client: try: usage_models = [MODEL_MANAGER.primary_model] files = {'file': (file.filename, image_data, file.content_type)} for llm_model in usage_models: try: response = await client.post( ML_URL, files=files, params={'engine': "api", 'llm_model': llm_model} ) response.raise_for_status() if response.status_code == 200: break except httpx.HTTPError: continue if response.status_code >= 400: response = await client.post( ML_URL, files=files, params={'engine': "model", 'llm_model': usage_models[0]} ) response.raise_for_status() result = response.json() ingredients = [detection["class_name"] for detection in result["detections"]] if len(ingredients) == 0: return JSONResponse( status_code=200, content={ "message": "Ингридиентов не обнаружено", "found_ingredients_count": 0, "ingredients": [] } ) tr_ingredients = translate_batch(ingredients) return JSONResponse( status_code=200, content={ "message": "Фото успешно обработано", "found_ingredients_count": len(tr_ingredients), "ingredients": tr_ingredients } ) """ found_recipes = [] find_recipes_by_ingredients_precise(tr_ingredients, db, limit=20) return { "message": "Фото успешно обработано", "detected_ingredients": tr_ingredients, "found_recipes_count": len(found_recipes), "recipes": [ { "id": recipe.id_recepts, "name": recipe.recept_name, "ingredients": recipe.recept_sostav, "instructions": recipe.recept_instuction, "category_id": recipe.recept_category, "podcategory": recipe.podcategory } for recipe in found_recipes ] } """ except httpx.TimeoutException: raise HTTPException(status_code=504, detail="Таймаут при обращении к внешнему сервису") except Exception as e: raise HTTPException(status_code=502, detail=f"Ошибка внешнего сервиса: {str(e)}")