From 55b035eaaa6fb49fb21f0d4d54dbda9c58f86214 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Sat, 19 Feb 2022 09:34:42 +0100 Subject: [PATCH 01/27] first boilerplate for new import view --- cookbook/templates/test.html | 41 ++++++++++++---------- cookbook/views/views.py | 6 +--- vue/src/apps/ImportView/ImportView.vue | 47 ++++++++++++++++++++++++++ vue/src/apps/ImportView/main.js | 18 ++++++++++ vue/vue.config.js | 4 +++ 5 files changed, 94 insertions(+), 22 deletions(-) create mode 100644 vue/src/apps/ImportView/ImportView.vue create mode 100644 vue/src/apps/ImportView/main.js diff --git a/cookbook/templates/test.html b/cookbook/templates/test.html index d36a8adc9..629d81bef 100644 --- a/cookbook/templates/test.html +++ b/cookbook/templates/test.html @@ -1,26 +1,33 @@ {% extends "base.html" %} -{% load crispy_forms_filters %} -{% load i18n %} +{% load render_bundle from webpack_loader %} {% load static %} +{% load i18n %} +{% load l10n %} + + +{% block title %}Test{% endblock %} + + +{% block content_fluid %} + +
+ +
-{% block title %}{% trans 'Import Recipes' %}{% endblock %} -{% block extra_head %} - {{ form.media }} {% endblock %} -{% block content %} -

{% trans 'Import' %}

