diff --git a/.idea/recipes.iml b/.idea/recipes.iml deleted file mode 100644 index 8a0e59c8e..000000000 --- a/.idea/recipes.iml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 28a348e16..67fd2d54c 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -1,3 +1,4 @@ +import random import traceback import uuid from datetime import datetime, timedelta @@ -999,6 +1000,17 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): read_only_fields = ('created_by',) +class AutoMealPlanSerializer(serializers.Serializer): + + start_date = serializers.DateField() + end_date = serializers.DateField() + meal_type_id = serializers.IntegerField() + keywords = KeywordSerializer(many=True) + servings = CustomDecimalField() + shared = UserSerializer(many=True, required=False, allow_null=True) + addshopping = serializers.BooleanField() + + class ShoppingListRecipeSerializer(serializers.ModelSerializer): name = serializers.SerializerMethodField('get_name') # should this be done at the front end? recipe_name = serializers.ReadOnlyField(source='recipe.name') diff --git a/cookbook/urls.py b/cookbook/urls.py index 7b6d18380..0308a3958 100644 --- a/cookbook/urls.py +++ b/cookbook/urls.py @@ -36,6 +36,7 @@ router.register(r'ingredient', api.IngredientViewSet) router.register(r'invite-link', api.InviteLinkViewSet) router.register(r'keyword', api.KeywordViewSet) router.register(r'meal-plan', api.MealPlanViewSet) +router.register(r'auto-plan', api.AutoPlanViewSet, basename='auto-plan') router.register(r'meal-type', api.MealTypeViewSet) router.register(r'recipe', api.RecipeViewSet) router.register(r'recipe-book', api.RecipeBookViewSet) diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 9f9f64204..96e0ce2d6 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -1,7 +1,9 @@ +import datetime import io import json import mimetypes import pathlib +import random import re import threading import traceback @@ -25,6 +27,7 @@ from django.core.files import File from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When, Avg, Max from django.db.models.fields.related import ForeignObjectRel from django.db.models.functions import Coalesce, Lower +from django.db.models.signals import post_save from django.http import FileResponse, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse @@ -94,7 +97,8 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportListSeri SyncLogSerializer, SyncSerializer, UnitSerializer, UserFileSerializer, UserSerializer, UserPreferenceSerializer, UserSpaceSerializer, ViewLogSerializer, AccessTokenSerializer, FoodSimpleSerializer, - RecipeExportSerializer, UnitConversionSerializer, PropertyTypeSerializer, PropertySerializer) + RecipeExportSerializer, UnitConversionSerializer, PropertyTypeSerializer, + PropertySerializer, AutoMealPlanSerializer) from cookbook.views.import_export import get_integration from recipes import settings @@ -666,6 +670,67 @@ class MealPlanViewSet(viewsets.ModelViewSet): return queryset +class AutoPlanViewSet(viewsets.ViewSet): + def create(self, request): + serializer = AutoMealPlanSerializer(data=request.data) + + if serializer.is_valid(): + keywords = serializer.validated_data['keywords'] + start_date = serializer.validated_data['start_date'] + end_date = serializer.validated_data['end_date'] + meal_type = MealType.objects.get(pk=serializer.validated_data['meal_type_id']) + servings = serializer.validated_data['servings'] + shared = serializer.get_initial().get('shared', None) + shared_pks = list() + if shared is not None: + for i in range(len(shared)): + shared_pks.append(shared[i]['id']) + + days = (end_date - start_date).days + 1 + recipes = Recipe.objects.all() + meal_plans = list() + + for keyword in keywords: + recipes = recipes.filter(keywords__name=keyword['name']) + + if len(recipes) == 0: + return Response(serializer.data) + recipes = recipes.order_by('?')[:days] + recipes = list(recipes) + + for i in range(0, days): + day = start_date + datetime.timedelta(i) + recipe = recipes[i % len(recipes)] + args = {'recipe': recipe, 'servings': servings, 'title': recipe.name, + 'created_by': request.user, + 'meal_type': meal_type, + 'note': '', 'date': day, 'space': request.space} + + m = MealPlan(**args) + meal_plans.append(m) + + MealPlan.objects.bulk_create(meal_plans) + + for m in meal_plans: + m.shared.set(shared_pks) + + if request.data.get('addshopping', False): + SLR = RecipeShoppingEditor(user=request.user, space=request.space) + SLR.create(mealplan=m, servings=servings) + + else: + post_save.send( + sender=m.__class__, + instance=m, + created=True, + update_fields=None, + ) + + return Response(serializer.data) + + return Response(serializer.errors, 400) + + class MealTypeViewSet(viewsets.ModelViewSet): """ returns list of meal types created by the diff --git a/vue/src/apps/MealPlanView/MealPlanView.vue b/vue/src/apps/MealPlanView/MealPlanView.vue index 6ed34700d..2763ac0d6 100644 --- a/vue/src/apps/MealPlanView/MealPlanView.vue +++ b/vue/src/apps/MealPlanView/MealPlanView.vue @@ -279,12 +279,26 @@ :create_date="mealplan_default_date" @reload-meal-types="refreshMealTypes" > +
+ + + {{ $t("Export_To_ICal") }} @@ -297,6 +311,7 @@ {{ $t("Create") }} +
@@ -322,6 +337,8 @@ import {CalendarView, CalendarMathMixin} from "vue-simple-calendar/src/component import {ApiApiFactory} from "@/utils/openapi/api" import BottomNavigationBar from "@/components/BottomNavigationBar.vue"; import {useMealPlanStore} from "@/stores/MealPlanStore"; +import axios from "axios"; +import AutoMealPlanModal from "@/components/AutoMealPlanModal"; const {makeToast} = require("@/utils/utils") @@ -334,6 +351,7 @@ let SETTINGS_COOKIE_NAME = "mealplan_settings" export default { name: "MealPlanView", components: { + AutoMealPlanModal, MealPlanEditModal, MealPlanCard, CalendarView, @@ -347,6 +365,16 @@ export default { mixins: [CalendarMathMixin, ApiMixin, ResolveUrlMixin], data: function () { return { + AutoPlan: { + meal_types: [], + keywords: [[]], + servings: 1, + date: Date.now(), + startDay: null, + endDay: null, + shared: [], + addshopping: false + }, showDate: new Date(), plan_entries: [], recipe_viewed: {}, @@ -656,6 +684,39 @@ export default { this.$bvModal.show(`id_meal_plan_edit_modal`) }) + }, + createAutoPlan() { + this.$bvModal.show(`autoplan-modal`) + }, + async autoPlanThread(autoPlan, mealTypeIndex) { + let apiClient = new ApiApiFactory() + let data = { + "start_date" : moment(autoPlan.startDay).format("YYYY-MM-DD"), + "end_date" : moment(autoPlan.endDay).format("YYYY-MM-DD"), + "meal_type_id" : autoPlan.meal_types[mealTypeIndex].id, + "keywords" : autoPlan.keywords[mealTypeIndex], + "servings" : autoPlan.servings, + "shared" : autoPlan.shared, + "addshopping": autoPlan.addshopping + } + await apiClient.createAutoPlanViewSet(data) + + }, + async doAutoPlan(autoPlan) { + for (let i = 0; i < autoPlan.meal_types.length; i++) { + if (autoPlan.keywords[i].length === 0) continue + await this.autoPlanThread(autoPlan, i) + } + this.refreshEntries() + }, + refreshEntries(){//todo Remove method + let date = this.current_period + useMealPlanStore().refreshFromAPI(moment(date.periodStart).format("YYYY-MM-DD"), moment(date.periodEnd).format("YYYY-MM-DD")) + }, + deleteAll(){//todo Remove method, only used in debugging + for (let i = 0; i < useMealPlanStore().plan_list.length; i++) { + useMealPlanStore().deleteObject(useMealPlanStore().plan_list[i]) + } } }, directives: { diff --git a/vue/src/components/AutoMealPlanModal.vue b/vue/src/components/AutoMealPlanModal.vue new file mode 100644 index 000000000..a2997c561 --- /dev/null +++ b/vue/src/components/AutoMealPlanModal.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/vue/src/utils/openapi/api.ts b/vue/src/utils/openapi/api.ts index 47bca8b10..f180b5469 100644 --- a/vue/src/utils/openapi/api.ts +++ b/vue/src/utils/openapi/api.ts @@ -5356,6 +5356,39 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * + * @param {any} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createAutoPlanViewSet: async (body?: any, options: any = {}): Promise => { + const localVarPath = `/api/auto-plan/`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {Automation} [automation] @@ -14686,6 +14719,16 @@ export const ApiApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.createAccessToken(accessToken, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {any} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createAutoPlanViewSet(body?: any, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createAutoPlanViewSet(body, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {Automation} [automation] @@ -17464,6 +17507,15 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: createAccessToken(accessToken?: AccessToken, options?: any): AxiosPromise { return localVarFp.createAccessToken(accessToken, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {any} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createAutoPlanViewSet(body?: any, options?: any): AxiosPromise { + return localVarFp.createAutoPlanViewSet(body, options).then((request) => request(axios, basePath)); + }, /** * * @param {Automation} [automation] @@ -19981,6 +20033,17 @@ export class ApiApi extends BaseAPI { return ApiApiFp(this.configuration).createAccessToken(accessToken, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {any} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public createAutoPlanViewSet(body?: any, options?: any) { + return ApiApiFp(this.configuration).createAutoPlanViewSet(body, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {Automation} [automation]