mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat(backend): ✨ Minor linting, bulk URL import, and improve BG tasks (#760)
* Fixes #751 * Fixes not showing original URL * start slice at 0 instead of 1 * remove print statements * add linter for print statements and remove print * hide all buttons when edit disabled * add bulk import API * update attribute bindings * unify button styles * bulk add recipe feature * thanks linter! * uncomment code Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
		| @@ -1,4 +1,6 @@ | |||||||
| import { BaseCRUDAPI } from "../_base"; | import { BaseCRUDAPI } from "../_base"; | ||||||
|  | import { Category } from "./categories"; | ||||||
|  | import { Tag } from "./tags"; | ||||||
| import { Recipe, CreateRecipe } from "~/types/api-types/recipe"; | import { Recipe, CreateRecipe } from "~/types/api-types/recipe"; | ||||||
|  |  | ||||||
| const prefix = "/api"; | const prefix = "/api"; | ||||||
| @@ -8,6 +10,7 @@ const routes = { | |||||||
|   recipesBase: `${prefix}/recipes`, |   recipesBase: `${prefix}/recipes`, | ||||||
|   recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`, |   recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`, | ||||||
|   recipesCreateUrl: `${prefix}/recipes/create-url`, |   recipesCreateUrl: `${prefix}/recipes/create-url`, | ||||||
|  |   recipesCreateUrlBulk: `${prefix}/recipes/create-url/bulk`, | ||||||
|   recipesCreateFromZip: `${prefix}/recipes/create-from-zip`, |   recipesCreateFromZip: `${prefix}/recipes/create-from-zip`, | ||||||
|   recipesCategory: `${prefix}/recipes/category`, |   recipesCategory: `${prefix}/recipes/category`, | ||||||
|   recipesParseIngredient: `${prefix}/parser/ingredient`, |   recipesParseIngredient: `${prefix}/parser/ingredient`, | ||||||
| @@ -59,6 +62,16 @@ export interface ParsedIngredient { | |||||||
|   ingredient: Ingredient; |   ingredient: Ingredient; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export interface BulkCreateRecipe { | ||||||
|  |   url: string; | ||||||
|  |   categories: Category[]; | ||||||
|  |   tags: Tag[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface BulkCreatePayload { | ||||||
|  |   imports: BulkCreateRecipe[]; | ||||||
|  | } | ||||||
|  |  | ||||||
| export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> { | export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> { | ||||||
|   baseRoute: string = routes.recipesBase; |   baseRoute: string = routes.recipesBase; | ||||||
|   itemRoute = routes.recipesRecipeSlug; |   itemRoute = routes.recipesRecipeSlug; | ||||||
| @@ -90,6 +103,10 @@ export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> { | |||||||
|     return await this.requests.post(routes.recipesCreateUrl, { url }); |     return await this.requests.post(routes.recipesCreateUrl, { url }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   async createManyByUrl(payload: BulkCreatePayload) { | ||||||
|  |     return await this.requests.post(routes.recipesCreateUrlBulk, payload); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   // Recipe Comments |   // Recipe Comments | ||||||
|  |  | ||||||
|   // Methods to Generate reference urls for assets/images * |   // Methods to Generate reference urls for assets/images * | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | //TODO: Prevent fetching Categories/Tags multiple time when selector is on page multiple times | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <v-autocomplete |   <v-autocomplete | ||||||
|     v-model="selected" |     v-model="selected" | ||||||
| @@ -14,12 +16,14 @@ | |||||||
|     :solo="solo" |     :solo="solo" | ||||||
|     :return-object="returnObject" |     :return-object="returnObject" | ||||||
|     :flat="flat" |     :flat="flat" | ||||||
|  |     v-bind="$attrs" | ||||||
|     @input="emitChange" |     @input="emitChange" | ||||||
|   > |   > | ||||||
|     <template #selection="data"> |     <template #selection="data"> | ||||||
|       <v-chip |       <v-chip | ||||||
|         v-if="showSelected" |         v-if="showSelected" | ||||||
|         :key="data.index" |         :key="data.index" | ||||||
|  |         :small="dense" | ||||||
|         class="ma-1" |         class="ma-1" | ||||||
|         :input-value="data.selected" |         :input-value="data.selected" | ||||||
|         close |         close | ||||||
|   | |||||||
| @@ -26,9 +26,7 @@ | |||||||
|     </v-card> |     </v-card> | ||||||
|  |  | ||||||
|     <div v-if="edit" class="d-flex justify-end"> |     <div v-if="edit" class="d-flex justify-end"> | ||||||
|       <v-btn class="mt-1" color="secondary" dark @click="addNote"> |       <BaseButton class="ml-auto my-2" @click="addNote"> {{ $t("general.new") }}</BaseButton> | ||||||
|         <v-icon>{{ $globals.icons.create }}</v-icon> |  | ||||||
|       </v-btn> |  | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -92,7 +92,7 @@ | |||||||
|           @end="onMoveCallback" |           @end="onMoveCallback" | ||||||
|         > |         > | ||||||
|           <v-card v-for="mealplan in plan.meals" :key="mealplan.id" v-model="hover[mealplan.id]" class="my-1"> |           <v-card v-for="mealplan in plan.meals" :key="mealplan.id" v-model="hover[mealplan.id]" class="my-1"> | ||||||
|             <v-list-item> |             <v-list-item :to="edit ? null : `/recipe/${mealplan.recipe.slug}`"> | ||||||
|               <v-list-item-avatar :rounded="false"> |               <v-list-item-avatar :rounded="false"> | ||||||
|                 <RecipeCardImage v-if="mealplan.recipe" tiny icon-size="25" :slug="mealplan.recipe.slug" /> |                 <RecipeCardImage v-if="mealplan.recipe" tiny icon-size="25" :slug="mealplan.recipe.slug" /> | ||||||
|                 <v-icon v-else> |                 <v-icon v-else> | ||||||
| @@ -108,8 +108,8 @@ | |||||||
|                 </v-list-item-subtitle> |                 </v-list-item-subtitle> | ||||||
|               </v-list-item-content> |               </v-list-item-content> | ||||||
|             </v-list-item> |             </v-list-item> | ||||||
|             <v-divider class="mx-2"></v-divider> |             <v-divider v-if="edit" class="mx-2"></v-divider> | ||||||
|             <v-card-actions> |             <v-card-actions v-if="edit"> | ||||||
|               <v-btn color="error" icon @click="actions.deleteOne(mealplan.id)"> |               <v-btn color="error" icon @click="actions.deleteOne(mealplan.id)"> | ||||||
|                 <v-icon>{{ $globals.icons.delete }}</v-icon> |                 <v-icon>{{ $globals.icons.delete }}</v-icon> | ||||||
|               </v-btn> |               </v-btn> | ||||||
|   | |||||||
| @@ -110,8 +110,8 @@ | |||||||
|               /> |               /> | ||||||
|             </draggable> |             </draggable> | ||||||
|             <div class="d-flex justify-end mt-2"> |             <div class="d-flex justify-end mt-2"> | ||||||
|               <RecipeIngredientParserMenu class="mr-1" :slug="recipe.slug" :ingredients="recipe.recipeIngredient" /> |               <RecipeIngredientParserMenu :slug="recipe.slug" :ingredients="recipe.recipeIngredient" /> | ||||||
|               <RecipeDialogBulkAdd class="mr-1" @bulk-data="addIngredient" /> |               <RecipeDialogBulkAdd class="ml-1 mr-1" @bulk-data="addIngredient" /> | ||||||
|               <BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton> |               <BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
| @@ -228,6 +228,30 @@ | |||||||
|               <RecipeNotes v-model="recipe.notes" :edit="form" /> |               <RecipeNotes v-model="recipe.notes" :edit="form" /> | ||||||
|             </v-col> |             </v-col> | ||||||
|           </v-row> |           </v-row> | ||||||
|  |  | ||||||
|  |           <v-card-actions class="justify-end"> | ||||||
|  |             <v-text-field | ||||||
|  |               v-if="form" | ||||||
|  |               v-model="recipe.orgURL" | ||||||
|  |               class="mt-10" | ||||||
|  |               :label="$t('recipe.original-url')" | ||||||
|  |             ></v-text-field> | ||||||
|  |             <v-btn | ||||||
|  |               v-else-if="recipe.orgURL" | ||||||
|  |               dense | ||||||
|  |               small | ||||||
|  |               :hover="false" | ||||||
|  |               type="label" | ||||||
|  |               :ripple="false" | ||||||
|  |               elevation="0" | ||||||
|  |               :href="recipe.orgURL" | ||||||
|  |               color="secondary darken-1" | ||||||
|  |               target="_blank" | ||||||
|  |               class="rounded-sm mr-4" | ||||||
|  |             > | ||||||
|  |               {{ $t("recipe.original-url") }} | ||||||
|  |             </v-btn> | ||||||
|  |           </v-card-actions> | ||||||
|         </v-card-text> |         </v-card-text> | ||||||
|       </div> |       </div> | ||||||
|     </v-card> |     </v-card> | ||||||
|   | |||||||
| @@ -142,7 +142,7 @@ | |||||||
|           <v-tab-item value="debug" eager> |           <v-tab-item value="debug" eager> | ||||||
|             <v-form ref="domUrlForm" @submit.prevent="debugUrl(recipeUrl)"> |             <v-form ref="domUrlForm" @submit.prevent="debugUrl(recipeUrl)"> | ||||||
|               <v-card flat> |               <v-card flat> | ||||||
|                 <v-card-title class="headline"> Recipe Debugger</v-card-title> |                 <v-card-title class="headline"> Recipe Importer </v-card-title> | ||||||
|                 <v-card-text> |                 <v-card-text> | ||||||
|                   Grab the URL of the recipe you want to debug and paste it here. The URL will be scraped by the recipe |                   Grab the URL of the recipe you want to debug and paste it here. The URL will be scraped by the recipe | ||||||
|                   scraper and the results will be displayed. If you don't see any data returned, the site you are trying |                   scraper and the results will be displayed. If you don't see any data returned, the site you are trying | ||||||
| @@ -174,6 +174,17 @@ | |||||||
|               </v-card> |               </v-card> | ||||||
|             </v-form> |             </v-form> | ||||||
|           </v-tab-item> |           </v-tab-item> | ||||||
|  |  | ||||||
|  |           <v-tab-item value="bulk" eager> | ||||||
|  |             <v-card flat> | ||||||
|  |               <v-card-title class="headline"> Recipe Bulk Importer </v-card-title> | ||||||
|  |               <v-card-text> | ||||||
|  |                 The Bulk recipe importer allows you to import multiple recipes at once by queing the sites on the | ||||||
|  |                 backend and running the task in the background. This can be useful when initially migrating to Mealie, | ||||||
|  |                 or when you want to import a large number of recipes. | ||||||
|  |               </v-card-text> | ||||||
|  |             </v-card> | ||||||
|  |           </v-tab-item> | ||||||
|         </v-tabs-items> |         </v-tabs-items> | ||||||
|       </section> |       </section> | ||||||
|       <v-divider class="mt-5"></v-divider> |       <v-divider class="mt-5"></v-divider> | ||||||
| @@ -195,6 +206,74 @@ | |||||||
|           height="700px" |           height="700px" | ||||||
|         /> |         /> | ||||||
|       </section> |       </section> | ||||||
|  |       <!--  Debug Extras --> | ||||||
|  |       <section v-else-if="tab === 'bulk'" class="mt-2"> | ||||||
|  |         <v-row v-for="(bulkUrl, idx) in bulkUrls" :key="'bulk-url' + idx" class="my-1" dense> | ||||||
|  |           <v-col cols="12" xs="12" sm="12" md="12"> | ||||||
|  |             <v-text-field | ||||||
|  |               v-model="bulkUrls[idx].url" | ||||||
|  |               :label="$t('new-recipe.recipe-url')" | ||||||
|  |               dense | ||||||
|  |               single-line | ||||||
|  |               validate-on-blur | ||||||
|  |               autofocus | ||||||
|  |               filled | ||||||
|  |               hide-details | ||||||
|  |               clearable | ||||||
|  |               :prepend-inner-icon="$globals.icons.link" | ||||||
|  |               rounded | ||||||
|  |               class="rounded-lg" | ||||||
|  |             > | ||||||
|  |               <template #append> | ||||||
|  |                 <v-btn color="error" icon x-small @click="bulkUrls.splice(idx, 1)"> | ||||||
|  |                   <v-icon> | ||||||
|  |                     {{ $globals.icons.delete }} | ||||||
|  |                   </v-icon> | ||||||
|  |                 </v-btn> | ||||||
|  |               </template> | ||||||
|  |             </v-text-field> | ||||||
|  |           </v-col> | ||||||
|  |           <v-col cols="12" xs="12" sm="6"> | ||||||
|  |             <RecipeCategoryTagSelector | ||||||
|  |               v-model="bulkUrls[idx].categories" | ||||||
|  |               validate-on-blur | ||||||
|  |               autofocus | ||||||
|  |               single-line | ||||||
|  |               filled | ||||||
|  |               hide-details | ||||||
|  |               dense | ||||||
|  |               clearable | ||||||
|  |               rounded | ||||||
|  |               class="rounded-lg" | ||||||
|  |             ></RecipeCategoryTagSelector> | ||||||
|  |           </v-col> | ||||||
|  |           <v-col cols="12" xs="12" sm="6"> | ||||||
|  |             <RecipeCategoryTagSelector | ||||||
|  |               v-model="bulkUrls[idx].tags" | ||||||
|  |               validate-on-blur | ||||||
|  |               autofocus | ||||||
|  |               tag-selector | ||||||
|  |               hide-details | ||||||
|  |               filled | ||||||
|  |               dense | ||||||
|  |               single-line | ||||||
|  |               clearable | ||||||
|  |               rounded | ||||||
|  |               class="rounded-lg" | ||||||
|  |             ></RecipeCategoryTagSelector> | ||||||
|  |           </v-col> | ||||||
|  |         </v-row> | ||||||
|  |         <v-card-actions class="justify-end"> | ||||||
|  |           <BaseButton delete @click="bulkUrls = []"> Clear </BaseButton> | ||||||
|  |           <v-spacer></v-spacer> | ||||||
|  |           <BaseButton color="info" @click="bulkUrls.push({ url: '', categories: [], tags: [] })"> | ||||||
|  |             <template #icon> {{ $globals.icons.createAlt }} </template> New | ||||||
|  |           </BaseButton> | ||||||
|  |           <BaseButton :disabled="bulkUrls.length === 0" @click="bulkCreate"> | ||||||
|  |             <template #icon> {{ $globals.icons.check }} </template> Submit | ||||||
|  |           </BaseButton> | ||||||
|  |         </v-card-actions> | ||||||
|  |       </section> | ||||||
|     </v-container> |     </v-container> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| @@ -204,10 +283,12 @@ import { defineComponent, reactive, toRefs, ref, useRouter, useContext } from "@ | |||||||
| // @ts-ignore No Types for v-jsoneditor | // @ts-ignore No Types for v-jsoneditor | ||||||
| import VJsoneditor from "v-jsoneditor"; | import VJsoneditor from "v-jsoneditor"; | ||||||
| import { useApiSingleton } from "~/composables/use-api"; | import { useApiSingleton } from "~/composables/use-api"; | ||||||
|  | import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue"; | ||||||
| import { validators } from "~/composables/use-validators"; | import { validators } from "~/composables/use-validators"; | ||||||
| import { Recipe } from "~/types/api-types/recipe"; | import { Recipe } from "~/types/api-types/recipe"; | ||||||
|  | import { alert } from "~/composables/use-toast"; | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { VJsoneditor }, |   components: { VJsoneditor, RecipeCategoryTagSelector }, | ||||||
|   setup() { |   setup() { | ||||||
|     const state = reactive({ |     const state = reactive({ | ||||||
|       error: false, |       error: false, | ||||||
| @@ -233,6 +314,11 @@ export default defineComponent({ | |||||||
|         text: "Import with .zip", |         text: "Import with .zip", | ||||||
|         value: "zip", |         value: "zip", | ||||||
|       }, |       }, | ||||||
|  |       { | ||||||
|  |         icon: $globals.icons.link, | ||||||
|  |         text: "Bulk URL Import", | ||||||
|  |         value: "bulk", | ||||||
|  |       }, | ||||||
|       { |       { | ||||||
|         icon: $globals.icons.robot, |         icon: $globals.icons.robot, | ||||||
|         text: "Debug Scraper", |         text: "Debug Scraper", | ||||||
| @@ -249,7 +335,6 @@ export default defineComponent({ | |||||||
|         state.loading = false; |         state.loading = false; | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|       console.log(response); |  | ||||||
|       router.push(`/recipe/${response.data}`); |       router.push(`/recipe/${response.data}`); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -300,7 +385,6 @@ export default defineComponent({ | |||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|       const { response } = await api.recipes.createOne({ name }); |       const { response } = await api.recipes.createOne({ name }); | ||||||
|       console.log("Create By Name Func", response); |  | ||||||
|       handleResponse(response); |       handleResponse(response); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -318,11 +402,31 @@ export default defineComponent({ | |||||||
|       formData.append(newRecipeZipFileName, newRecipeZip.value); |       formData.append(newRecipeZipFileName, newRecipeZip.value); | ||||||
|  |  | ||||||
|       const { response } = await api.upload.file("/api/recipes/create-from-zip", formData); |       const { response } = await api.upload.file("/api/recipes/create-from-zip", formData); | ||||||
|       console.log(response); |  | ||||||
|       handleResponse(response); |       handleResponse(response); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // =================================================== | ||||||
|  |     // Bulk Importer | ||||||
|  |  | ||||||
|  |     const bulkUrls = ref([{ url: "", categories: [], tags: [] }]); | ||||||
|  |  | ||||||
|  |     async function bulkCreate() { | ||||||
|  |       if (bulkUrls.value.length === 0) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const { response } = await api.recipes.createManyByUrl({ imports: bulkUrls.value }); | ||||||
|  |  | ||||||
|  |       if (response?.status === 202) { | ||||||
|  |         alert.success("Bulk Import process has started"); | ||||||
|  |       } else { | ||||||
|  |         alert.error("Bulk import process has failed"); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return { |     return { | ||||||
|  |       bulkCreate, | ||||||
|  |       bulkUrls, | ||||||
|       debugTreeView, |       debugTreeView, | ||||||
|       tabs, |       tabs, | ||||||
|       domCreateByName, |       domCreateByName, | ||||||
|   | |||||||
| @@ -21,7 +21,7 @@ import { useLazyRecipes } from "~/composables/use-recipes"; | |||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { RecipeCardSection }, |   components: { RecipeCardSection }, | ||||||
|   setup() { |   setup() { | ||||||
|     const start = ref(1); |     const start = ref(0); | ||||||
|     const limit = ref(30); |     const limit = ref(30); | ||||||
|     const increment = ref(30); |     const increment = ref(30); | ||||||
|     const ready = ref(false); |     const ready = ref(false); | ||||||
|   | |||||||
| @@ -1,9 +1,11 @@ | |||||||
| import json | import json | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  |  | ||||||
|  | from mealie.core.root_logger import get_logger | ||||||
| from mealie.db.data_access_layer.access_model_factory import Database | from mealie.db.data_access_layer.access_model_factory import Database | ||||||
|  |  | ||||||
| CWD = Path(__file__).parent | CWD = Path(__file__).parent | ||||||
|  | logger = get_logger(__name__) | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_default_foods(): | def get_default_foods(): | ||||||
| @@ -23,10 +25,10 @@ def default_recipe_unit_init(db: Database) -> None: | |||||||
|         try: |         try: | ||||||
|             db.ingredient_units.create(unit) |             db.ingredient_units.create(unit) | ||||||
|         except Exception as e: |         except Exception as e: | ||||||
|             print(e) |             logger.error(e) | ||||||
|  |  | ||||||
|     for food in get_default_foods(): |     for food in get_default_foods(): | ||||||
|         try: |         try: | ||||||
|             db.ingredient_foods.create(food) |             db.ingredient_foods.create(food) | ||||||
|         except Exception as e: |         except Exception as e: | ||||||
|             print(e) |             logger.error(e) | ||||||
|   | |||||||
| @@ -33,7 +33,6 @@ async def check_email_config(): | |||||||
|  |  | ||||||
| @router.post("", response_model=EmailSuccess) | @router.post("", response_model=EmailSuccess) | ||||||
| async def send_test_email(data: EmailTest): | async def send_test_email(data: EmailTest): | ||||||
|     print(data) |  | ||||||
|     service = EmailService() |     service = EmailService() | ||||||
|     status = False |     status = False | ||||||
|     error = None |     error = None | ||||||
|   | |||||||
| @@ -8,17 +8,14 @@ from sqlalchemy.orm.session import Session | |||||||
| from mealie.db.database import get_database | from mealie.db.database import get_database | ||||||
| from mealie.db.db_setup import generate_session | from mealie.db.db_setup import generate_session | ||||||
| from mealie.routes.routers import UserAPIRouter | from mealie.routes.routers import UserAPIRouter | ||||||
| from mealie.schema.recipe import CreateRecipeByURL, Recipe, RecipeAsset | from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeAsset | ||||||
| from mealie.services.image.image import scrape_image, write_image | from mealie.services.image.image import scrape_image, write_image | ||||||
|  |  | ||||||
| user_router = UserAPIRouter() | user_router = UserAPIRouter() | ||||||
|  |  | ||||||
|  |  | ||||||
| @user_router.post("/{slug}/image") | @user_router.post("/{slug}/image") | ||||||
| def scrape_image_url( | def scrape_image_url(slug: str, url: CreateRecipeByUrl): | ||||||
|     slug: str, |  | ||||||
|     url: CreateRecipeByURL, |  | ||||||
| ): |  | ||||||
|     """ Removes an existing image and replaces it with the incoming file. """ |     """ Removes an existing image and replaces it with the incoming file. """ | ||||||
|  |  | ||||||
|     scrape_image(url.url, slug) |     scrape_image(url.url, slug) | ||||||
|   | |||||||
| @@ -12,10 +12,12 @@ from mealie.core.root_logger import get_logger | |||||||
| from mealie.db.database import get_database | from mealie.db.database import get_database | ||||||
| from mealie.db.db_setup import generate_session | from mealie.db.db_setup import generate_session | ||||||
| from mealie.routes.routers import UserAPIRouter | from mealie.routes.routers import UserAPIRouter | ||||||
| from mealie.schema.recipe import CreateRecipeByURL, Recipe, RecipeImageTypes | from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeImageTypes | ||||||
| from mealie.schema.recipe.recipe import CreateRecipe, RecipeSummary | from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary | ||||||
|  | from mealie.schema.server.tasks import ServerTaskNames | ||||||
| from mealie.services.recipe.recipe_service import RecipeService | from mealie.services.recipe.recipe_service import RecipeService | ||||||
| from mealie.services.scraper.scraper import create_from_url, scrape_from_url | from mealie.services.scraper.scraper import create_from_url, scrape_from_url | ||||||
|  | from mealie.services.server_tasks.background_executory import BackgroundExecutor | ||||||
|  |  | ||||||
| user_router = UserAPIRouter() | user_router = UserAPIRouter() | ||||||
| logger = get_logger() | logger = get_logger() | ||||||
| @@ -34,15 +36,55 @@ def create_from_name(data: CreateRecipe, recipe_service: RecipeService = Depends | |||||||
|  |  | ||||||
|  |  | ||||||
| @user_router.post("/create-url", status_code=201, response_model=str) | @user_router.post("/create-url", status_code=201, response_model=str) | ||||||
| def parse_recipe_url(url: CreateRecipeByURL, recipe_service: RecipeService = Depends(RecipeService.private)): | def parse_recipe_url(url: CreateRecipeByUrl, recipe_service: RecipeService = Depends(RecipeService.private)): | ||||||
|     """ Takes in a URL and attempts to scrape data and load it into the database """ |     """ Takes in a URL and attempts to scrape data and load it into the database """ | ||||||
|  |  | ||||||
|     recipe = create_from_url(url.url) |     recipe = create_from_url(url.url) | ||||||
|     return recipe_service.create_one(recipe).slug |     return recipe_service.create_one(recipe).slug | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @user_router.post("/create-url/bulk", status_code=202) | ||||||
|  | def parse_recipe_url_bulk( | ||||||
|  |     bulk: CreateRecipeByUrlBulk, | ||||||
|  |     recipe_service: RecipeService = Depends(RecipeService.private), | ||||||
|  |     bg_service: BackgroundExecutor = Depends(BackgroundExecutor.private), | ||||||
|  | ): | ||||||
|  |     """ Takes in a URL and attempts to scrape data and load it into the database """ | ||||||
|  |  | ||||||
|  |     def bulk_import_func(task_id: int, session: Session) -> None: | ||||||
|  |         database = get_database(session) | ||||||
|  |         task = database.server_tasks.get_one(task_id) | ||||||
|  |  | ||||||
|  |         task.append_log("test task has started") | ||||||
|  |  | ||||||
|  |         for b in bulk.imports: | ||||||
|  |             try: | ||||||
|  |                 recipe = create_from_url(b.url) | ||||||
|  |  | ||||||
|  |                 if b.tags: | ||||||
|  |                     recipe.tags = b.tags | ||||||
|  |  | ||||||
|  |                 if b.categories: | ||||||
|  |                     recipe.recipe_category = b.categories | ||||||
|  |  | ||||||
|  |                 recipe_service.create_one(recipe) | ||||||
|  |                 task.append_log(f"INFO: Created recipe from url: {b.url}") | ||||||
|  |             except Exception as e: | ||||||
|  |                 task.append_log(f"Error: Failed to create recipe from url: {b.url}") | ||||||
|  |                 task.append_log(f"Error: {e}") | ||||||
|  |                 logger.error(f"Failed to create recipe from url: {b.url}") | ||||||
|  |                 logger.error(e) | ||||||
|  |             database.server_tasks.update(task.id, task) | ||||||
|  |  | ||||||
|  |         task.set_finished() | ||||||
|  |         database.server_tasks.update(task.id, task) | ||||||
|  |  | ||||||
|  |     bg_service.dispatch(ServerTaskNames.bulk_recipe_import, bulk_import_func) | ||||||
|  |  | ||||||
|  |     return {"details": "task has been started"} | ||||||
|  |  | ||||||
|  |  | ||||||
| @user_router.post("/test-scrape-url") | @user_router.post("/test-scrape-url") | ||||||
| def test_parse_recipe_url(url: CreateRecipeByURL): | def test_parse_recipe_url(url: CreateRecipeByUrl): | ||||||
|     # Debugger should produce the same result as the scraper sees before cleaning |     # Debugger should produce the same result as the scraper sees before cleaning | ||||||
|     scraped_data = scrape_from_url(url.url) |     scraped_data = scrape_from_url(url.url) | ||||||
|     if scraped_data: |     if scraped_data: | ||||||
| @@ -73,11 +115,8 @@ async def get_recipe_as_zip( | |||||||
| ): | ): | ||||||
|     """ Get a Recipe and It's Original Image as a Zip File """ |     """ Get a Recipe and It's Original Image as a Zip File """ | ||||||
|     db = get_database(session) |     db = get_database(session) | ||||||
|  |  | ||||||
|     recipe: Recipe = db.recipes.get(slug) |     recipe: Recipe = db.recipes.get(slug) | ||||||
|  |  | ||||||
|     image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value) |     image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value) | ||||||
|  |  | ||||||
|     with ZipFile(temp_path, "w") as myzip: |     with ZipFile(temp_path, "w") as myzip: | ||||||
|         myzip.writestr(f"{slug}.json", recipe.json()) |         myzip.writestr(f"{slug}.json", recipe.json()) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -25,7 +25,6 @@ class CreatePlanEntry(CamelModel): | |||||||
|     @validator("recipe_id", always=True) |     @validator("recipe_id", always=True) | ||||||
|     @classmethod |     @classmethod | ||||||
|     def id_or_title(cls, value, values): |     def id_or_title(cls, value, values): | ||||||
|         print(value, values) |  | ||||||
|         if bool(value) is False and bool(values["title"]) is False: |         if bool(value) is False and bool(values["title"]) is False: | ||||||
|             raise ValueError(f"`recipe_id={value}` or `title={values['title']}` must be provided") |             raise ValueError(f"`recipe_id={value}` or `title={values['title']}` must be provided") | ||||||
|  |  | ||||||
|   | |||||||
| @@ -21,17 +21,6 @@ from .recipe_step import RecipeStep | |||||||
| app_dirs = get_app_dirs() | app_dirs = get_app_dirs() | ||||||
|  |  | ||||||
|  |  | ||||||
| class CreateRecipeByURL(BaseModel): |  | ||||||
|     url: str |  | ||||||
|  |  | ||||||
|     class Config: |  | ||||||
|         schema_extra = {"example": {"url": "https://myfavoriterecipes.com/recipes"}} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class CreateRecipe(CamelModel): |  | ||||||
|     name: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class RecipeTag(CamelModel): | class RecipeTag(CamelModel): | ||||||
|     name: str |     name: str | ||||||
|     slug: str |     slug: str | ||||||
| @@ -44,6 +33,27 @@ class RecipeCategory(RecipeTag): | |||||||
|     pass |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CreateRecipeByUrl(BaseModel): | ||||||
|  |     url: str | ||||||
|  |  | ||||||
|  |     class Config: | ||||||
|  |         schema_extra = {"example": {"url": "https://myfavoriterecipes.com/recipes"}} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CreateRecipeBulk(BaseModel): | ||||||
|  |     url: str | ||||||
|  |     categories: list[RecipeCategory] = None | ||||||
|  |     tags: list[RecipeTag] = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CreateRecipeByUrlBulk(BaseModel): | ||||||
|  |     imports: list[CreateRecipeBulk] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CreateRecipe(CamelModel): | ||||||
|  |     name: str | ||||||
|  |  | ||||||
|  |  | ||||||
| class RecipeSummary(CamelModel): | class RecipeSummary(CamelModel): | ||||||
|     id: Optional[int] |     id: Optional[int] | ||||||
|  |  | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ from pydantic import Field | |||||||
| class ServerTaskNames(str, enum.Enum): | class ServerTaskNames(str, enum.Enum): | ||||||
|     default = "Background Task" |     default = "Background Task" | ||||||
|     backup_task = "Database Backup" |     backup_task = "Database Backup" | ||||||
|  |     bulk_recipe_import = "Bulk Recipe Import" | ||||||
|  |  | ||||||
|  |  | ||||||
| class ServerTaskStatus(str, enum.Enum): | class ServerTaskStatus(str, enum.Enum): | ||||||
|   | |||||||
| @@ -37,6 +37,7 @@ class CrudHttpMixins(Generic[C, R, U], ABC): | |||||||
|             self.item = self.dal.create(data) |             self.item = self.dal.create(data) | ||||||
|         except Exception as ex: |         except Exception as ex: | ||||||
|             logger.exception(ex) |             logger.exception(ex) | ||||||
|  |             self.session.rollback() | ||||||
|  |  | ||||||
|             msg = default_msg |             msg = default_msg | ||||||
|             if exception_msgs: |             if exception_msgs: | ||||||
|   | |||||||
| @@ -73,14 +73,3 @@ class EmailService(BaseService): | |||||||
|             button_text="Test Email", |             button_text="Test Email", | ||||||
|         ) |         ) | ||||||
|         return self.send_email(address, test_email) |         return self.send_email(address, test_email) | ||||||
|  |  | ||||||
|  |  | ||||||
| def main(): |  | ||||||
|     print("Starting...") |  | ||||||
|     service = EmailService() |  | ||||||
|     service.send_test_email("hay-kot@pm.me") |  | ||||||
|     print("Finished...") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": |  | ||||||
|     main() |  | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ replace_abbreviations = { | |||||||
| def replace_common_abbreviations(string: str) -> str: | def replace_common_abbreviations(string: str) -> str: | ||||||
|  |  | ||||||
|     for k, v in replace_abbreviations.items(): |     for k, v in replace_abbreviations.items(): | ||||||
|         regex = rf"(?<=\d)\s?({k}s?)" |         regex = rf"(?<=\d)\s?({k}\bs?)" | ||||||
|         string = re.sub(regex, v, string) |         string = re.sub(regex, v, string) | ||||||
|  |  | ||||||
|     return string |     return string | ||||||
|   | |||||||
| @@ -43,13 +43,9 @@ def clean_string(text: str) -> str: | |||||||
|     if isinstance(text, list): |     if isinstance(text, list): | ||||||
|         text = text[0] |         text = text[0] | ||||||
|  |  | ||||||
|     print(type(text)) |  | ||||||
|  |  | ||||||
|     if text == "" or text is None: |     if text == "" or text is None: | ||||||
|         return "" |         return "" | ||||||
|  |  | ||||||
|     print(text) |  | ||||||
|  |  | ||||||
|     cleaned_text = html.unescape(text) |     cleaned_text = html.unescape(text) | ||||||
|     cleaned_text = re.sub("<[^<]+?>", "", cleaned_text) |     cleaned_text = re.sub("<[^<]+?>", "", cleaned_text) | ||||||
|     cleaned_text = re.sub(" +", " ", cleaned_text) |     cleaned_text = re.sub(" +", " ", cleaned_text) | ||||||
| @@ -201,9 +197,10 @@ def clean_time(time_entry): | |||||||
|     if time_entry is None: |     if time_entry is None: | ||||||
|         return None |         return None | ||||||
|     elif isinstance(time_entry, timedelta): |     elif isinstance(time_entry, timedelta): | ||||||
|         pretty_print_timedelta(time_entry) |         return pretty_print_timedelta(time_entry) | ||||||
|     elif isinstance(time_entry, datetime): |     elif isinstance(time_entry, datetime): | ||||||
|         print(time_entry) |         pass | ||||||
|  |         # print(time_entry) | ||||||
|     elif isinstance(time_entry, str): |     elif isinstance(time_entry, str): | ||||||
|         try: |         try: | ||||||
|             time_delta_object = parse_duration(time_entry) |             time_delta_object = parse_duration(time_entry) | ||||||
|   | |||||||
							
								
								
									
										19
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @@ -372,6 +372,19 @@ mccabe = ">=0.6.0,<0.7.0" | |||||||
| pycodestyle = ">=2.7.0,<2.8.0" | pycodestyle = ">=2.7.0,<2.8.0" | ||||||
| pyflakes = ">=2.3.0,<2.4.0" | pyflakes = ">=2.3.0,<2.4.0" | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "flake8-print" | ||||||
|  | version = "4.0.0" | ||||||
|  | description = "print statement checker plugin for flake8" | ||||||
|  | category = "dev" | ||||||
|  | optional = false | ||||||
|  | python-versions = ">=3.6" | ||||||
|  |  | ||||||
|  | [package.dependencies] | ||||||
|  | flake8 = ">=3.0" | ||||||
|  | pycodestyle = "*" | ||||||
|  | six = "*" | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "ghp-import" | name = "ghp-import" | ||||||
| version = "2.0.2" | version = "2.0.2" | ||||||
| @@ -1381,7 +1394,7 @@ pgsql = ["psycopg2-binary"] | |||||||
| [metadata] | [metadata] | ||||||
| lock-version = "1.1" | lock-version = "1.1" | ||||||
| python-versions = "^3.9" | python-versions = "^3.9" | ||||||
| content-hash = "89271346f576de3d209ae69639ab7227c03bb8512a1671905a48407d76371ba9" | content-hash = "31d3ee104998ad61b18322584c0cc84de32dbad0dc7657c9f7b7ae8214dae9c3" | ||||||
|  |  | ||||||
| [metadata.files] | [metadata.files] | ||||||
| aiofiles = [ | aiofiles = [ | ||||||
| @@ -1619,6 +1632,10 @@ flake8 = [ | |||||||
|     {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, |     {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, | ||||||
|     {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, |     {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, | ||||||
| ] | ] | ||||||
|  | flake8-print = [ | ||||||
|  |     {file = "flake8-print-4.0.0.tar.gz", hash = "sha256:5afac374b7dc49aac2c36d04b5eb1d746d72e6f5df75a6ecaecd99e9f79c6516"}, | ||||||
|  |     {file = "flake8_print-4.0.0-py3-none-any.whl", hash = "sha256:6c0efce658513169f96d7a24cf136c434dc711eb00ebd0a985eb1120103fe584"}, | ||||||
|  | ] | ||||||
| ghp-import = [ | ghp-import = [ | ||||||
|     {file = "ghp-import-2.0.2.tar.gz", hash = "sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071"}, |     {file = "ghp-import-2.0.2.tar.gz", hash = "sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071"}, | ||||||
|     {file = "ghp_import-2.0.2-py3-none-any.whl", hash = "sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46"}, |     {file = "ghp_import-2.0.2-py3-none-any.whl", hash = "sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46"}, | ||||||
|   | |||||||
| @@ -50,6 +50,7 @@ pydantic-to-typescript = "^1.0.7" | |||||||
| rich = "^10.7.0" | rich = "^10.7.0" | ||||||
| isort = "^5.9.3" | isort = "^5.9.3" | ||||||
| regex = "2021.9.30" # TODO: Remove during Upgrade -> https://github.com/psf/black/issues/2524 | regex = "2021.9.30" # TODO: Remove during Upgrade -> https://github.com/psf/black/issues/2524 | ||||||
|  | flake8-print = "^4.0.0" | ||||||
|  |  | ||||||
| [build-system] | [build-system] | ||||||
| requires = ["poetry-core>=1.0.0"] | requires = ["poetry-core>=1.0.0"] | ||||||
|   | |||||||
| @@ -47,7 +47,6 @@ def register_user(api_client, invite): | |||||||
|     registration.group_token = invite |     registration.group_token = invite | ||||||
|  |  | ||||||
|     response = api_client.post(Routes.register, json=registration.dict(by_alias=True)) |     response = api_client.post(Routes.register, json=registration.dict(by_alias=True)) | ||||||
|     print(response.json()) |  | ||||||
|     return registration, response |     return registration, response | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -28,8 +28,6 @@ def test_read_webhook(api_client: TestClient, unique_user: TestUser, webhook_dat | |||||||
|  |  | ||||||
|     webhook = response.json() |     webhook = response.json() | ||||||
|  |  | ||||||
|     print(webhook) |  | ||||||
|  |  | ||||||
|     assert webhook["id"] |     assert webhook["id"] | ||||||
|     assert webhook["name"] == webhook_data["name"] |     assert webhook["name"] == webhook_data["name"] | ||||||
|     assert webhook["url"] == webhook_data["url"] |     assert webhook["url"] == webhook_data["url"] | ||||||
|   | |||||||
| @@ -0,0 +1,35 @@ | |||||||
|  | import pytest | ||||||
|  | from fastapi.testclient import TestClient | ||||||
|  |  | ||||||
|  | from tests.utils.fixture_schemas import TestUser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Routes: | ||||||
|  |     base = "/api/recipes" | ||||||
|  |     bulk = "/api/recipes/create-url/bulk" | ||||||
|  |  | ||||||
|  |     def item(item_id: str) -> str: | ||||||
|  |         return f"{Routes.base}/{item_id}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.skip("Long Running Scraper") | ||||||
|  | def test_bulk_import(api_client: TestClient, unique_user: TestUser): | ||||||
|  |     recipes = { | ||||||
|  |         "imports": [ | ||||||
|  |             {"url": "https://www.bonappetit.com/recipe/caramel-crunch-chocolate-chunklet-cookies"}, | ||||||
|  |             {"url": "https://www.allrecipes.com/recipe/10813/best-chocolate-chip-cookies/"}, | ||||||
|  |         ] | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     slugs = [ | ||||||
|  |         "caramel-crunch-chocolate-chunklet-cookies", | ||||||
|  |         "best-chocolate-chip-cookies", | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     response = api_client.post(Routes.bulk, json=recipes, headers=unique_user.token) | ||||||
|  |  | ||||||
|  |     assert response.status_code == 201 | ||||||
|  |  | ||||||
|  |     for slug in slugs: | ||||||
|  |         response = api_client.get(Routes.item(slug), headers=unique_user.token) | ||||||
|  |         assert response.status_code == 200 | ||||||
| @@ -73,5 +73,4 @@ def test_delete_food(api_client: TestClient, food: dict, unique_user: TestUser): | |||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|  |  | ||||||
|     response = api_client.get(Routes.item(id), headers=unique_user.token) |     response = api_client.get(Routes.item(id), headers=unique_user.token) | ||||||
|     print(response.json()) |  | ||||||
|     assert response.status_code == 404 |     assert response.status_code == 404 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user