-
-
-
- {% csrf_token %} - {{ form|crispy }} - -
-
-
+{% block script %} + {% if debug %} + + {% else %} + + {% endif %} + + + {% render_bundle 'import_view' %} {% endblock %} \ No newline at end of file diff --git a/cookbook/views/views.py b/cookbook/views/views.py index 26d80f6be..2d298547b 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -647,11 +647,7 @@ def test(request): if not settings.DEBUG: return HttpResponseRedirect(reverse('index')) - with scopes_disabled(): - result = ShoppingList.objects.filter( - Q(created_by=request.user) | Q(shared=request.user)).filter( - space=request.space).values().distinct() - return JsonResponse(list(result), safe=False, json_dumps_params={'indent': 2}) + return render(request, 'test.html', {}) def test2(request): diff --git a/vue/src/apps/ImportView/ImportView.vue b/vue/src/apps/ImportView/ImportView.vue new file mode 100644 index 000000000..8761aefe4 --- /dev/null +++ b/vue/src/apps/ImportView/ImportView.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/vue/src/apps/ImportView/main.js b/vue/src/apps/ImportView/main.js new file mode 100644 index 000000000..f55808a72 --- /dev/null +++ b/vue/src/apps/ImportView/main.js @@ -0,0 +1,18 @@ +import Vue from 'vue' +import App from './ImportView.vue' +import i18n from '@/i18n' + +Vue.config.productionTip = false + +// TODO move this and other default stuff to centralized JS file (verify nothing breaks) +let publicPath = localStorage.STATIC_URL + 'vue/' +if (process.env.NODE_ENV === 'development') { + publicPath = 'http://localhost:8080/' +} +export default __webpack_public_path__ = publicPath // eslint-disable-line + + +new Vue({ + i18n, + render: h => h(App), +}).$mount('#app') diff --git a/vue/vue.config.js b/vue/vue.config.js index a94390ce8..a4b0c98be 100644 --- a/vue/vue.config.js +++ b/vue/vue.config.js @@ -13,6 +13,10 @@ const pages = { entry: "./src/apps/OfflineView/main.js", chunks: ["chunk-vendors"], }, + import_view: { + entry: "./src/apps/ImportView/main.js", + chunks: ["chunk-vendors"], + }, import_response_view: { entry: "./src/apps/ImportResponseView/main.js", chunks: ["chunk-vendors"], From 89348f69f146915253cba2ee50fc08290c84ebbf Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Sat, 19 Feb 2022 16:55:17 +0100 Subject: [PATCH 02/27] basics of new import page --- cookbook/models.py | 8 +- cookbook/views/api.py | 110 ++++---- vue/src/apps/ImportView/ImportView.vue | 370 +++++++++++++++++++++++-- vue/src/locales/en.json | 3 + vue/src/utils/integration.js | 22 ++ vue/src/utils/utils.js | 64 +++-- 6 files changed, 462 insertions(+), 115 deletions(-) create mode 100644 vue/src/utils/integration.js diff --git a/cookbook/models.py b/cookbook/models.py index 67679a897..8f3041e9b 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -591,6 +591,8 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss no_amount = models.BooleanField(default=False) order = models.IntegerField(default=0) + original_text = models.CharField(max_length=512, null=True, blank=True, default=None) + space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space') @@ -673,9 +675,9 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel working_time = models.IntegerField(default=0) waiting_time = models.IntegerField(default=0) internal = models.BooleanField(default=False) - nutrition = models.ForeignKey( - NutritionInformation, blank=True, null=True, on_delete=models.CASCADE - ) + nutrition = models.ForeignKey( NutritionInformation, blank=True, null=True, on_delete=models.CASCADE ) + + source_url = models.CharField(max_length=1024, default=None, blank=True, null=True) created_by = models.ForeignKey(User, on_delete=models.PROTECT) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 872489787..55ba8f469 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -926,6 +926,7 @@ class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin): space=self.request.space).distinct() return super().get_queryset() + # -------------- non django rest api views -------------------- @@ -1063,45 +1064,45 @@ def get_plan_ical(request, from_date, to_date): @group_required('user') def recipe_from_source(request): - url = request.POST.get('url', None) - data = request.POST.get('data', None) - mode = request.POST.get('mode', None) - auto = request.POST.get('auto', 'true') + """ + function to retrieve a recipe from a given url or source string + :param request: standard request with additional post parameters + - url: url to use for importing recipe + - data: if no url is given recipe is imported from provided source data + - auto: true to return just the recipe as json, false to return source json, html and images as well + :return: + """ + request_payload = json.loads(request.body.decode('utf-8')) + url = request_payload.get('url', None) + data = request_payload.get('data', None) + auto = True if request_payload.get('auto', 'true') == 'true' else False - HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.0.7) Gecko/2009021910 Firefox/3.0.7" - } + # headers to use for request to external sites + external_request_headers = {"User-Agent": "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.0.7) Gecko/2009021910 Firefox/3.0.7"} - if (not url and not data) or (mode == 'url' and not url) or (mode == 'source' and not data): - return JsonResponse( - { - 'error': True, - 'msg': _('Nothing to do.') - }, - status=400 - ) + if not url and not data: + return JsonResponse({ + 'error': True, + 'msg': _('Nothing to do.') + }, status=400) - if mode == 'url' and auto == 'true': + # in auto mode scrape url directly with recipe scrapers library + if url and auto: try: scrape = scrape_me(url) except (WebsiteNotImplementedError, AttributeError): try: scrape = scrape_me(url, wild_mode=True) except NoSchemaFoundInWildMode: - return JsonResponse( - { - 'error': True, - 'msg': _('The requested site provided malformed data and cannot be read.') # noqa: E501 - }, - status=400) - except ConnectionError: - return JsonResponse( - { + return JsonResponse({ 'error': True, - 'msg': _('The requested page could not be found.') - }, - status=400 - ) + 'msg': _('The requested site provided malformed data and cannot be read.') + }, status=400) + except ConnectionError: + return JsonResponse({ + 'error': True, + 'msg': _('The requested page could not be found.') + }, status=400) try: instructions = scrape.instructions() @@ -1111,38 +1112,30 @@ def recipe_from_source(request): ingredients = scrape.ingredients() except Exception: ingredients = [] + if len(ingredients) + len(instructions) == 0: - return JsonResponse( - { - 'error': True, - 'msg': _( - 'The requested site does not provide any recognized data format to import the recipe from.') - # noqa: E501 - }, - status=400) + return JsonResponse({ + 'error': True, + 'msg': _('The requested site does not provide any recognized data format to import the recipe from.') + }, status=400) else: return JsonResponse({"recipe_json": get_from_scraper(scrape, request)}) - elif (mode == 'source') or (mode == 'url' and auto == 'false'): + elif data or (url and not auto): + # in manual mode request complete page to return it later if not data or data == 'undefined': try: - data = requests.get(url, headers=HEADERS).content + data = requests.get(url, headers=external_request_headers).content except requests.exceptions.ConnectionError: - return JsonResponse( - { - 'error': True, - 'msg': _('Connection Refused.') - }, - status=400 - ) + return JsonResponse({ + 'error': True, + 'msg': _('Connection Refused.') + }, status=400) recipe_json, recipe_tree, recipe_html, images = get_recipe_from_source(data, url, request) if len(recipe_tree) == 0 and len(recipe_json) == 0: - return JsonResponse( - { - 'error': True, - 'msg': _('No usable data could be found.') - }, - status=400 - ) + return JsonResponse({ + 'error': True, + 'msg': _('No usable data could be found.') + }, status=400) else: return JsonResponse({ 'recipe_tree': recipe_tree, @@ -1152,13 +1145,10 @@ def recipe_from_source(request): }) else: - return JsonResponse( - { - 'error': True, - 'msg': _('I couldn\'t find anything to do.') - }, - status=400 - ) + return JsonResponse({ + 'error': True, + 'msg': _('I couldn\'t find anything to do.') + }, status=400) @group_required('admin') diff --git a/vue/src/apps/ImportView/ImportView.vue b/vue/src/apps/ImportView/ImportView.vue index 8761aefe4..3dccc870f 100644 --- a/vue/src/apps/ImportView/ImportView.vue +++ b/vue/src/apps/ImportView/ImportView.vue @@ -1,7 +1,270 @@ @@ -12,31 +275,94 @@ import {BootstrapVue} from 'bootstrap-vue' import 'bootstrap-vue/dist/bootstrap-vue.css' -import {ResolveUrlMixin, ToastMixin} from "@/utils/utils"; +import {resolveDjangoUrl, ResolveUrlMixin, StandardToasts, ToastMixin} from "@/utils/utils"; +import axios from "axios"; Vue.use(BootstrapVue) export default { - name: 'ImportView', - mixins: [ - ResolveUrlMixin, - ToastMixin, - ], - components: { - - }, - data() { - return { + name: 'ImportView', + mixins: [ + ResolveUrlMixin, + ToastMixin, + ], + components: {}, + data() { + return { + LS_IMPORT_RECENT: 'import_recent_urls', //TODO use central helper to manage all local storage keys (and maybe even access) + website_url: '', + recent_urls: [], + source_data: '', + recipe_data: undefined, + recipe_json: undefined, + recipe_tree: undefined, + recipe_images: [], + automatic: true, + error: undefined, + loading: false, + preview: false, + } + }, + mounted() { + let local_storage_recent = JSON.parse(window.localStorage.getItem(this.LS_IMPORT_RECENT)) + this.recent_urls = local_storage_recent !== null ? local_storage_recent : [] + }, + methods: { + /** + * Requests the recipe to be loaded form the source (url/data) from the server + * Updates all variables to contain what they need to render either simple preview or manual mapping mode + */ + loadRecipe: function () { + console.log(this.website_url) + if (this.website_url !== '') { + if (this.recent_urls.length > 5) { + this.recent_urls.pop() + } + if (this.recent_urls.filter(x => x === this.website_url).length === 0) { + this.recent_urls.push(this.website_url) + } + window.localStorage.setItem(this.LS_IMPORT_RECENT, JSON.stringify(this.recent_urls)) + } + this.recipe_data = undefined + this.recipe_json = undefined + this.recipe_tree = undefined + this.recipe_images = [] + this.error = undefined + this.loading = true + this.preview = false + axios.post(resolveDjangoUrl('api_recipe_from_source'), { + 'url': this.website_url, + 'data': this.source_data, + 'auto': this.automatic, + 'mode': this.mode + },).then((response) => { + this.recipe_json = response.data['recipe_json']; + this.recipe_tree = response.data['recipe_tree']; + this.recipe_html = response.data['recipe_html']; + this.recipe_images = response.data['images']; //todo change on backend as well after old view is deprecated + if (this.automatic) { + this.recipe_data = this.recipe_json; + this.preview = false + } else { + this.preview = true + } + this.loading = false + }).catch((err) => { + this.error = err.data + this.loading = false + console.log(err.response) + StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH, err.response.data.msg) + }) + }, + /** + * Clear list of recently imported recipe urls + */ + clearRecentImports: function () { + window.localStorage.setItem(this.LS_IMPORT_RECENT, JSON.stringify([])) + this.recent_urls = [] + } } - }, - mounted() { - - - }, - methods: { - - } } diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index b70844ab8..31fc6e763 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -333,6 +333,9 @@ "ingredient_list": "Ingredient List", "explain": "Explain", "filter": "Filter", + "Website": "Website", + "App": "App", + "Bookmarklet": "Bookmarklet", "search_no_recipes": "Could not find any recipes!", "search_import_help_text": "Import a recipe from an external website or application.", "search_create_help_text": "Create a new recipe directly in Tandoor.", diff --git a/vue/src/utils/integration.js b/vue/src/utils/integration.js new file mode 100644 index 000000000..5cdedbaea --- /dev/null +++ b/vue/src/utils/integration.js @@ -0,0 +1,22 @@ +// containing all data and functions regarding the different integrations + +export const INTEGRATIONS = { + DEFAULT: {name: "Tandoor", import: true, export: true}, + CHEFTAP: {name: "Cheftap", import: true, export: false}, + CHOWDOWN: {name: "Chowdown", import: true, export: false}, + COOKBOOKAPP: {name: "CookBookApp", import: true, export: false}, + COPYMETHAT: {name: "CopyMeThat", import: true, export: false}, + DOMESTICA: {name: "Domestica", import: true, export: false}, + MEALIE: {name: "Mealie", import: true, export: false}, + MEALMASTER: {name: "Mealmaster", import: true, export: false}, + NEXTCLOUD: {name: "Nextcloud Cookbook", import: true, export: false}, + OPENEATS: {name: "Openeats", import: true, export: false}, + PAPRIKA: {name: "Paprika", import: true, export: false}, + PEPPERPLATE: {name: "Pepperplate", import: true, export: false}, + PLANTOEAT: {name: "Plantoeat", import: true, export: false}, + RECETTETEK: {name: "RecetteTek", import: true, export: false}, + RECIPEKEEPER: {name: "Recipekeeper", import: true, export: false}, + RECIPESAGE: {name: "Recipesage", import: true, export: true}, + REZKONV: {name: "Rezkonv", import: true, export: false}, + SAFRON: {name: "Safron", import: true, export: true}, +} diff --git a/vue/src/utils/utils.js b/vue/src/utils/utils.js index f03024ded..b5626b8be 100644 --- a/vue/src/utils/utils.js +++ b/vue/src/utils/utils.js @@ -2,18 +2,18 @@ * Utility functions to call bootstrap toasts * */ import i18n from "@/i18n" -import { frac } from "@/utils/fractions" +import {frac} from "@/utils/fractions" /* * Utility functions to use OpenAPIs generically * */ -import { ApiApiFactory } from "@/utils/openapi/api.ts" +import {ApiApiFactory} from "@/utils/openapi/api.ts" import axios from "axios" -import { BToast } from "bootstrap-vue" +import {BToast} from "bootstrap-vue" // /* // * Utility functions to use manipulate nested components // * */ import Vue from "vue" -import { Actions, Models } from "./models" +import {Actions, Models} from "./models" export const ToastMixin = { name: "ToastMixin", @@ -49,37 +49,37 @@ export class StandardToasts { static FAIL_MOVE = "FAIL_MOVE" static FAIL_MERGE = "FAIL_MERGE" - static makeStandardToast(toast, err_details = undefined) { + static makeStandardToast(toast, err_details = undefined) { //TODO err_details render very ugly, improve this maybe by using a custom toast component (in conjunction with error logging maybe) switch (toast) { case StandardToasts.SUCCESS_CREATE: - makeToast(i18n.tc("Success"), i18n.tc("success_creating_resource"), "success") + makeToast(i18n.tc("Success"), i18n.tc("success_creating_resource") + (err_details ? "\n" + err_details : ""), "success") break case StandardToasts.SUCCESS_FETCH: - makeToast(i18n.tc("Success"), i18n.tc("success_fetching_resource"), "success") + makeToast(i18n.tc("Success"), i18n.tc("success_fetching_resource") + (err_details ? "\n" + err_details : ""), "success") break case StandardToasts.SUCCESS_UPDATE: - makeToast(i18n.tc("Success"), i18n.tc("success_updating_resource"), "success") + makeToast(i18n.tc("Success"), i18n.tc("success_updating_resource") + (err_details ? "\n" + err_details : ""), "success") break case StandardToasts.SUCCESS_DELETE: - makeToast(i18n.tc("Success"), i18n.tc("success_deleting_resource"), "success") + makeToast(i18n.tc("Success"), i18n.tc("success_deleting_resource") + (err_details ? "\n" + err_details : ""), "success") break case StandardToasts.SUCCESS_MOVE: - makeToast(i18n.tc("Success"), i18n.tc("success_moving_resource"), "success") + makeToast(i18n.tc("Success"), i18n.tc("success_moving_resource") + (err_details ? "\n" + err_details : ""), "success") break case StandardToasts.SUCCESS_MERGE: - makeToast(i18n.tc("Success"), i18n.tc("success_merging_resource"), "success") + makeToast(i18n.tc("Success"), i18n.tc("success_merging_resource") + (err_details ? "\n" + err_details : ""), "success") break case StandardToasts.FAIL_CREATE: - makeToast(i18n.tc("Failure"), i18n.tc("err_creating_resource"), "danger") + makeToast(i18n.tc("Failure"), i18n.tc("err_creating_resource") + (err_details ? "\n" + err_details : ""), "danger") break case StandardToasts.FAIL_FETCH: - makeToast(i18n.tc("Failure"), i18n.tc("err_fetching_resource"), "danger") + makeToast(i18n.tc("Failure"), i18n.tc("err_fetching_resource") + (err_details ? "\n" + err_details : ""), "danger") break case StandardToasts.FAIL_UPDATE: - makeToast(i18n.tc("Failure"), i18n.tc("err_updating_resource"), "danger") + makeToast(i18n.tc("Failure"), i18n.tc("err_updating_resource") + (err_details ? "\n" + err_details : ""), "danger") break case StandardToasts.FAIL_DELETE: - makeToast(i18n.tc("Failure"), i18n.tc("err_deleting_resource"), "danger") + makeToast(i18n.tc("Failure"), i18n.tc("err_deleting_resource") + (err_details ? "\n" + err_details : ""), "danger") break case StandardToasts.FAIL_MOVE: makeToast(i18n.tc("Failure"), i18n.tc("err_moving_resource") + (err_details ? "\n" + err_details : ""), "danger") @@ -234,7 +234,7 @@ export const ApiMixin = { return apiClient[func](...parameters) }, genericGetAPI: function (url, options) { - return axios.get(resolveDjangoUrl(url), { params: options, emulateJSON: true }) + return axios.get(resolveDjangoUrl(url), {params: options, emulateJSON: true}) }, genericPostAPI: function (url, form) { let data = new FormData() @@ -284,6 +284,7 @@ function formatParam(config, value, options) { } return value } + function buildParams(options, setup) { let config = setup?.config ?? {} let params = setup?.params ?? [] @@ -311,6 +312,7 @@ function buildParams(options, setup) { }) return parameters } + function getDefault(config, options) { let value = undefined value = config?.default ?? undefined @@ -338,11 +340,12 @@ function getDefault(config, options) { } return value } + export function getConfig(model, action) { let f = action.function // if not defined partialUpdate will use params from create if (f === "partialUpdate" && !model?.[f]?.params) { - model[f] = { params: [...["id"], ...model.create.params] } + model[f] = {params: [...["id"], ...model.create.params]} } let config = { @@ -350,12 +353,12 @@ export function getConfig(model, action) { apiName: model.apiName, } // spread operator merges dictionaries - last item in list takes precedence - config = { ...config, ...action, ...model.model_type?.[f], ...model?.[f] } + config = {...config, ...action, ...model.model_type?.[f], ...model?.[f]} // nested dictionaries are not merged - so merge again on any nested keys - config.config = { ...action?.config, ...model.model_type?.[f]?.config, ...model?.[f]?.config } + config.config = {...action?.config, ...model.model_type?.[f]?.config, ...model?.[f]?.config} // look in partialUpdate again if necessary if (f === "partialUpdate" && Object.keys(config.config).length === 0) { - config.config = { ...model.model_type?.create?.config, ...model?.create?.config } + config.config = {...model.model_type?.create?.config, ...model?.create?.config} } config["function"] = f + config.apiName + (config?.suffix ?? "") // parens are required to force optional chaining to evaluate before concat return config @@ -366,17 +369,17 @@ export function getConfig(model, action) { // * */ export function getForm(model, action, item1, item2) { let f = action.function - let config = { ...action?.form, ...model.model_type?.[f]?.form, ...model?.[f]?.form } + let config = {...action?.form, ...model.model_type?.[f]?.form, ...model?.[f]?.form} // if not defined partialUpdate will use form from create if (f === "partialUpdate" && Object.keys(config).length == 0) { - config = { ...Actions.CREATE?.form, ...model.model_type?.["create"]?.form, ...model?.["create"]?.form } - config["title"] = { ...action?.form_title, ...model.model_type?.[f]?.form_title, ...model?.[f]?.form_title } + config = {...Actions.CREATE?.form, ...model.model_type?.["create"]?.form, ...model?.["create"]?.form} + config["title"] = {...action?.form_title, ...model.model_type?.[f]?.form_title, ...model?.[f]?.form_title} // form functions should not be inherited if (config?.["form_function"]?.includes("Create")) { delete config["form_function"] } } - let form = { fields: [] } + let form = {fields: []} let value = "" for (const [k, v] of Object.entries(config)) { if (v?.function) { @@ -404,6 +407,7 @@ export function getForm(model, action, item1, item2) { } return form } + function formTranslate(translate, model, item1, item2) { if (typeof translate !== "object") { return translate @@ -495,7 +499,7 @@ const specialCases = { let params = [] if (action.function === "partialUpdate") { API = GenericAPI - params = [Models.SUPERMARKET, Actions.FETCH, { id: options.id }] + params = [Models.SUPERMARKET, Actions.FETCH, {id: options.id}] } else if (action.function === "create") { API = new ApiApiFactory()[setup.function] params = buildParams(options, setup) @@ -532,15 +536,15 @@ const specialCases = { let order = Math.max(...existing_categories.map((x) => x?.order ?? 0), ...updated_categories.map((x) => x?.order ?? 0), 0) + 1 removed_categories.forEach((x) => { - promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.DELETE, { id: x.id })) + promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.DELETE, {id: x.id})) }) - let item = { supermarket: id } + let item = {supermarket: id} added_categories.forEach((x) => { item.order = x?.order ?? order if (!x?.order) { order = order + 1 } - item.category = { id: x.category.id, name: x.category.name } + item.category = {id: x.category.id, name: x.category.name} promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.CREATE, item)) }) changed_categories.forEach((x) => { @@ -549,13 +553,13 @@ const specialCases = { if (!x?.order) { order = order + 1 } - item.category = { id: x.category.id, name: x.category.name } + item.category = {id: x.category.id, name: x.category.name} promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.UPDATE, item)) }) return Promise.all(promises).then(() => { // finally get and return the Supermarket which everything downstream is expecting - return GenericAPI(Models.SUPERMARKET, Actions.FETCH, { id: id }) + return GenericAPI(Models.SUPERMARKET, Actions.FETCH, {id: id}) }) }) }, From c8fc67fa2b1de12326e61d8384dea9e30f4b4949 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Sat, 19 Feb 2022 17:54:00 +0100 Subject: [PATCH 03/27] changed source import to match field structure of recipe model first imports working --- cookbook/helper/recipe_url_import.py | 186 +++++--------- .../migrations/0172_auto_20220219_1655.py | 23 ++ cookbook/models.py | 16 +- cookbook/serializer.py | 2 +- cookbook/views/api.py | 4 +- vue/src/apps/ImportView/ImportView.vue | 238 ++---------------- .../apps/RecipeEditView/RecipeEditView.vue | 2 + vue/src/utils/openapi/api.ts | 12 + 8 files changed, 138 insertions(+), 345 deletions(-) create mode 100644 cookbook/migrations/0172_auto_20220219_1655.py diff --git a/cookbook/helper/recipe_url_import.py b/cookbook/helper/recipe_url_import.py index a87f8b7d7..dd6c24353 100644 --- a/cookbook/helper/recipe_url_import.py +++ b/cookbook/helper/recipe_url_import.py @@ -11,6 +11,7 @@ from cookbook.helper import recipe_url_import as helper from cookbook.helper.ingredient_parser import IngredientParser from cookbook.models import Keyword + # from recipe_scrapers._utils import get_minutes ## temporary until/unless upstream incorporates get_minutes() PR @@ -33,6 +34,7 @@ def get_from_scraper(scrape, request): description = '' recipe_json['description'] = parse_description(description) + recipe_json['internal'] = True try: servings = scrape.yields() or None @@ -51,20 +53,20 @@ def get_from_scraper(scrape, request): recipe_json['servings'] = max(servings, 1) try: - recipe_json['prepTime'] = get_minutes(scrape.schema.data.get("prepTime")) or 0 + recipe_json['working_time'] = get_minutes(scrape.schema.data.get("prepTime")) or 0 except Exception: - recipe_json['prepTime'] = 0 + recipe_json['working_time'] = 0 try: - recipe_json['cookTime'] = get_minutes(scrape.schema.data.get("cookTime")) or 0 + recipe_json['waiting_time'] = get_minutes(scrape.schema.data.get("cookTime")) or 0 except Exception: - recipe_json['cookTime'] = 0 + recipe_json['waiting_time'] = 0 - if recipe_json['cookTime'] + recipe_json['prepTime'] == 0: + if recipe_json['working_time'] + recipe_json['waiting_time'] == 0: try: - recipe_json['prepTime'] = get_minutes(scrape.total_time()) or 0 + recipe_json['working_time'] = get_minutes(scrape.total_time()) or 0 except Exception: try: - get_minutes(scrape.schema.data.get("totalTime")) or 0 + recipe_json['working_time'] = get_minutes(scrape.schema.data.get("totalTime")) or 0 except Exception: pass @@ -101,54 +103,49 @@ def get_from_scraper(scrape, request): ingredient_parser = IngredientParser(request, True) - ingredients = [] + recipe_json['steps'] = [] + + for i in parse_instructions(scrape.instructions()): + recipe_json['steps'].append({'instruction': i, 'ingredients': [], }) + if len(recipe_json['steps']) == 0: + recipe_json['steps'].append({'instruction': '', 'ingredients': [], }) + try: for x in scrape.ingredients(): try: amount, unit, ingredient, note = ingredient_parser.parse(x) - ingredients.append( + recipe_json['steps'][0]['ingredients'].append( { 'amount': amount, 'unit': { - 'text': unit, - 'id': random.randrange(10000, 99999) + 'name': unit, }, - 'ingredient': { - 'text': ingredient, - 'id': random.randrange(10000, 99999) + 'food': { + 'name': ingredient, }, 'note': note, - 'original': x + 'original_text': x } ) except Exception: - ingredients.append( + recipe_json['steps'][0]['ingredients'].append( { 'amount': 0, 'unit': { - 'text': '', - 'id': random.randrange(10000, 99999) + 'name': '', }, - 'ingredient': { - 'text': x, - 'id': random.randrange(10000, 99999) + 'food': { + 'name': x, }, 'note': '', - 'original': x + 'original_text': x } ) - recipe_json['recipeIngredient'] = ingredients except Exception: - recipe_json['recipeIngredient'] = ingredients - - try: - recipe_json['recipeInstructions'] = parse_instructions(scrape.instructions()) - except Exception: - recipe_json['recipeInstructions'] = "" + pass if scrape.url: - recipe_json['url'] = scrape.url - recipe_json['recipeInstructions'] += "\n\nImported from " + scrape.url + recipe_json['source_url'] = scrape.url return recipe_json @@ -161,102 +158,46 @@ def parse_name(name): return normalize_string(name) -def parse_ingredients(ingredients): - # some pages have comma separated ingredients in a single array entry - try: - if type(ingredients[0]) == dict: - return ingredients - except (KeyError, IndexError): - pass - - if (len(ingredients) == 1 and type(ingredients) == list): - ingredients = ingredients[0].split(',') - elif type(ingredients) == str: - ingredients = ingredients.split(',') - - for x in ingredients: - if '\n' in x: - ingredients.remove(x) - for i in x.split('\n'): - ingredients.insert(0, i) - - ingredient_list = [] - - for x in ingredients: - if x.replace(' ', '') != '': - x = x.replace('½', "0.5").replace('¼', "0.25").replace('¾', "0.75") - try: - amount, unit, ingredient, note = parse_single_ingredient(x) - if ingredient: - ingredient_list.append( - { - 'amount': amount, - 'unit': { - 'text': unit, - 'id': random.randrange(10000, 99999) - }, - 'ingredient': { - 'text': ingredient, - 'id': random.randrange(10000, 99999) - }, - 'note': note, - 'original': x - } - ) - except Exception: - ingredient_list.append( - { - 'amount': 0, - 'unit': { - 'text': '', - 'id': random.randrange(10000, 99999) - }, - 'ingredient': { - 'text': x, - 'id': random.randrange(10000, 99999) - }, - 'note': '', - 'original': x - } - ) - - ingredients = ingredient_list - else: - ingredients = [] - return ingredients - - def parse_description(description): return normalize_string(description) -def parse_instructions(instructions): - instruction_text = '' - - # flatten instructions if they are in a list - if type(instructions) == list: - for i in instructions: - if type(i) == str: - instruction_text += i - else: - if 'text' in i: - instruction_text += i['text'] + '\n\n' - elif 'itemListElement' in i: - for ile in i['itemListElement']: - if type(ile) == str: - instruction_text += ile + '\n\n' - elif 'text' in ile: - instruction_text += ile['text'] + '\n\n' - else: - instruction_text += str(i) - instructions = instruction_text - - normalized_string = normalize_string(instructions) +def clean_instruction_string(instruction): + normalized_string = normalize_string(instruction) normalized_string = normalized_string.replace('\n', ' \n') normalized_string = normalized_string.replace(' \n \n', '\n\n') return normalized_string +def parse_instructions(instructions): + """ + Convert arbitrary instructions object from website import and turn it into a flat list of strings + :param instructions: any instructions object from import + :return: list of strings (from one to many elements depending on website) + """ + instruction_list = [] + + if type(instructions) == list: + for i in instructions: + if type(i) == str: + instruction_list.append(clean_instruction_string(i)) + else: + if 'text' in i: + instruction_list.append(clean_instruction_string(i['text'])) + elif 'itemListElement' in i: + for ile in i['itemListElement']: + if type(ile) == str: + instruction_list.append(clean_instruction_string(ile)) + elif 'text' in ile: + instruction_list.append(clean_instruction_string(ile['text'])) + else: + instruction_list.append(clean_instruction_string(str(i))) + else: + instruction_list.append(clean_instruction_string(instructions)) + + return instruction_list + + def parse_image(image): # check if list of images is returned, take first if so if not image: @@ -334,9 +275,9 @@ def parse_keywords(keyword_json, space): kw = normalize_string(kw) if len(kw) != 0: if k := Keyword.objects.filter(name=kw, space=space).first(): - keywords.append({'id': str(k.id), 'text': str(k)}) + keywords.append({'name': str(k)}) else: - keywords.append({'id': random.randrange(1111111, 9999999, 1), 'text': kw}) + keywords.append({'name': kw}) return keywords @@ -367,6 +308,7 @@ def normalize_string(string): unescaped_string = unescaped_string.replace("\xa0", " ").replace("\t", " ").strip() return unescaped_string + # TODO deprecate when merged into recipe_scapers @@ -408,9 +350,9 @@ def get_minutes(time_text): if "/" in (hours := matched.groupdict().get("hours") or ''): number = hours.split(" ") if len(number) == 2: - minutes += 60*int(number[0]) + minutes += 60 * int(number[0]) fraction = number[-1:][0].split("/") - minutes += 60 * float(int(fraction[0])/int(fraction[1])) + minutes += 60 * float(int(fraction[0]) / int(fraction[1])) else: minutes += 60 * float(hours) diff --git a/cookbook/migrations/0172_auto_20220219_1655.py b/cookbook/migrations/0172_auto_20220219_1655.py new file mode 100644 index 000000000..36238f5f9 --- /dev/null +++ b/cookbook/migrations/0172_auto_20220219_1655.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.12 on 2022-02-19 15:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0171_alter_searchpreference_trigram_threshold'), + ] + + operations = [ + migrations.AddField( + model_name='ingredient', + name='original_text', + field=models.CharField(blank=True, default=None, max_length=512, null=True), + ), + migrations.AddField( + model_name='recipe', + name='source_url', + field=models.CharField(blank=True, default=None, max_length=1024, null=True), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index 8f3041e9b..5db27217b 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -240,7 +240,7 @@ class Space(ExportModelOperationsMixin('space'), models.Model): max_users = models.IntegerField(default=0) allow_sharing = models.BooleanField(default=True) demo = models.BooleanField(default=False) - food_inherit = models.ManyToManyField(FoodInheritField, blank=True) + food_inherit = models.ManyToManyField(FoodInheritField, blank=True) show_facet_count = models.BooleanField(default=False) def __str__(self): @@ -336,7 +336,7 @@ class UserPreference(models.Model, PermissionModelMixin): default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4) shopping_recent_days = models.PositiveIntegerField(default=7) csv_delim = models.CharField(max_length=2, default=",") - csv_prefix = models.CharField(max_length=10, blank=True,) + csv_prefix = models.CharField(max_length=10, blank=True, ) created_at = models.DateTimeField(auto_now_add=True) space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True) @@ -495,11 +495,11 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): ignore_shopping = models.BooleanField(default=False) # inherited field onhand_users = models.ManyToManyField(User, blank=True) description = models.TextField(default='', blank=True) - inherit_fields = models.ManyToManyField(FoodInheritField, blank=True) + inherit_fields = models.ManyToManyField(FoodInheritField, blank=True) substitute = models.ManyToManyField("self", blank=True) substitute_siblings = models.BooleanField(default=False) substitute_children = models.BooleanField(default=False) - child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit') + child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit') space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space', _manager_class=TreeManager) @@ -532,7 +532,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): if food: # if child inherit fields is preset children should be set to that, otherwise inherit this foods inherited fields inherit = list((food.child_inherit_fields.all() or food.inherit_fields.all()).values('id', 'field')) - tree_filter = Q(path__startswith=food.path, space=space, depth=food.depth+1) + tree_filter = Q(path__startswith=food.path, space=space, depth=food.depth + 1) else: inherit = list(space.food_inherit.all().values('id', 'field')) tree_filter = Q(space=space) @@ -663,9 +663,7 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel servings = models.IntegerField(default=1) servings_text = models.CharField(default='', blank=True, max_length=32) image = models.ImageField(upload_to='recipes/', blank=True, null=True) - storage = models.ForeignKey( - Storage, on_delete=models.PROTECT, blank=True, null=True - ) + storage = models.ForeignKey(Storage, on_delete=models.PROTECT, blank=True, null=True) file_uid = models.CharField(max_length=256, default="", blank=True) file_path = models.CharField(max_length=512, default="", blank=True) link = models.CharField(max_length=512, null=True, blank=True) @@ -675,7 +673,7 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel working_time = models.IntegerField(default=0) waiting_time = models.IntegerField(default=0) internal = models.BooleanField(default=False) - nutrition = models.ForeignKey( NutritionInformation, blank=True, null=True, on_delete=models.CASCADE ) + nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE) source_url = models.CharField(max_length=1024, default=None, blank=True, null=True) created_by = models.ForeignKey(User, on_delete=models.PROTECT) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index b1b72d3e2..d41a47be1 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -483,7 +483,7 @@ class IngredientSerializer(WritableNestedModelSerializer): model = Ingredient fields = ( 'id', 'food', 'unit', 'amount', 'note', 'order', - 'is_header', 'no_amount' + 'is_header', 'no_amount', 'original_text' ) diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 55ba8f469..9e62bbdf2 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -1138,10 +1138,10 @@ def recipe_from_source(request): }, status=400) else: return JsonResponse({ - 'recipe_tree': recipe_tree, 'recipe_json': recipe_json, + 'recipe_tree': recipe_tree, 'recipe_html': recipe_html, - 'images': images, + 'recipe_images': images, }) else: diff --git a/vue/src/apps/ImportView/ImportView.vue b/vue/src/apps/ImportView/ImportView.vue index 3dccc870f..adacc222d 100644 --- a/vue/src/apps/ImportView/ImportView.vue +++ b/vue/src/apps/ImportView/ImportView.vue @@ -32,218 +32,21 @@
Options
-
-
- -
-
-

Recipe Preview

-
Drag recipe attributes from the - right into the appropriate box below. -
-
-
-
-
-
- Name - -
-
Text dragged here will - be appended to the name. -
-
- -
-
{{recipe_json.name}}
-
-
-
- -
-
-
- {% trans 'Description' %} - -
-
{% trans 'Text dragged here will - be appended to the description.' %} -
-
- -
-
{{recipe_json.description}}
-
-
-
- -
-
-
- {% trans 'Keywords' %} - -
-
{% trans 'Keywords dragged here - will be appended to current list' %} -
-
- -
-
-
{{ kw.text }}
-
-
-
-
- -
-
- {% trans 'Image' %} - -
- -
- Recipe Image -
-
-
- -
-
-
-
- {% trans 'Servings' %} - -
-
-
{{recipe_json.servings}}
-
-
-
-
-
-
- {% trans 'Prep Time' %} - -
-
-
{{recipe_json.prepTime}}
-
-
-
-
-
-
- {% trans 'Cook Time' %} - -
-
-
{{recipe_json.cookTime}}
-
-
-
-
- -
-
-
- {% trans 'Ingredients' %} - -
-
{% trans 'Ingredients dragged here - will be appended to current list.' %} -
-
- -
-
    -
    -
  • -
    {{i.amount}}
    -
    {{i.unit.text}}
    -
    - {{i.ingredient.text}} -
    -
    {{i.note}}
    -
  • -
    -
-
-
-
- -
-
-
- {% trans 'Instructions' %} - -
-
{% trans 'Recipe instructions - dragged here will be appended to current instructions.' %} -
-
- -
-
{{recipe_json.recipeInstructions}} -
-
-
-
-
-
-
- - -
+
+ Images + Keywords +
    +
  • {{k}}
  • +
+ Steps +
    +
  • {{s}}
  • +
-
+
Import
+ Import @@ -277,6 +80,7 @@ import 'bootstrap-vue/dist/bootstrap-vue.css' import {resolveDjangoUrl, ResolveUrlMixin, StandardToasts, ToastMixin} from "@/utils/utils"; import axios from "axios"; +import {ApiApiFactory} from "@/utils/openapi/api"; Vue.use(BootstrapVue) @@ -293,8 +97,8 @@ export default { website_url: '', recent_urls: [], source_data: '', - recipe_data: undefined, recipe_json: undefined, + recipe_data: undefined, recipe_tree: undefined, recipe_images: [], automatic: true, @@ -309,6 +113,18 @@ export default { }, methods: { + /** + * Import recipe based on the data configured by the client + */ + importRecipe: function () { + let apiFactory = new ApiApiFactory() + apiFactory.createRecipe(this.recipe_json).then(response => { + StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE) + window.location = resolveDjangoUrl('edit_recipe', response.data.id) + }).catch(err => { + StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE) + }) + }, /** * Requests the recipe to be loaded form the source (url/data) from the server * Updates all variables to contain what they need to render either simple preview or manual mapping mode @@ -340,7 +156,7 @@ export default { this.recipe_json = response.data['recipe_json']; this.recipe_tree = response.data['recipe_tree']; this.recipe_html = response.data['recipe_html']; - this.recipe_images = response.data['images']; //todo change on backend as well after old view is deprecated + this.recipe_images = response.data['recipe_images']; //todo change on backend as well after old view is deprecated if (this.automatic) { this.recipe_data = this.recipe_json; this.preview = false diff --git a/vue/src/apps/RecipeEditView/RecipeEditView.vue b/vue/src/apps/RecipeEditView/RecipeEditView.vue index 215f9fb86..f09ae6736 100644 --- a/vue/src/apps/RecipeEditView/RecipeEditView.vue +++ b/vue/src/apps/RecipeEditView/RecipeEditView.vue @@ -308,6 +308,8 @@

+ +
{{ingredient.original_text}}
diff --git a/vue/src/utils/openapi/api.ts b/vue/src/utils/openapi/api.ts index 07281335d..dae44015d 100644 --- a/vue/src/utils/openapi/api.ts +++ b/vue/src/utils/openapi/api.ts @@ -746,6 +746,12 @@ export interface Ingredient { * @memberof Ingredient */ no_amount?: boolean; + /** + * + * @type {string} + * @memberof Ingredient + */ + original_text?: string | null; } /** * @@ -1905,6 +1911,12 @@ export interface RecipeIngredients { * @memberof RecipeIngredients */ no_amount?: boolean; + /** + * + * @type {string} + * @memberof RecipeIngredients + */ + original_text?: string | null; } /** * From c06c511dc9a6cb0c25d0e9ad21ce124285207cb3 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 21 Feb 2022 15:35:36 +0100 Subject: [PATCH 04/27] changed unit default in ingredient parser to none because empty name units are not accepted by unit serializer but null values can be handled by the ingredient serializer (as a unit can be null) --- cookbook/helper/ingredient_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cookbook/helper/ingredient_parser.py b/cookbook/helper/ingredient_parser.py index b3f4a1c03..0b97dc6d8 100644 --- a/cookbook/helper/ingredient_parser.py +++ b/cookbook/helper/ingredient_parser.py @@ -203,7 +203,7 @@ class IngredientParser: def parse(self, x): # initialize default values amount = 0 - unit = '' + unit = None ingredient = '' note = '' unit_note = '' From e04d6727507961248a26b7b3c952b09c370603d9 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 21 Feb 2022 15:59:30 +0100 Subject: [PATCH 05/27] import with image working --- cookbook/helper/recipe_html_import.py | 12 ------- cookbook/helper/recipe_url_import.py | 8 ++--- cookbook/serializer.py | 5 ++- cookbook/views/api.py | 33 +++++++++++++----- openapitools.json | 0 vue/src/apps/ImportView/ImportView.vue | 46 +++++++++++++++++++++++--- vue/src/utils/openapi/api.ts | 28 ++++++++++++---- 7 files changed, 94 insertions(+), 38 deletions(-) create mode 100644 openapitools.json diff --git a/cookbook/helper/recipe_html_import.py b/cookbook/helper/recipe_html_import.py index acf72917b..7fa7beaf2 100644 --- a/cookbook/helper/recipe_html_import.py +++ b/cookbook/helper/recipe_html_import.py @@ -58,18 +58,6 @@ def get_recipe_from_source(text, url, request): }) return kid_list - recipe_json = { - 'name': '', - 'url': '', - 'description': '', - 'image': '', - 'keywords': [], - 'recipeIngredient': [], - 'recipeInstructions': '', - 'servings': '', - 'prepTime': '', - 'cookTime': '' - } recipe_tree = [] parse_list = [] html_data = [] diff --git a/cookbook/helper/recipe_url_import.py b/cookbook/helper/recipe_url_import.py index dd6c24353..3a39b403f 100644 --- a/cookbook/helper/recipe_url_import.py +++ b/cookbook/helper/recipe_url_import.py @@ -131,9 +131,7 @@ def get_from_scraper(scrape, request): recipe_json['steps'][0]['ingredients'].append( { 'amount': 0, - 'unit': { - 'name': '', - }, + 'unit': None, 'food': { 'name': x, }, @@ -275,9 +273,9 @@ def parse_keywords(keyword_json, space): kw = normalize_string(kw) if len(kw) != 0: if k := Keyword.objects.filter(name=kw, space=space).first(): - keywords.append({'name': str(k)}) + keywords.append({'label': str(k), 'name': k.name, 'id': k.id}) else: - keywords.append({'name': kw}) + keywords.append({'label': kw, 'name': kw}) return keywords diff --git a/cookbook/serializer.py b/cookbook/serializer.py index d41a47be1..3c19b7be0 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -621,9 +621,12 @@ class RecipeSerializer(RecipeBaseSerializer): class RecipeImageSerializer(WritableNestedModelSerializer): + image = serializers.ImageField(required=False, allow_null=True) + image_url = serializers.CharField(max_length=4096, required=False, allow_null=True) + class Meta: model = Recipe - fields = ['image', ] + fields = ['image', 'image_url', ] class RecipeImportSerializer(SpacedModelSerializer): diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 9e62bbdf2..39df3b076 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -5,6 +5,7 @@ import uuid from collections import OrderedDict import requests +from PIL import UnidentifiedImageError from annoying.decorators import ajax_request from annoying.functions import get_object_or_None from django.contrib import messages @@ -23,6 +24,7 @@ from django.utils.translation import gettext as _ from django_scopes import scopes_disabled from icalendar import Calendar, Event from recipe_scrapers import NoSchemaFoundInWildMode, WebsiteNotImplementedError, scrape_me +from requests.exceptions import MissingSchema from rest_framework import decorators, status, viewsets from rest_framework.exceptions import APIException, PermissionDenied from rest_framework.pagination import PageNumberPagination @@ -706,20 +708,33 @@ class RecipeViewSet(viewsets.ModelViewSet): serializer = self.serializer_class(obj, data=request.data, partial=True) - if self.request.space.demo: - raise PermissionDenied(detail='Not available in demo', code=None) - if serializer.is_valid(): serializer.save() + image = None - if serializer.validated_data == {}: - obj.image = None - else: - img, filetype = handle_image(request, obj.image) + if 'image' in serializer.validated_data: + image = obj.image + elif 'image_url' in serializer.validated_data: + try: + response = requests.get(serializer.validated_data['image_url']) + image = File(io.BytesIO(response.content)) + print('test') + except UnidentifiedImageError as e: + print(e) + pass + except MissingSchema as e: + print(e) + pass + except Exception as e: + print(e) + pass + + if image is not None: + img, filetype = handle_image(request, image) obj.image = File(img, name=f'{uuid.uuid4()}_{obj.pk}{filetype}') - obj.save() + obj.save() + return Response(serializer.data) - return Response(serializer.data) return Response(serializer.errors, 400) # TODO: refactor API to use post/put/delete or leave as put and change VUE to use list_recipe after creating diff --git a/openapitools.json b/openapitools.json new file mode 100644 index 000000000..e69de29bb diff --git a/vue/src/apps/ImportView/ImportView.vue b/vue/src/apps/ImportView/ImportView.vue index adacc222d..c0d7b8d65 100644 --- a/vue/src/apps/ImportView/ImportView.vue +++ b/vue/src/apps/ImportView/ImportView.vue @@ -34,13 +34,41 @@
Images + +
+
+ +
+
+ +
+
+ Click the image you want to import for this + recipe +
+
+ +
+
+ + Keywords
    -
  • {{k}}
  • +
  • {{ k.label }}
  • +
+ unused +
    +
  • {{ + k.label + }} +
Steps
    -
  • {{s}}
  • +
  • {{ s }}
@@ -119,8 +147,14 @@ export default { importRecipe: function () { let apiFactory = new ApiApiFactory() apiFactory.createRecipe(this.recipe_json).then(response => { - StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE) - window.location = resolveDjangoUrl('edit_recipe', response.data.id) + let recipe = response.data + apiFactory.imageRecipe(response.data.id, undefined, this.recipe_json.image).then(response => { + StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE) + window.location = resolveDjangoUrl('edit_recipe', recipe.id) + }).catch(e => { + StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE) + window.location = resolveDjangoUrl('edit_recipe', recipe.id) + }) }).catch(err => { StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE) }) @@ -154,6 +188,10 @@ export default { 'mode': this.mode },).then((response) => { this.recipe_json = response.data['recipe_json']; + + this.$set(this.recipe_json, 'unused_keywords', this.recipe_json.keywords.filter(k => k.id === undefined)) + this.$set(this.recipe_json, 'keywords', this.recipe_json.keywords.filter(k => k.id !== undefined)) + this.recipe_tree = response.data['recipe_tree']; this.recipe_html = response.data['recipe_html']; this.recipe_images = response.data['recipe_images']; //todo change on backend as well after old view is deprecated diff --git a/vue/src/utils/openapi/api.ts b/vue/src/utils/openapi/api.ts index dae44015d..c88cb5e21 100644 --- a/vue/src/utils/openapi/api.ts +++ b/vue/src/utils/openapi/api.ts @@ -1856,6 +1856,12 @@ export interface RecipeImage { * @memberof RecipeImage */ image?: any | null; + /** + * + * @type {string} + * @memberof RecipeImage + */ + image_url?: string | null; } /** * @@ -5227,10 +5233,11 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) * * @param {string} id A unique integer value identifying this recipe. * @param {any} [image] + * @param {string} [imageUrl] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - imageRecipe: async (id: string, image?: any, options: any = {}): Promise => { + imageRecipe: async (id: string, image?: any, imageUrl?: string, options: any = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('imageRecipe', 'id', id) const localVarPath = `/api/recipe/{id}/image/` @@ -5252,6 +5259,10 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) localVarFormParams.append('image', image as any); } + if (imageUrl !== undefined) { + localVarFormParams.append('image_url', imageUrl as any); + } + localVarHeaderParameter['Content-Type'] = 'multipart/form-data'; @@ -10341,11 +10352,12 @@ export const ApiApiFp = function(configuration?: Configuration) { * * @param {string} id A unique integer value identifying this recipe. * @param {any} [image] + * @param {string} [imageUrl] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async imageRecipe(id: string, image?: any, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.imageRecipe(id, image, options); + async imageRecipe(id: string, image?: any, imageUrl?: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.imageRecipe(id, image, imageUrl, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -12174,11 +12186,12 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * * @param {string} id A unique integer value identifying this recipe. * @param {any} [image] + * @param {string} [imageUrl] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - imageRecipe(id: string, image?: any, options?: any): AxiosPromise { - return localVarFp.imageRecipe(id, image, options).then((request) => request(axios, basePath)); + imageRecipe(id: string, image?: any, imageUrl?: string, options?: any): AxiosPromise { + return localVarFp.imageRecipe(id, image, imageUrl, options).then((request) => request(axios, basePath)); }, /** * @@ -13992,12 +14005,13 @@ export class ApiApi extends BaseAPI { * * @param {string} id A unique integer value identifying this recipe. * @param {any} [image] + * @param {string} [imageUrl] * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof ApiApi */ - public imageRecipe(id: string, image?: any, options?: any) { - return ApiApiFp(this.configuration).imageRecipe(id, image, options).then((request) => request(this.axios, this.basePath)); + public imageRecipe(id: string, image?: any, imageUrl?: string, options?: any) { + return ApiApiFp(this.configuration).imageRecipe(id, image, imageUrl, options).then((request) => request(this.axios, this.basePath)); } /** From 0d98c7730160c11777140cdcfdeed3de2cd535eb Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 21 Feb 2022 18:29:23 +0100 Subject: [PATCH 06/27] some small ui stuff --- cookbook/helper/recipe_url_import.py | 2 +- vue/src/apps/ImportView/ImportView.vue | 191 +++++++++++++++++-------- 2 files changed, 136 insertions(+), 57 deletions(-) diff --git a/cookbook/helper/recipe_url_import.py b/cookbook/helper/recipe_url_import.py index 3a39b403f..307e5f603 100644 --- a/cookbook/helper/recipe_url_import.py +++ b/cookbook/helper/recipe_url_import.py @@ -33,7 +33,7 @@ def get_from_scraper(scrape, request): except Exception: description = '' - recipe_json['description'] = parse_description(description) + recipe_json['description'] = parse_description(description)[:512] recipe_json['internal'] = True try: diff --git a/vue/src/apps/ImportView/ImportView.vue b/vue/src/apps/ImportView/ImportView.vue index c0d7b8d65..83e6bc321 100644 --- a/vue/src/apps/ImportView/ImportView.vue +++ b/vue/src/apps/ImportView/ImportView.vue @@ -11,70 +11,127 @@
-
Website
- - - - - - - + + + Website + + + + + + + + + + - Clear recent imports - + Clear recent imports + + + + -
Options
- -
-
- Images + + + Additional Options + + + -
-
- +
+
+ +
-
-
-
- Click the image you want to import for this - recipe +
+
+ Click the image you want to import for this + recipe +
+
+ +
-
- + +
+
+ + + + + {{ + k.label + }} + + + + + +
+
+ + + + {{ + k.label + }} + + + + +
-
+ Steps +
+
+ Split + + {{ s.instruction }} + + +
- Keywords -
    -
  • {{ k.label }}
  • -
- unused -
    -
  • {{ - k.label - }} -
  • -
- Steps -
    -
  • {{ s }}
  • -
-
-
+
+ + + + + + + + Import + + + + + + Import & View + Import & Edit + Import & start new import + + + + + -
Import
- Import @@ -109,6 +166,7 @@ import 'bootstrap-vue/dist/bootstrap-vue.css' import {resolveDjangoUrl, ResolveUrlMixin, StandardToasts, ToastMixin} from "@/utils/utils"; import axios from "axios"; import {ApiApiFactory} from "@/utils/openapi/api"; +import draggable from "vuedraggable"; Vue.use(BootstrapVue) @@ -118,7 +176,9 @@ export default { ResolveUrlMixin, ToastMixin, ], - components: {}, + components: { + draggable + }, data() { return { LS_IMPORT_RECENT: 'import_recent_urls', //TODO use central helper to manage all local storage keys (and maybe even access) @@ -146,9 +206,9 @@ export default { */ importRecipe: function () { let apiFactory = new ApiApiFactory() - apiFactory.createRecipe(this.recipe_json).then(response => { + apiFactory.createRecipe(this.recipe_json).then(response => { // save recipe let recipe = response.data - apiFactory.imageRecipe(response.data.id, undefined, this.recipe_json.image).then(response => { + apiFactory.imageRecipe(response.data.id, undefined, this.recipe_json.image).then(response => { // save recipe image StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE) window.location = resolveDjangoUrl('edit_recipe', recipe.id) }).catch(e => { @@ -209,6 +269,25 @@ export default { StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH, err.response.data.msg) }) }, + /** + * Splits the steps of a given recipe at the split character (e.g. \n or \n\n) + * @param split_character: character to split steps at + */ + splitSteps: function (split_character) { + let steps = [] + this.recipe_json.steps.forEach(step => { + step.instruction.split(split_character).forEach(part => { + steps.push({'instruction': part, 'ingredients': []}) + }) + }) + this.recipe_json.steps.forEach(step => { + if (step.ingredients.length > 0) { + console.log('found ingredients', step.ingredients) + steps[0].ingredients = steps[0].ingredients.concat(step.ingredients) + } + }) + this.recipe_json.steps = steps + }, /** * Clear list of recently imported recipe urls */ From 52c16ab7dd4b3df0e6211baea06f0d31625a4dac Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Tue, 22 Feb 2022 17:00:30 +0100 Subject: [PATCH 07/27] all types bascially working (lacking bookmark) --- cookbook/views/import_export.py | 2 +- vue/src/apps/ImportView/ImportView.vue | 115 ++++++++++++++++++------- vue/src/locales/en.json | 2 + vue/src/utils/integration.js | 40 ++++----- 4 files changed, 107 insertions(+), 52 deletions(-) diff --git a/cookbook/views/import_export.py b/cookbook/views/import_export.py index f4aea7c0f..5dcb3ff40 100644 --- a/cookbook/views/import_export.py +++ b/cookbook/views/import_export.py @@ -99,7 +99,7 @@ def import_recipe(request): t.setDaemon(True) t.start() - return JsonResponse({'import_id': [il.pk]}) + return JsonResponse({'import_id': il.pk}) except NotImplementedError: return JsonResponse( { diff --git a/vue/src/apps/ImportView/ImportView.vue b/vue/src/apps/ImportView/ImportView.vue index 83e6bc321..bd559fc3b 100644 --- a/vue/src/apps/ImportView/ImportView.vue +++ b/vue/src/apps/ImportView/ImportView.vue @@ -9,14 +9,16 @@
- - + + + Website - + - Additional Options + Additional + Options + - - + + +
@@ -119,7 +125,8 @@ Import - + @@ -134,16 +141,50 @@ - - + + + + + + + {{ $t('import_duplicates') }} + + + + + + - + +
+ + +
+ {{ $t('Import') }} +
+ - - - Bookmark Text + + + Some pages cannot be imported from their URL, the Bookmarklet can be used to import from + some of them anyway. + 1. Drag the following button to your bookmarks bar Bookmark Text + 2. Open the page you want to import from + 3. Click on the bookmark to perform the import @@ -167,6 +208,7 @@ import {resolveDjangoUrl, ResolveUrlMixin, StandardToasts, ToastMixin} from "@/u import axios from "axios"; import {ApiApiFactory} from "@/utils/openapi/api"; import draggable from "vuedraggable"; +import {INTEGRATIONS} from "@/utils/integration"; Vue.use(BootstrapVue) @@ -181,6 +223,8 @@ export default { }, data() { return { + tab_index: 0, + // URL import LS_IMPORT_RECENT: 'import_recent_urls', //TODO use central helper to manage all local storage keys (and maybe even access) website_url: '', recent_urls: [], @@ -189,10 +233,12 @@ export default { recipe_data: undefined, recipe_tree: undefined, recipe_images: [], - automatic: true, - error: undefined, - loading: false, - preview: false, + // App Import + INTEGRATIONS: INTEGRATIONS, + recipe_app: undefined, + import_duplicates: false, + recipe_files: [], + } }, mounted() { @@ -238,9 +284,7 @@ export default { this.recipe_json = undefined this.recipe_tree = undefined this.recipe_images = [] - this.error = undefined - this.loading = true - this.preview = false + axios.post(resolveDjangoUrl('api_recipe_from_source'), { 'url': this.website_url, 'data': this.source_data, @@ -254,21 +298,30 @@ export default { this.recipe_tree = response.data['recipe_tree']; this.recipe_html = response.data['recipe_html']; - this.recipe_images = response.data['recipe_images']; //todo change on backend as well after old view is deprecated - if (this.automatic) { - this.recipe_data = this.recipe_json; - this.preview = false - } else { - this.preview = true - } - this.loading = false + this.recipe_images = response.data['recipe_images']; + + this.tab_index = 0 }).catch((err) => { - this.error = err.data - this.loading = false - console.log(err.response) StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH, err.response.data.msg) }) }, + /** + * Import recipes with uploaded files and app integration + */ + importAppRecipe: function () { + let formData = new FormData(); + formData.append('type', this.recipe_app); + formData.append('duplicates', this.import_duplicates) + for (let i = 0; i < this.recipe_files.length; i++) { + formData.append('files', this.recipe_files[i]); + } + axios.post(resolveDjangoUrl('view_import'), formData, {headers: {'Content-Type': 'multipart/form-data'}}).then((response) => { + window.location.href = resolveDjangoUrl('view_import_response', response.data['import_id']) + }).catch((err) => { + console.log(err) + StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE) + }) + }, /** * Splits the steps of a given recipe at the split character (e.g. \n or \n\n) * @param split_character: character to split steps at diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index 31fc6e763..930b3302b 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -336,6 +336,8 @@ "Website": "Website", "App": "App", "Bookmarklet": "Bookmarklet", + "import_duplicates": "To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.", + "paste_json": "Paste json or html source here to load recipe.", "search_no_recipes": "Could not find any recipes!", "search_import_help_text": "Import a recipe from an external website or application.", "search_create_help_text": "Create a new recipe directly in Tandoor.", diff --git a/vue/src/utils/integration.js b/vue/src/utils/integration.js index 5cdedbaea..326d0a30f 100644 --- a/vue/src/utils/integration.js +++ b/vue/src/utils/integration.js @@ -1,22 +1,22 @@ // containing all data and functions regarding the different integrations -export const INTEGRATIONS = { - DEFAULT: {name: "Tandoor", import: true, export: true}, - CHEFTAP: {name: "Cheftap", import: true, export: false}, - CHOWDOWN: {name: "Chowdown", import: true, export: false}, - COOKBOOKAPP: {name: "CookBookApp", import: true, export: false}, - COPYMETHAT: {name: "CopyMeThat", import: true, export: false}, - DOMESTICA: {name: "Domestica", import: true, export: false}, - MEALIE: {name: "Mealie", import: true, export: false}, - MEALMASTER: {name: "Mealmaster", import: true, export: false}, - NEXTCLOUD: {name: "Nextcloud Cookbook", import: true, export: false}, - OPENEATS: {name: "Openeats", import: true, export: false}, - PAPRIKA: {name: "Paprika", import: true, export: false}, - PEPPERPLATE: {name: "Pepperplate", import: true, export: false}, - PLANTOEAT: {name: "Plantoeat", import: true, export: false}, - RECETTETEK: {name: "RecetteTek", import: true, export: false}, - RECIPEKEEPER: {name: "Recipekeeper", import: true, export: false}, - RECIPESAGE: {name: "Recipesage", import: true, export: true}, - REZKONV: {name: "Rezkonv", import: true, export: false}, - SAFRON: {name: "Safron", import: true, export: true}, -} +export const INTEGRATIONS = [ + {id: 'DEFAULT', name: "Tandoor", import: true, export: true}, + {id: 'CHEFTAP', name: "Cheftap", import: true, export: false}, + {id: 'CHOWDOWN', name: "Chowdown", import: true, export: false}, + {id: 'COOKBOOKAPP', name: "CookBookApp", import: true, export: false}, + {id: 'COPYMETHAT', name: "CopyMeThat", import: true, export: false}, + {id: 'DOMESTICA', name: "Domestica", import: true, export: false}, + {id: 'MEALIE', name: "Mealie", import: true, export: false}, + {id: 'MEALMASTER', name: "Mealmaster", import: true, export: false}, + {id: 'NEXTCLOUD', name: "Nextcloud Cookbook", import: true, export: false}, + {id: 'OPENEATS', name: "Openeats", import: true, export: false}, + {id: 'PAPRIKA', name: "Paprika", import: true, export: false}, + {id: 'PEPPERPLATE', name: "Pepperplate", import: true, export: false}, + {id: 'PLANTOEAT', name: "Plantoeat", import: true, export: false}, + {id: 'RECETTETEK', name: "RecetteTek", import: true, export: false}, + {id: 'RECIPEKEEPER', name: "Recipekeeper", import: true, export: false}, + {id: 'RECIPESAGE', name: "Recipesage", import: true, export: true}, + {id: 'REZKONV', name: "Rezkonv", import: true, export: false}, + {id: 'SAFRON', name: "Safron", import: true, export: true}, +] From 6d8fe3c1627086d1288fd4a939aaf24c6085b8c5 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Fri, 4 Mar 2022 15:54:11 +0100 Subject: [PATCH 08/27] wip --- ...0219_1655.py => 0173_recipe_source_url.py} | 9 +-- cookbook/views/api.py | 4 +- vue/src/apps/ImportView/ImportView.vue | 72 ++++++++++++++----- 3 files changed, 60 insertions(+), 25 deletions(-) rename cookbook/migrations/{0172_auto_20220219_1655.py => 0173_recipe_source_url.py} (50%) diff --git a/cookbook/migrations/0172_auto_20220219_1655.py b/cookbook/migrations/0173_recipe_source_url.py similarity index 50% rename from cookbook/migrations/0172_auto_20220219_1655.py rename to cookbook/migrations/0173_recipe_source_url.py index 36238f5f9..dc84e64eb 100644 --- a/cookbook/migrations/0172_auto_20220219_1655.py +++ b/cookbook/migrations/0173_recipe_source_url.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.12 on 2022-02-19 15:55 +# Generated by Django 3.2.12 on 2022-03-04 13:39 from django.db import migrations, models @@ -6,15 +6,10 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('cookbook', '0171_alter_searchpreference_trigram_threshold'), + ('cookbook', '0172_ingredient_original_text'), ] operations = [ - migrations.AddField( - model_name='ingredient', - name='original_text', - field=models.CharField(blank=True, default=None, max_length=512, null=True), - ), migrations.AddField( model_name='recipe', name='source_url', diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 39df3b076..1463c3f33 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -1145,7 +1145,7 @@ def recipe_from_source(request): 'error': True, 'msg': _('Connection Refused.') }, status=400) - recipe_json, recipe_tree, recipe_html, images = get_recipe_from_source(data, url, request) + recipe_json, recipe_tree, recipe_html, recipe_images = get_recipe_from_source(data, url, request) if len(recipe_tree) == 0 and len(recipe_json) == 0: return JsonResponse({ 'error': True, @@ -1156,7 +1156,7 @@ def recipe_from_source(request): 'recipe_json': recipe_json, 'recipe_tree': recipe_tree, 'recipe_html': recipe_html, - 'recipe_images': images, + 'recipe_images': recipe_images, }) else: diff --git a/vue/src/apps/ImportView/ImportView.vue b/vue/src/apps/ImportView/ImportView.vue index bd559fc3b..34bc58314 100644 --- a/vue/src/apps/ImportView/ImportView.vue +++ b/vue/src/apps/ImportView/ImportView.vue @@ -11,14 +11,15 @@
- + + Website + role="tabpanel" v-model="collapse_visible.url"> + - Additional - Options + Options + role="tabpanel" v-model="collapse_visible.options"> -
@@ -64,6 +64,7 @@ recipe
+ No additional images found in source. @@ -107,10 +108,14 @@
Split + Merge all + {{ s.instruction }} + v-bind:key="s.instruction"> + Delete + Merge
@@ -121,12 +126,28 @@ + - Import + Advanced Options + + + + + + + + + + + + + + Import + role="tabpanel" v-model="collapse_visible.import"> @@ -139,10 +160,9 @@ - - + {% trans 'Automatic' %} - - - -
-
- -
- -
-
-
- - - - -
- -
- -
-
- {% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %} - - {% else %} - - {% endif %} -
- - -
-
-
- -
-
-

{% trans 'Preview Recipe Data' %}

-
{% trans 'Drag recipe attributes from the right into the appropriate box below.' %}
-
-
- -
-
-
- {% trans 'Name' %} - -
-
{% trans 'Text dragged here will be appended to the name.' %}
-
- -
-
[[recipe_json.name]]
-
-
-
- -
-
-
- {% trans 'Description' %} - -
-
{% trans 'Text dragged here will be appended to the description.' %}
-
- -
-
[[recipe_json.description]]
-
-
-
- -
-
-
- {% trans 'Keywords' %} - -
-
{% trans 'Keywords dragged here will be appended to current list' %}
-
- -
-
-
[[kw.text]]
-
-
-
-
- -
-
- {% trans 'Image' %} - -
- -
- Recipe Image -
-
-
- -
-
-
-
- {% trans 'Servings' %} - -
-
-
[[recipe_json.servings]]
-
-
-
-
-
-
- {% trans 'Prep Time' %} - -
-
-
[[recipe_json.prepTime]]
-
-
-
-
-
-
- {% trans 'Cook Time' %} - -
-
-
[[recipe_json.cookTime]]
-
-
-
-
- -
-
-
- {% trans 'Ingredients' %} - -
-
{% trans 'Ingredients dragged here will be appended to current list.' %}
-
- -
-
    -
    -
  • -
    [[i.amount]]
    -
    [[i.unit.text]]
    -
    [[i.ingredient.text]]
    -
    [[i.note]]
    -
  • -
    -
-
-
-
- -
-
-
- {% trans 'Instructions' %} - -
-
{% trans 'Recipe instructions dragged here will be appended to current instructions.' %}
-
- -
-
[[recipe_json.recipeInstructions]]
-
-
-
-
-
-
- - -
- - -
-
-
-

{% trans 'Discovered Attributes' %}

-
- {% trans 'Drag recipe attributes from below into the appropriate box on the left. Click any node to display its full properties.' %} -
-
-
- - - -
- -
-
-
-
- {% trans 'Blank Field' %} - -
-
{% trans 'Items dragged to Blank Field will be appended.' %}
-
-
- [[blank_field]] -
-
- - - - - -
-
    -
    - [[txt]] - -
    -
-
- -
-
    -
    - Image - -
    -
-
-
-
-
- -
-
- - - - - +
- - {% endblock %} + + +{% block script %} + {% if debug %} + + {% else %} + + {% endif %} + + + + {% render_bundle 'import_view' %} +{% endblock %} \ No newline at end of file diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 57cb39fa3..06b6e62c1 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -70,7 +70,7 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializ SupermarketCategorySerializer, SupermarketSerializer, SyncLogSerializer, SyncSerializer, UnitSerializer, UserFileSerializer, UserNameSerializer, UserPreferenceSerializer, - ViewLogSerializer, IngredientSimpleSerializer) + ViewLogSerializer, IngredientSimpleSerializer, BookmarkletImportListSerializer) from recipes import settings @@ -974,6 +974,11 @@ class BookmarkletImportViewSet(viewsets.ModelViewSet): serializer_class = BookmarkletImportSerializer permission_classes = [CustomIsUser] + def get_serializer_class(self): + if self.action == 'list': + return BookmarkletImportListSerializer + return self.serializer_class + def get_queryset(self): return self.queryset.filter(space=self.request.space).all() diff --git a/cookbook/views/data.py b/cookbook/views/data.py index 9980744b4..8dd1e969f 100644 --- a/cookbook/views/data.py +++ b/cookbook/views/data.py @@ -10,7 +10,7 @@ from django_tables2 import RequestConfig from rest_framework.authtoken.models import Token from cookbook.forms import BatchEditForm, SyncForm -from cookbook.helper.permission_helper import group_required, has_group_permission +from cookbook.helper.permission_helper import group_required, has_group_permission, above_space_limit from cookbook.models import (Comment, Food, Keyword, Recipe, RecipeImport, Sync, Unit, UserPreference, BookmarkletImport) from cookbook.tables import SyncTable @@ -19,12 +19,9 @@ from recipes import settings @group_required('user') def sync(request): - if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function - messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.')) - return HttpResponseRedirect(reverse('index')) - - if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users: - messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.')) + limit, msg = above_space_limit(request.space) + if limit: + messages.add_message(request, messages.WARNING, msg) return HttpResponseRedirect(reverse('index')) if request.space.demo or settings.HOSTED: @@ -113,12 +110,9 @@ def batch_edit(request): @group_required('user') def import_url(request): - if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function - messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.')) - return HttpResponseRedirect(reverse('index')) - - if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users: - messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.')) + limit, msg = above_space_limit(request.space) + if limit: + messages.add_message(request, messages.WARNING, msg) return HttpResponseRedirect(reverse('index')) if (api_token := Token.objects.filter(user=request.user).first()) is None: @@ -129,7 +123,7 @@ def import_url(request): if bookmarklet_import := BookmarkletImport.objects.filter(id=request.GET['id']).first(): bookmarklet_import_id = bookmarklet_import.pk - return render(request, 'test.html', {'api_token': api_token, 'bookmarklet_import_id': bookmarklet_import_id}) + return render(request, 'url_import.html', {'api_token': api_token, 'bookmarklet_import_id': bookmarklet_import_id}) class Object(object): diff --git a/cookbook/views/edit.py b/cookbook/views/edit.py index ff3134662..50e878662 100644 --- a/cookbook/views/edit.py +++ b/cookbook/views/edit.py @@ -9,7 +9,7 @@ from django.views.generic import UpdateView from django.views.generic.edit import FormMixin from cookbook.forms import CommentForm, ExternalRecipeForm, MealPlanForm, StorageForm, SyncForm -from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required +from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required, above_space_limit from cookbook.models import (Comment, MealPlan, MealType, Recipe, RecipeImport, Storage, Sync, UserPreference) from cookbook.provider.dropbox import Dropbox @@ -39,12 +39,9 @@ def convert_recipe(request, pk): @group_required('user') def internal_recipe_update(request, pk): - if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() > request.space.max_recipes: # TODO move to central helper function - messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.')) - return HttpResponseRedirect(reverse('view_recipe', args=[pk])) - - if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users: - messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.')) + limit, msg = above_space_limit(request.space) + if limit: + messages.add_message(request, messages.WARNING, msg) return HttpResponseRedirect(reverse('view_recipe', args=[pk])) recipe_instance = get_object_or_404(Recipe, pk=pk, space=request.space) diff --git a/cookbook/views/import_export.py b/cookbook/views/import_export.py index daa55d432..d81777fce 100644 --- a/cookbook/views/import_export.py +++ b/cookbook/views/import_export.py @@ -10,7 +10,7 @@ from django.urls import reverse from django.utils.translation import gettext as _ from cookbook.forms import ExportForm, ImportExportBase, ImportForm -from cookbook.helper.permission_helper import group_required +from cookbook.helper.permission_helper import group_required, above_space_limit from cookbook.helper.recipe_search import RecipeSearch from cookbook.integration.cheftap import ChefTap from cookbook.integration.chowdown import Chowdown @@ -84,12 +84,9 @@ def get_integration(request, export_type): @group_required('user') def import_recipe(request): - if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function - messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.')) - return HttpResponseRedirect(reverse('index')) - - if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users: - messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.')) + limit, msg = above_space_limit(request.space) + if limit: + messages.add_message(request, messages.WARNING, msg) return HttpResponseRedirect(reverse('index')) if request.method == "POST": diff --git a/vue/src/apps/ImportView/ImportView.vue b/vue/src/apps/ImportView/ImportView.vue index bb39f49d9..c049100d3 100644 --- a/vue/src/apps/ImportView/ImportView.vue +++ b/vue/src/apps/ImportView/ImportView.vue @@ -106,10 +106,12 @@
-

{{ recipe_json.name - }}

+ }} + + {{ + $t('Help') + }} + x.id === this.recipe_app)[0] + }, + }, data() { return { tab_index: 0, @@ -343,7 +355,8 @@ export default { recipe_files: [], loading: false, empty_input: false, - edit_name: false,// Bookmarklet + edit_name: false, + // Bookmarklet BOOKMARKLET_CODE: window.BOOKMARKLET_CODE } }, @@ -422,7 +435,7 @@ export default { window.localStorage.setItem(this.LS_IMPORT_RECENT, JSON.stringify(this.recent_urls)) } - if (this.website_url === '') { + if (this.website_url === '' && bookmarklet === undefined) { this.empty_input = true setTimeout(() => { this.empty_input = false @@ -541,7 +554,7 @@ export default { `localStorage.setItem("token", "${window.API_TOKEN}");` + `document.body.appendChild(document.createElement("script")).src="${localStorage.getItem('BASE_PATH')}${resolveDjangoStatic('/js/bookmarklet.js')}?r="+Math.floor(Math.random()*999999999)}` + `})()` - } + }, }, directives: { hover: { diff --git a/vue/src/utils/integration.js b/vue/src/utils/integration.js index a2e6db1bb..c5a450215 100644 --- a/vue/src/utils/integration.js +++ b/vue/src/utils/integration.js @@ -1,24 +1,24 @@ // containing all data and functions regarding the different integrations export const INTEGRATIONS = [ - {id: 'DEFAULT', name: "Tandoor", import: true, export: true}, - {id: 'CHEFTAP', name: "Cheftap", import: true, export: false}, - {id: 'CHOWDOWN', name: "Chowdown", import: true, export: false}, - {id: 'COOKBOOKAPP', name: "CookBookApp", import: true, export: false}, - {id: 'COOKMATE', name: "Cookmate", import: true, export: false}, - {id: 'COPYMETHAT', name: "CopyMeThat", import: true, export: false}, - {id: 'DOMESTICA', name: "Domestica", import: true, export: false}, - {id: 'MEALIE', name: "Mealie", import: true, export: false}, - {id: 'MEALMASTER', name: "Mealmaster", import: true, export: false}, - {id: 'MELARECIPES', name: "Melarecipes", import: true, export: false}, - {id: 'NEXTCLOUD', name: "Nextcloud Cookbook", import: true, export: false}, - {id: 'OPENEATS', name: "Openeats", import: true, export: false}, - {id: 'PAPRIKA', name: "Paprika", import: true, export: false}, - {id: 'PEPPERPLATE', name: "Pepperplate", import: true, export: false}, - {id: 'PLANTOEAT', name: "Plantoeat", import: true, export: false}, - {id: 'RECETTETEK', name: "RecetteTek", import: true, export: false}, - {id: 'RECIPEKEEPER', name: "Recipekeeper", import: true, export: false}, - {id: 'RECIPESAGE', name: "Recipesage", import: true, export: true}, - {id: 'REZKONV', name: "Rezkonv", import: true, export: false}, - {id: 'SAFRON', name: "Safron", import: true, export: true}, + {id: 'DEFAULT', name: "Tandoor", import: true, export: true, help_url: 'https://docs.tandoor.dev/features/import_export/#default'}, + {id: 'CHEFTAP', name: "Cheftap", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#cheftap'}, + {id: 'CHOWDOWN', name: "Chowdown", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#chowdown'}, + {id: 'COOKBOOKAPP', name: "CookBookApp", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#cookbookapp'}, + {id: 'COOKMATE', name: "Cookmate", import: true, export: false, help_url: ''}, + {id: 'COPYMETHAT', name: "CopyMeThat", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#copymethat'}, + {id: 'DOMESTICA', name: "Domestica", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#domestica'}, + {id: 'MEALIE', name: "Mealie", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#mealie'}, + {id: 'MEALMASTER', name: "Mealmaster", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#mealmaster'}, + {id: 'MELARECIPES', name: "Melarecipes", import: true, export: false, help_url: ''}, + {id: 'NEXTCLOUD', name: "Nextcloud Cookbook", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#nextcloud'}, + {id: 'OPENEATS', name: "Openeats", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#openeats'}, + {id: 'PAPRIKA', name: "Paprika", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#paprika'}, + {id: 'PEPPERPLATE', name: "Pepperplate", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#pepperplate'}, + {id: 'PLANTOEAT', name: "Plantoeat", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#plantoeat'}, + {id: 'RECETTETEK', name: "RecetteTek", import: true, export: false, help_url: ''}, + {id: 'RECIPEKEEPER', name: "Recipekeeper", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#recipekeeper'}, + {id: 'RECIPESAGE', name: "Recipesage", import: true, export: true, help_url: 'https://docs.tandoor.dev/features/import_export/#recipesage'}, + {id: 'REZKONV', name: "Rezkonv", import: true, export: false, help_url: 'https://docs.tandoor.dev/features/import_export/#rezkonv'}, + {id: 'SAFRON', name: "Safron", import: true, export: true, help_url: 'https://docs.tandoor.dev/features/import_export/#safron'}, ] From 1740913a140fcd5871638a2685221efa5959bb17 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Fri, 22 Apr 2022 20:19:39 +0200 Subject: [PATCH 24/27] improve experience when importing multiple recipes --- cookbook/views/api.py | 2 +- vue/src/apps/ImportView/ImportView.vue | 140 ++++++++++++++++++------- vue/src/components/RecipeCard.vue | 2 +- 3 files changed, 107 insertions(+), 37 deletions(-) diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 06b6e62c1..1eb3d55e3 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -1199,7 +1199,7 @@ def recipe_from_source(request): 'recipe_json': recipe_json, 'recipe_tree': recipe_tree, 'recipe_html': recipe_html, - 'recipe_images': recipe_images, + 'recipe_images': list(dict.fromkeys(recipe_images)), }) diff --git a/vue/src/apps/ImportView/ImportView.vue b/vue/src/apps/ImportView/ImportView.vue index c049100d3..0090ba926 100644 --- a/vue/src/apps/ImportView/ImportView.vue +++ b/vue/src/apps/ImportView/ImportView.vue @@ -37,7 +37,8 @@ v-model="website_url" placeholder="Website URL" @paste="onURLPaste"> - @@ -57,7 +58,7 @@
  • {{ + @click="loadRecipe(x, false, undefined)">{{ x }}
  • @@ -195,7 +196,8 @@ - + - + + + :show_context_menu="false" + > + + + + +
    + {{ + r.name + }} + Imported + +
    +
    + + +
    + {{ u }} + Failed + +
    +
    +
    - Import & View - Import & Edit + Import & View + Import & Edit - Import & Restart + Import & Restart + + Restart @@ -261,7 +298,7 @@ :placeholder="$t('paste_json')" style="font-size: 12px">
- {{ $t('Import') }} @@ -339,7 +376,9 @@ export default { website_url_list: [ 'https://madamedessert.de/schokoladenpudding-rezept-mit-echter-schokolade/', 'https://www.essen-und-trinken.de/rezepte/58294-rzpt-schokoladenpudding', - 'https://www.chefkoch.de/rezepte/1825781296124455/Schokoladenpudding-selbst-gemacht.html' + 'https://www.chefkoch.de/rezepte/1825781296124455/Schokoladenpudding-selbst-gemacht.html', + 'test.com', + 'https://bla.com' ], import_multiple: false, recent_urls: [], @@ -348,6 +387,8 @@ export default { recipe_html: undefined, recipe_tree: undefined, recipe_images: [], + imported_recipes: [], + failed_imports: [], // App Import INTEGRATIONS: INTEGRATIONS, recipe_app: undefined, @@ -366,7 +407,7 @@ export default { this.tab_index = 0 //TODO add ability to pass open tab via get parameter if (window.BOOKMARKLET_IMPORT_ID !== -1) { - this.loadRecipe(false, window.BOOKMARKLET_IMPORT_ID) + this.loadRecipe('', false, window.BOOKMARKLET_IMPORT_ID) } }, methods: { @@ -374,24 +415,43 @@ export default { * Import recipe based on the data configured by the client * @param action: action to perform after import (options are: edit, view, import) * @param data: if parameter is passed ignore global application state and import form data variable + * @param silent do not show any messages for imports */ - importRecipe: function (action, data) { - this.$set(this.recipe_json, 'keywords', this.recipe_json.keywords.filter(k => k.show)) + importRecipe: function (action, data, silent) { + if (this.recipe_json !== undefined) { + this.$set(this.recipe_json, 'keywords', this.recipe_json.keywords.filter(k => k.show)) + } let apiFactory = new ApiApiFactory() let recipe_json = data !== undefined ? data : this.recipe_json - apiFactory.createRecipe(recipe_json).then(response => { // save recipe - let recipe = response.data - apiFactory.imageRecipe(response.data.id, undefined, recipe_json.image).then(response => { // save recipe image - StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE) - this.afterImportAction(action, recipe) - }).catch(e => { - StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE) - this.afterImportAction(action, recipe) + if (recipe_json !== undefined) { + apiFactory.createRecipe(recipe_json).then(response => { // save recipe + let recipe = response.data + apiFactory.imageRecipe(response.data.id, undefined, recipe_json.image).then(response => { // save recipe image + if (!silent) { + StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE) + } + this.afterImportAction(action, recipe) + }).catch(e => { + if (!silent) { + StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE) + } + this.afterImportAction(action, recipe) + }) + }).catch(err => { + if (recipe_json.source_url !== '') { + this.failed_imports.push(recipe_json.source_url) + } + if (!silent) { + StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE) + } }) - }).catch(err => { - StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE) - }) + } else { + console.log('cant import recipe without data') + if (!silent) { + StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE) + } + } }, /** * Action performed after URL import @@ -413,6 +473,9 @@ export default { case 'import': location.reload(); break; + case 'multi_import': + this.imported_recipes.push(recipe) + break; case 'nothing': break; } @@ -420,22 +483,23 @@ export default { /** * Requests the recipe to be loaded form the source (url/data) from the server * Updates all variables to contain what they need to render either simple preview or manual mapping mode + * @param url url to import (optional, empty string for bookmarklet imports) * @param silent do not open the options tab after loading the recipe * @param bookmarklet id of bookmarklet import to load instead of url, default undefined */ - loadRecipe: function (silent, bookmarklet) { + loadRecipe: function (url, silent, bookmarklet) { // keep list of recently imported urls - if (this.website_url !== '') { + if (url !== '') { if (this.recent_urls.length > 5) { this.recent_urls.pop() } - if (this.recent_urls.filter(x => x === this.website_url).length === 0) { - this.recent_urls.push(this.website_url) + if (this.recent_urls.filter(x => x === url).length === 0) { + this.recent_urls.push(url) } window.localStorage.setItem(this.LS_IMPORT_RECENT, JSON.stringify(this.recent_urls)) } - if (this.website_url === '' && bookmarklet === undefined) { + if (url === '' && bookmarklet === undefined) { this.empty_input = true setTimeout(() => { this.empty_input = false @@ -443,7 +507,9 @@ export default { return } - this.loading = true + if (!silent) { + this.loading = true + } // reset all variables this.recipe_html = undefined @@ -453,7 +519,7 @@ export default { // load recipe let payload = { - 'url': this.website_url, + 'url': url, 'data': this.source_data, } @@ -484,7 +550,11 @@ export default { } return this.recipe_json }).catch((err) => { + if (url !== '') { + this.failed_imports.push(url) + } StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH, err.response.data.msg) + throw "Load Recipe Error" }) }, /** @@ -492,13 +562,13 @@ export default { * Takes input from website_url_list */ autoImport: function () { + this.collapse_visible.import = true this.website_url_list.forEach(r => { - this.website_url = r - this.loadRecipe(true, undefined).then((recipe_json) => { - this.importRecipe('nothing', recipe_json) //TODO handle feedback of what was imported and what not + this.loadRecipe(r, true, undefined).then((recipe_json) => { + this.website_url_list = this.website_url_list.filter(u => u !== r) + this.importRecipe('multi_import', recipe_json) //TODO handle feedback of what was imported and what not }) }) - this.website_url_list = [] }, /** * Import recipes with uploaded files and app integration diff --git a/vue/src/components/RecipeCard.vue b/vue/src/components/RecipeCard.vue index 3597cb4aa..6871f475d 100755 --- a/vue/src/components/RecipeCard.vue +++ b/vue/src/components/RecipeCard.vue @@ -24,7 +24,7 @@