Merge branch 'develop' of https://github.com/vabene1111/recipes into develop

# Conflicts:
#	vue/src/components/Modals/LookupInput.vue
This commit is contained in:
Kaibu
2022-01-17 23:48:12 +01:00
22 changed files with 591 additions and 323 deletions

View File

@@ -45,7 +45,8 @@ SHOPPING_MIN_AUTOSYNC_INTERVAL=5
# Default for user setting sticky navbar
# STICKY_NAV_PREF_DEFAULT=1
# If base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
# If base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
# Be sure to not have a trailing slash: e.g. '/recipes' instead of '/recipes/'
# SCRIPT_NAME=/recipes
# If staticfiles are stored at a different location uncomment and change accordingly, MUST END IN /
@@ -148,4 +149,4 @@ REVERSE_PROXY_AUTH=0
# Enables exporting PDF (see export docs)
# Disabled by default, uncomment to enable
# ENABLE_PDF_EXPORT=1
# ENABLE_PDF_EXPORT=1

View File

@@ -15,7 +15,7 @@ from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
TelegramBot, Unit, UserFile, UserPreference, ViewLog)
TelegramBot, Unit, UserFile, UserPreference, ViewLog, Automation)
class CustomUserAdmin(UserAdmin):
@@ -29,11 +29,52 @@ admin.site.register(User, CustomUserAdmin)
admin.site.unregister(Group)
@admin.action(description='Delete all data from a space')
def delete_space_action(modeladmin, request, queryset):
for space in queryset:
CookLog.objects.filter(space=space).delete()
ViewLog.objects.filter(space=space).delete()
ImportLog.objects.filter(space=space).delete()
BookmarkletImport.objects.filter(space=space).delete()
Comment.objects.filter(recipe__space=space).delete()
Keyword.objects.filter(space=space).delete()
Ingredient.objects.filter(space=space).delete()
Food.objects.filter(space=space).delete()
Unit.objects.filter(space=space).delete()
Step.objects.filter(space=space).delete()
NutritionInformation.objects.filter(space=space).delete()
RecipeBookEntry.objects.filter(book__space=space).delete()
RecipeBook.objects.filter(space=space).delete()
MealType.objects.filter(space=space).delete()
MealPlan.objects.filter(space=space).delete()
ShareLink.objects.filter(space=space).delete()
Recipe.objects.filter(space=space).delete()
RecipeImport.objects.filter(space=space).delete()
SyncLog.objects.filter(sync__space=space).delete()
Sync.objects.filter(space=space).delete()
Storage.objects.filter(space=space).delete()
ShoppingListEntry.objects.filter(shoppinglist__space=space).delete()
ShoppingListRecipe.objects.filter(shoppinglist__space=space).delete()
ShoppingList.objects.filter(space=space).delete()
SupermarketCategoryRelation.objects.filter(supermarket__space=space).delete()
SupermarketCategory.objects.filter(space=space).delete()
Supermarket.objects.filter(space=space).delete()
InviteLink.objects.filter(space=space).delete()
UserFile.objects.filter(space=space).delete()
Automation.objects.filter(space=space).delete()
class SpaceAdmin(admin.ModelAdmin):
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
search_fields = ('name', 'created_by__username')
list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
date_hierarchy = 'created_at'
actions = [delete_space_action]
admin.site.register(Space, SpaceAdmin)
@@ -128,7 +169,7 @@ def sort_tree(modeladmin, request, queryset):
class KeywordAdmin(TreeAdmin):
form = movenodeform_factory(Keyword)
ordering = ('space', 'path',)
search_fields = ('name', )
search_fields = ('name',)
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
@@ -171,13 +212,15 @@ class RecipeAdmin(admin.ModelAdmin):
admin.site.register(Recipe, RecipeAdmin)
admin.site.register(Unit)
# admin.site.register(FoodInheritField)
class FoodAdmin(TreeAdmin):
form = movenodeform_factory(Keyword)
ordering = ('space', 'path',)
search_fields = ('name', )
search_fields = ('name',)
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]

View File

@@ -172,6 +172,8 @@ class RecipeSearch():
def keyword_filters(self, keywords=None, operator=True):
if not keywords:
return
if not isinstance(keywords, list):
keywords = [keywords]
if operator == True:
# TODO creating setting to include descendants of keywords a setting
self._queryset = self._queryset.filter(keywords__in=Keyword.include_descendants(Keyword.objects.filter(pk__in=keywords)))
@@ -184,6 +186,8 @@ class RecipeSearch():
def food_filters(self, foods=None, operator=True):
if not foods:
return
if not isinstance(foods, list):
foods = [foods]
if operator == True:
# TODO creating setting to include descendants of food a setting
self._queryset = self._queryset.filter(steps__ingredients__food__in=Food.include_descendants(Food.objects.filter(pk__in=foods)))
@@ -198,7 +202,9 @@ class RecipeSearch():
raise NotImplementedError
if not units:
return
self._queryset = self._queryset.filter(steps__ingredients__unit__id=units)
if not isinstance(units, list):
units = [units]
self._queryset = self._queryset.filter(steps__ingredients__unit__in=units)
def rating_filter(self, rating=None):
if rating is None:
@@ -217,6 +223,8 @@ class RecipeSearch():
def book_filters(self, books=None, operator=True):
if not books:
return
if not isinstance(books, list):
books = [books]
if operator == True:
self._queryset = self._queryset.filter(recipebookentry__book__id__in=books)
else:
@@ -228,6 +236,8 @@ class RecipeSearch():
raise NotImplementedError
if not steps:
return
if not isinstance(steps, list):
steps = [unistepsts]
self._queryset = self._queryset.filter(steps__id__in=steps)
def build_fulltext_filters(self, string=None):
@@ -490,7 +500,7 @@ class RecipeFacet():
'space': self._request.space,
}
elif self.hash_key is not None:
self._recipe_list = self._cache.get('recipe_list', None)
self._recipe_list = self._cache.get('recipe_list', [])
self._search_params = {
'keyword_list': self._cache.get('keyword_list', None),
'food_list': self._cache.get('food_list', None),
@@ -637,7 +647,7 @@ class RecipeFacet():
depth = getattr(keyword, 'depth', 0) + 1
steplen = depth * Keyword.steplen
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('keywords', depth, steplen)), 0)
return queryset.annotate(count=Coalesce(1, 0)
).filter(depth=depth, count__gt=0
).values('id', 'name', 'count', 'numchild').order_by('name')
@@ -645,7 +655,7 @@ class RecipeFacet():
depth = getattr(food, 'depth', 0) + 1
steplen = depth * Food.steplen
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('steps__ingredients__food', depth, steplen)), 0)
return queryset.annotate(count=Coalesce(1, 0)
).filter(depth__lte=depth, count__gt=0
).values('id', 'name', 'count', 'numchild').order_by('name')

View File

@@ -2,7 +2,6 @@ import json
from io import BytesIO, StringIO
from re import match
from zipfile import ZipFile
from django.utils.text import get_valid_filename
from rest_framework.renderers import JSONRenderer
@@ -57,8 +56,7 @@ class Default(Integration):
pass
recipe_zip_obj.close()
export_zip_obj.writestr(get_valid_filename(r.name) + '.zip', recipe_zip_stream.getvalue())
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
export_zip_obj.close()
return [[ 'export.zip', export_zip_stream.getvalue() ]]

View File

@@ -42,7 +42,7 @@ class Integration:
try:
last_kw = Keyword.objects.filter(name__regex=r'^(Import [0-9]+)', space=request.space).latest('created_at')
name = f'Import {int(last_kw.name.replace("Import ", "")) + 1}'
except ObjectDoesNotExist:
except (ObjectDoesNotExist, ValueError):
name = 'Import 1'
parent, created = Keyword.objects.get_or_create(name='Import', space=request.space)
@@ -53,7 +53,7 @@ class Integration:
icon=icon,
space=request.space
)
except IntegrityError: # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
except (IntegrityError, ValueError): # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
self.keyword = parent.add_child(
name=f'{name} {str(uuid.uuid4())[0:8]}',
description=description,
@@ -86,12 +86,10 @@ class Integration:
export_obj.close()
export_file = export_stream.getvalue()
response = HttpResponse(export_file, content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename="'+export_filename+'"'
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
return response
def import_file_name_filter(self, zip_info_object):
"""
Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files
@@ -262,7 +260,6 @@ class Integration:
"""
raise NotImplementedError('Method not implemented in integration')
def get_files_from_recipes(self, recipes, cookie):
"""
Takes a list of recipe object and converts it to a array containing each file.

View File

@@ -29,7 +29,11 @@ class Nextcloud(Provider):
client = Nextcloud.get_client(monitor.storage)
files = client.list(monitor.path)
files.pop(0) # remove first element because its the folder itself
try:
files.pop(0) # remove first element because its the folder itself
except IndexError:
pass # folder is emtpy, no recipes will be imported
import_count = 0
for file in files:

View File

@@ -165,9 +165,10 @@ class FoodInheritFieldSerializer(WritableNestedModelSerializer):
read_only_fields = ['id']
class UserPreferenceSerializer(serializers.ModelSerializer):
class UserPreferenceSerializer(WritableNestedModelSerializer):
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True)
plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
shopping_share = UserNameSerializer(many=True, allow_null=True, required=False)
def create(self, validated_data):
if not validated_data.get('user', None):
@@ -475,7 +476,7 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
# check if root type is recipe to prevent infinite recursion
# can be improved later to allow multi level embedding
if obj.step_recipe and type(self.parent.root) == RecipeSerializer:
return StepRecipeSerializer(obj.step_recipe).data
return StepRecipeSerializer(obj.step_recipe, context={'request': self.context['request']}).data
class Meta:
model = Step
@@ -496,6 +497,11 @@ class StepRecipeSerializer(WritableNestedModelSerializer):
class NutritionInformationSerializer(serializers.ModelSerializer):
carbohydrates = CustomDecimalField()
fats = CustomDecimalField()
proteins = CustomDecimalField()
calories = CustomDecimalField()
def create(self, validated_data):
validated_data['space'] = self.context['request'].space

View File

@@ -19,7 +19,7 @@
<div class="row">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<hr>
<hr>
<form class="login" method="POST" action="{% url 'account_login' %}">
{% csrf_token %}
{{ form | crispy }}
@@ -29,12 +29,16 @@
{% endif %}
<button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
<a class="btn btn-secondary" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% if SIGNUP_ENABLED %}
<a class="btn btn-secondary" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% endif %}
{% if EMAIL_ENABLED %}
<a class="btn btn-warning float-right d-none d-xl-block d-lg-block"
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a>
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
{% endif %}
</form>
</div>
@@ -44,7 +48,7 @@
{% if socialaccount_providers %}
<div class="row" style="margin-top: 2vh">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<h5>{% trans "Social Login" %}</h5>
<span>{% trans 'You can use any of the following providers to sign in.' %}</span>
@@ -62,5 +66,8 @@
</div>
{% endif %}
<script>
$('#id_login').focus()
</script>
{% endblock %}

View File

@@ -71,4 +71,8 @@
</div>
<script>
$('#id_username').focus()
</script>
{% endblock %}

View File

@@ -660,13 +660,14 @@
</div>
<script src="{% url 'javascript-catalog' %}"></script>
<script src="{% url 'javascript-catalog' %}">
</script>
<script type="application/javascript">
let csrftoken = Cookies.get('csrftoken');
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
Vue.component('vue-multiselect', window.VueMultiselect.default)
import { ApiApiFactory } from "@/utils/openapi/api"
let app = new Vue({
components: {
@@ -885,27 +886,27 @@
this.$set(this.$refs.ingredient[index].$data, 'search', this.recipe_data.recipeIngredient[index].ingredient.text)
},
searchKeywords: function (query) {
// this.keywords_loading = true
// this.$http.get("{% url 'dal_keyword' %}" + '?q=' + query).then((response) => {
// this.keywords = response.data.results;
// this.keywords_loading = false
// }).catch((err) => {
// console.log(err)
// this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
// })
let apiFactory = new ApiApiFactory()
this.keywords_loading = true
apiFactory
.listKeywords(query, undefined, undefined, 1, this.options_limit)
.then((response) => {
this.keywords = response.data.results
this.keywords_loading = false
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
this.$http.get("{% url 'dal_keyword' %}" + '?q=' + query).then((response) => {
this.keywords = response.data.results;
this.keywords_loading = false
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
// let apiFactory = new ApiApiFactory()
// this.keywords_loading = true
// apiFactory
// .listKeywords(query, undefined, undefined, 1, this.options_limit)
// .then((response) => {
// this.keywords = response.data.results
// this.keywords_loading = false
// })
// .catch((err) => {
// console.log(err)
// StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
// })
},
searchUnits: function (query) {
let apiFactory = new ApiApiFactory()
@@ -932,46 +933,46 @@
})
},
searchIngredients: function (query) {
// this.ingredients_loading = true
// this.$http.get("{% url 'dal_food' %}" + '?q=' + query).then((response) => {
// this.ingredients = response.data.results
// if (this.recipe_data !== undefined) {
// for (let x of Array.from(this.recipe_data.recipeIngredient)) {
// if (x.ingredient.text !== '') {
// this.ingredients = this.ingredients.filter(item => item.text !== x.ingredient.text)
// this.ingredients.push(x.ingredient)
// }
// }
// }
// this.ingredients_loading = false
// }).catch((err) => {
// console.log(err)
// this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
// })
let apiFactory = new ApiApiFactory()
this.foods_loading = true
apiFactory
.listFoods(query, undefined, undefined, 1, this.options_limit)
.then((response) => {
this.foods = response.data.results
if (this.recipe !== undefined) {
for (let s of this.recipe.steps) {
for (let i of s.ingredients) {
if (i.food !== null && i.food.id === undefined) {
this.foods.push(i.food)
}
}
this.ingredients_loading = true
this.$http.get("{% url 'dal_food' %}" + '?q=' + query).then((response) => {
this.ingredients = response.data.results
if (this.recipe_data !== undefined) {
for (let x of Array.from(this.recipe_data.recipeIngredient)) {
if (x.ingredient.text !== '') {
this.ingredients = this.ingredients.filter(item => item.text !== x.ingredient.text)
this.ingredients.push(x.ingredient)
}
}
}
this.foods_loading = false
})
.catch((err) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
this.ingredients_loading = false
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
// let apiFactory = new ApiApiFactory()
// this.foods_loading = true
// apiFactory
// .listFoods(query, undefined, undefined, 1, this.options_limit)
// .then((response) => {
// this.foods = response.data.results
// if (this.recipe !== undefined) {
// for (let s of this.recipe.steps) {
// for (let i of s.ingredients) {
// if (i.food !== null && i.food.id === undefined) {
// this.foods.push(i.food)
// }
// }
// }
// }
// this.foods_loading = false
// })
// .catch((err) => {
// StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
// })
},
deleteNode: function (node, item, e) {
e.stopPropagation()

View File

@@ -655,7 +655,8 @@ class RecipeViewSet(viewsets.ModelViewSet):
# self.queryset = search_recipes(self.request, self.queryset, self.request.GET)
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x in list(self.request.GET)}
self.queryset = RecipeSearch(self.request, **params).get_queryset(self.queryset).prefetch_related('cooklog_set')
search = RecipeSearch(self.request, **params)
self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set')
return self.queryset
def list(self, request, *args, **kwargs):

View File

@@ -45,21 +45,15 @@ def hook(request, token):
tb.save()
if tb.chat_id == str(data['message']['chat']['id']):
sl = ShoppingList.objects.filter(Q(created_by=tb.created_by)).filter(finished=False, space=tb.space).order_by('-created_at').first()
if not sl:
sl = ShoppingList.objects.create(created_by=tb.created_by, space=tb.space)
request.space = tb.space # TODO this is likely a bad idea. Verify and test
request.user = tb.created_by
ingredient_parser = IngredientParser(request, False)
amount, unit, ingredient, note = ingredient_parser.parse(data['message']['text'])
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
sl.entries.add(
ShoppingListEntry.objects.create(
food=f, unit=u, amount=amount
)
)
ShoppingListEntry.objects.create(food=f, unit=u, amount=amount, created_by=request.user, space=request.space)
return JsonResponse({'data': data['message']['text']})
except Exception:
pass

View File

@@ -138,6 +138,8 @@ In both cases, also make sure to mount `/media/` in your swag container to point
Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup.
For step-by-step instructions to set this up from scratch, see [this example](swag.md).
### Others
If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [PLAIN](https://docs.tandoor.dev/install/docker/#plain) setup above and change the outbound port to one of your liking.

118
docs/install/swag.md Normal file
View File

@@ -0,0 +1,118 @@
!!! danger
Please refer to the [official documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup. This example shows just one setup that may or may not differ from yours in significant ways. This tutorial does not cover security measures, backups, and many other things that you might want to consider.
## Prerequisites
- You have a newly spun-up Ubuntu server with docker (pre-)installed.
- At least one `mydomain.com` and one `mysubdomain.mydomain.com` are pointing to the server's IP. (This tutorial does not cover subfolder installation.)
- You have an ssh terminal session open.
## Installation
### Download and edit Tandoor configuration
```
cd /opt
mkdir recipes
cd recipes
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env
base64 /dev/urandom | head -c50
```
Copy the response from that last command and paste the key into the `.env` file:
```
nano .env
```
You'll also need to enter a Postgres password into the `.env` file. Then, save the file and exit the editor.
### Install and configure Docker Compose
In keeping with [these instructions](https://docs.linuxserver.io/general/docker-compose):
```
cd /opt
curl -L --fail https://raw.githubusercontent.com/linuxserver/docker-docker-compose/master/run.sh -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
```
Next, create and edit the docker compose file.
```
nano docker-compose.yml
```
Paste the following and adjust your domains, subdomains and time zone.
```
---
version: "2.1"
services:
swag:
image: ghcr.io/linuxserver/swag
container_name: swag
cap_add:
- NET_ADMIN
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Berlin # <---- EDIT THIS <---- <----
- URL=mydomain.com # <---- EDIT THIS <---- <----
- SUBDOMAINS=mysubdomain,myothersubdomain # <---- EDIT THIS <---- <----
- EXTRA_DOMAINS=myotherdomain.com # <---- EDIT THIS <---- <----
- VALIDATION=http
volumes:
- ./swag:/config
- ./recipes/media:/media
ports:
- 443:443
- 80:80
restart: unless-stopped
db_recipes:
restart: always
container_name: db_recipes
image: postgres:11-alpine
volumes:
- ./recipes/db:/var/lib/postgresql/data
env_file:
- ./recipes/.env
recipes:
image: vabene1111/recipes
container_name: recipes
restart: unless-stopped
env_file:
- ./recipes/.env
environment:
- UID=1000
- GID=1000
- TZ=Europe/Berlin # <---- EDIT THIS <---- <----
volumes:
- ./recipes/static:/opt/recipes/staticfiles
- ./recipes/media:/opt/recipes/mediafiles
depends_on:
- db_recipes
```
Save and exit.
### Create containers and configure swag reverse proxy
```
docker-compose up -d
```
```
cd /opt/swag/nginx/proxy-confs
cp recipes.subdomain.conf.sample recipes.subdomain.conf
nano recipes.subdomain.conf
```
Change the line `server_name recipes.*;` to `server_name mysubdomain.*;`, save and exit.
### Finalize
```
cd /opt
docker restart swag recipes
```
Go to `https://mysubdomain.mydomain.com`. (If you get a "502 Bad Gateway" error, be patient. It might take a short while until it's functional.)

View File

@@ -586,6 +586,8 @@ export default {
if (this.recipe.working_time === "" || isNaN(this.recipe.working_time)) {
this.recipe.working_time = 0
}
this.recipe.servings = Math.floor(this.recipe.servings) // temporary fix until a proper framework for frontend input validation is established
if (this.recipe.servings === "" || isNaN(this.recipe.servings)) {
this.recipe.servings = 0
}

View File

@@ -1,6 +1,6 @@
<template>
<div id="app" style="margin-bottom: 4vh">
<RecipeSwitcher mode="mealplan" />
<RecipeSwitcher ref="ref_recipe_switcher"/>
<div class="row">
<div class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1">
<div class="row">

View File

@@ -5,7 +5,7 @@
</template>
<div v-if="!loading">
<RecipeSwitcher :recipe="rootrecipe.id" :name="rootrecipe.name" mode="recipe" @switch="quickSwitch($event)" />
<RecipeSwitcher ref="ref_recipe_switcher" @switch="quickSwitch($event)" />
<div class="row">
<div class="col-12" style="text-align: center">
<h3>{{ recipe.name }}</h3>

View File

@@ -1,45 +1,75 @@
<template>
<div v-if="recipes !== {}">
<div id="switcher" class="align-center">
<i class="btn btn-outline-dark fas fa-receipt fa-xl fa-fw shadow-none btn-circle"
<i class="btn btn-primary fas fa-receipt fa-xl fa-fw shadow-none btn-circle"
v-b-toggle.related-recipes/>
</div>
<b-sidebar id="related-recipes" title="Quick actions" backdrop right shadow="sm" style="z-index: 10000">
<b-sidebar id="related-recipes" backdrop right bottom no-header shadow="sm" style="z-index: 10000"
@shown="updatePinnedRecipes()">
<template #default="{ hide }">
<nav class="mb-3 ml-3">
<b-nav vertical>
<h5><i class="fas fa-calendar fa-fw"></i> Planned</h5>
<div class="d-flex flex-column justify-content-end h-100 p-3 align-items-end">
<div v-for="r in planned_recipes" :key="`plan${r.id}`">
<b-nav-item variant="link" @click="
navRecipe(r)
hide()
">{{ r.name }}
</b-nav-item>
</div>
<hr/>
<h5><i class="fas fa-thumbtack fa-fw"></i> Pinned</h5>
<h5>Planned <i class="fas fa-calendar fa-fw"></i></h5>
<div v-for="r in pinned_recipes" :key="`pin${r.id}`">
<b-nav-item variant="link" @click="
navRecipe(r)
hide()
">{{ r.name }}
</b-nav-item>
</div>
<hr/>
<h5><i class="fas fa-link fa-fw"></i> Related</h5>
<div class="text-right">
<template v-if="planned_recipes.length > 0">
<div v-for="r in planned_recipes" :key="`plan${r.id}`">
<div class="pb-1 pt-1">
<a @click=" navRecipe(r); hide()" href="javascript:void(0);">{{ r.name }}</a>
</div>
</div>
</template>
<template v-else>
<span class="text-muted">You have nothing planned for today!</span>
</template>
</div>
<div v-for="r in related_recipes" :key="`related${r.id}`">
<b-nav-item variant="link" @click="
navRecipe(r)
hide()
">{{ r.name }}
</b-nav-item>
<h5>Pinned <i class="fas fa-thumbtack fa-fw"></i></h5>
<template v-if="pinned_recipes.length > 0">
<div class="text-right">
<div v-for="r in pinned_recipes" :key="`pin${r.id}`">
<b-row class="pb-1 pt-1">
<b-col cols="2">
<a href="javascript:void(0)" @click="unPinRecipe(r)"
class="text-muted"><i class="fas fa-times"></i></a>
</b-col>
<b-col cols="10">
<a @click="navRecipe(r); hide()" href="javascript:void(0);"
class="align-self-end">{{ r.name }} </a>
</b-col>
</b-row>
</div>
</div>
</b-nav>
</nav>
</template>
<template v-else>
<span class="text-muted">You have no pinned recipes!</span>
</template>
<template v-if="related_recipes.length > 0">
<h5>Related <i class="fas fa-link fa-fw"></i></h5>
<div class="text-right">
<div v-for="r in related_recipes" :key="`related${r.id}`">
<div class="pb-1 pt-1">
<a @click=" navRecipe(r); hide()" href="javascript:void(0);">{{ r.name }}</a>
</div>
</div>
</div>
</template>
</div>
</template>
<template #footer="{ hide }">
<div class="d-flex bg-dark text-light align-items-center px-3 py-2">
<strong class="mr-auto">Quick actions</strong>
<b-button size="sm" @click="hide">Close</b-button>
</div>
</template>
</b-sidebar>
</div>
@@ -60,7 +90,7 @@ export default {
related_recipes: [],
planned_recipes: [],
pinned_recipes: [],
recipes: {}
recipes: {},
}
},
computed: {
@@ -89,14 +119,22 @@ export default {
window.location.href = this.resolveDjangoUrl("view_recipe", recipe.id)
}
},
updatePinnedRecipes: function () {
//TODO clean this up to prevent duplicate API calls
this.loadPinnedRecipes()
this.loadRecipeData()
},
loadRecipeData: function () {
let apiClient = new ApiApiFactory()
let recipe_list = [...this.related_recipes, ...this.planned_recipes, ...this.pinned_recipes]
let recipe_ids = []
recipe_list.forEach((recipe) => {
if (!recipe_ids.includes(recipe.id)) {
recipe_ids.push(recipe.id)
let id = recipe.id
if (!recipe_ids.includes(id)) {
recipe_ids.push(id)
}
})
@@ -111,12 +149,15 @@ export default {
let apiClient = new ApiApiFactory()
// get related recipes and save them for later
return apiClient.relatedRecipe(this.recipe, {query: {levels: 2}}).then((result) => {
this.related_recipes = result.data
})
if (this.$parent.recipe) {
this.related_recipes = [this.$parent.recipe]
return apiClient.relatedRecipe(this.$parent.recipe.id, {query: {levels: 2, format: 'json'}}).then((result) => {
this.related_recipes = this.related_recipes.concat(result.data)
})
}
},
loadPinnedRecipes: function () {
let pinned_recipe_ids = localStorage.getItem('pinned_recipes') || []
let pinned_recipe_ids = JSON.parse(localStorage.getItem('pinned_recipes')) || []
this.pinned_recipes = pinned_recipe_ids
},
loadMealPlans: function () {
@@ -142,6 +183,13 @@ export default {
return Promise.all(promises)
})
},
unPinRecipe: function (recipe) {
let pinnedRecipes = JSON.parse(localStorage.getItem('pinned_recipes')) || []
pinnedRecipes = pinnedRecipes.filter((r) => r.id !== recipe.id)
console.log('pinned left', pinnedRecipes)
this.pinned_recipes = pinnedRecipes
localStorage.setItem('pinned_recipes', JSON.stringify(pinnedRecipes))
}
},
}
</script>

View File

@@ -8,12 +8,12 @@
:class="{ 'border border-primary': over, shake: isError }"
:style="{ 'cursor:grab': useDrag }"
:draggable="useDrag"
@[useDrag&&`dragover`].prevent
@[useDrag&&`dragenter`].prevent
@[useDrag&&`dragstart`]="handleDragStart($event)"
@[useDrag&&`dragenter`]="handleDragEnter($event)"
@[useDrag&&`dragleave`]="handleDragLeave($event)"
@[useDrag&&`drop`]="handleDragDrop($event)"
@[useDrag&&`dragover`||``].prevent
@[useDrag&&`dragenter`||``].prevent
@[useDrag&&`dragstart`||``]="handleDragStart($event)"
@[useDrag&&`dragenter`||``]="handleDragEnter($event)"
@[useDrag&&`dragleave`||``]="handleDragLeave($event)"
@[useDrag&&`drop`||``]="handleDragDrop($event)"
>
<b-row no-gutters>
<b-col no-gutters class="col-sm-3">
@@ -27,6 +27,7 @@
<div class="m-0 text-truncate small text-muted" v-if="getFullname">{{ getFullname }}</div>
<generic-pill v-for="x in itemTags" :key="x.field" :item_list="itemList(x)" :label="x.label" :color="x.color" />
<generic-ordered-pill
v-for="x in itemOrderedTags"
:key="x.field"
@@ -37,6 +38,7 @@
:item="item"
@finish-action="finishAction"
/>
<div class="mt-auto mb-1" align="right">
<span v-if="item[child_count]" class="mx-2 btn btn-link btn-sm" style="z-index: 800" v-on:click="$emit('item-action', { action: 'get-children', source: item })">
<div v-if="!item.show_children">{{ item[child_count] }} {{ itemName }}</div>

View File

@@ -1,173 +1,172 @@
<template>
<div>
<b-form-group :class="class_list">
<template #label v-if="show_label">
{{ form.label }}
</template>
<generic-multiselect
@change="new_value = $event.val"
@remove="new_value = undefined"
:initial_selection="initialSelection"
:model="model"
:multiple="useMultiple"
:sticky_options="sticky_options"
:allow_create="form.allow_create"
:create_placeholder="createPlaceholder"
:clear="clear"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="modelName"
@new="addNew"
>
</generic-multiselect>
</b-form-group>
</div>
<div>
<b-form-group class="mb-3">
<template #label v-if="show_label">
{{ form.label }}
</template>
<generic-multiselect
@change="new_value = $event.val"
@remove="new_value = undefined"
:initial_selection="initialSelection"
:model="model"
:multiple="useMultiple"
:sticky_options="sticky_options"
:allow_create="form.allow_create"
:create_placeholder="createPlaceholder"
:clear="clear"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="modelName"
@new="addNew"
>
</generic-multiselect>
</b-form-group>
</div>
</template>
<script>
import GenericMultiselect from "@/components/GenericMultiselect"
import {StandardToasts, ApiMixin} from "@/utils/utils"
import { StandardToasts, ApiMixin } from "@/utils/utils"
export default {
name: "LookupInput",
components: {GenericMultiselect},
mixins: [ApiMixin],
props: {
form: {
type: Object,
default() {
return undefined
},
name: "LookupInput",
components: { GenericMultiselect },
mixins: [ApiMixin],
props: {
form: {
type: Object,
default() {
return undefined
},
},
model: {
type: Object,
default() {
return undefined
},
},
show_label: { type: Boolean, default: true },
clear: { type: Number },
},
model: {
type: Object,
default() {
return undefined
},
},
class_list: {type: String, default: "mb-3"},
show_label: {type: Boolean, default: true},
clear: {type: Number},
},
data() {
return {
new_value: undefined,
field: undefined,
label: undefined,
sticky_options: undefined,
first_run: true,
}
},
mounted() {
this.new_value = this.form?.value
this.field = this.form?.field ?? "You Forgot To Set Field Name"
this.label = this.form?.label ?? ""
this.sticky_options = this.form?.sticky_options ?? []
},
computed: {
modelName() {
return this?.model?.name ?? this.$t("Search")
},
useMultiple() {
return this.form?.multiple || this.form?.ordered || false
},
initialSelection() {
let this_value = this.new_value
let arrayValues = undefined
// multiselect is expect to get an array of objects - make sure it gets one
if (Array.isArray(this_value)) {
arrayValues = this_value
} else if (!this_value) {
arrayValues = []
} else if (typeof this_value === "object") {
arrayValues = [this_value]
} else {
arrayValues = [{id: -1, name: this_value}]
}
if (this.form?.ordered && this.first_run && arrayValues.length > 0) {
return this.flattenItems(arrayValues)
} else {
return arrayValues
}
},
createPlaceholder() {
return this.$t("Create_New_" + this?.model?.name)
},
},
watch: {
"form.value": function (newVal, oldVal) {
this.new_value = newVal
},
new_value: function () {
let x = this?.new_value
// pass the unflattened attributes that can be restored when ready to save/update
if (this.form?.ordered) {
x["__override__"] = this.unflattenItem(this?.new_value)
}
this.$root.$emit("change", this.form.field, x)
this.$emit("change", x)
},
},
methods: {
addNew: function (e) {
// if create a new item requires more than 1 parameter or the field 'name' is insufficient this will need reworked
// in a perfect world this would trigger a new modal and allow editing all fields
this.genericAPI(this.model, this.Actions.CREATE, {name: e})
.then((result) => {
this.new_value = result.data
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
},
// ordered lookups have nested attributes that need flattened attributes to drive lookup
flattenItems: function (itemlist) {
let flat_items = []
let item = undefined
let label = this.form.list_label.split("::")
itemlist.forEach((x) => {
item = {}
for (const [k, v] of Object.entries(x)) {
if (k == label[0]) {
item["id"] = v.id
item[label[1]] = v[label[1]]
} else {
item[this.form.field + "__" + k] = v
}
data() {
return {
new_value: undefined,
field: undefined,
label: undefined,
sticky_options: undefined,
first_run: true,
}
flat_items.push(item)
})
this.first_run = false
return flat_items
},
unflattenItem: function (itemList) {
let unflat_items = []
let item = undefined
let this_label = undefined
let label = this.form.list_label.split("::")
let order = 0
itemList.forEach((x) => {
item = {}
item[label[0]] = {}
for (const [k, v] of Object.entries(x)) {
switch (k) {
case "id":
item[label[0]]["id"] = v
break
case label[1]:
item[label[0]][label[1]] = v
break
default:
this_label = k.replace(this.form.field + "__", "")
}
}
item["order"] = order
order++
unflat_items.push(item)
})
return unflat_items
mounted() {
this.new_value = this.form?.value
this.field = this.form?.field ?? "You Forgot To Set Field Name"
this.label = this.form?.label ?? ""
this.sticky_options = this.form?.sticky_options ?? []
},
computed: {
modelName() {
return this?.model?.name ?? this.$t("Search")
},
useMultiple() {
return this.form?.multiple || this.form?.ordered || false
},
initialSelection() {
let this_value = this.new_value
let arrayValues = undefined
// multiselect is expect to get an array of objects - make sure it gets one
if (Array.isArray(this_value)) {
arrayValues = this_value
} else if (!this_value) {
arrayValues = []
} else if (typeof this_value === "object") {
arrayValues = [this_value]
} else {
arrayValues = [{ id: -1, name: this_value }]
}
if (this.form?.ordered && this.first_run) {
return this.flattenItems(arrayValues)
} else {
return arrayValues
}
},
createPlaceholder() {
return this.$t("Create_New_" + this?.model?.name)
},
},
watch: {
"form.value": function (newVal, oldVal) {
this.new_value = newVal
},
new_value: function () {
let x = this?.new_value
// pass the unflattened attributes that can be restored when ready to save/update
if (this.form?.ordered) {
x["__override__"] = this.unflattenItem(this?.new_value)
}
this.$root.$emit("change", this.form.field, x)
this.$emit("change", x)
},
},
methods: {
addNew: function (e) {
// if create a new item requires more than 1 parameter or the field 'name' is insufficient this will need reworked
// in a perfect world this would trigger a new modal and allow editing all fields
this.genericAPI(this.model, this.Actions.CREATE, { name: e })
.then((result) => {
this.new_value = result.data
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
},
// ordered lookups have nested attributes that need flattened attributes to drive lookup
flattenItems: function (itemlist) {
let flat_items = []
let item = undefined
let label = this.form.list_label.split("::")
itemlist.forEach((x) => {
item = {}
for (const [k, v] of Object.entries(x)) {
if (k == label[0]) {
item["id"] = v.id
item[label[1]] = v[label[1]]
} else {
item[this.form.field + "__" + k] = v
}
}
flat_items.push(item)
})
this.first_run = false
return flat_items
},
unflattenItem: function (itemList) {
let unflat_items = []
let item = undefined
let this_label = undefined
let label = this.form.list_label.split("::")
let order = 0
itemList.forEach((x) => {
item = {}
item[label[0]] = {}
for (const [k, v] of Object.entries(x)) {
switch (k) {
case "id":
item[label[0]]["id"] = v
break
case label[1]:
item[label[0]][label[1]] = v
break
default:
this_label = k.replace(this.form.field + "__", "")
}
}
item["order"] = order
order++
unflat_items.push(item)
})
return unflat_items
},
},
},
}
</script>

View File

@@ -1,38 +1,60 @@
<template>
<div>
<div class="dropdown d-print-none">
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-ellipsis-v fa-lg"></i>
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i
class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)" v-if="!recipe.internal"><i class="fas fa-exchange-alt fa-fw"></i> {{ $t("convert_internal") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)"
v-if="!recipe.internal"><i class="fas fa-exchange-alt fa-fw"></i> {{ $t("convert_internal") }}</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)"><i class="fas fa-bookmark fa-fw"></i> {{ $t("Manage_Books") }}</button>
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)"><i
class="fas fa-bookmark fa-fw"></i> {{ $t("Manage_Books") }}
</button>
</a>
<a class="dropdown-item" :href="`${resolveDjangoUrl('view_shopping')}?r=[${recipe.id},${servings_value}]`" v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
<a class="dropdown-item"
:href="`${resolveDjangoUrl('view_shopping')}?r=[${recipe.id},${servings_value}]`"
v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t("Add_to_Shopping") }}
</a>
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#"> <i class="fas fa-shopping-cart fa-fw"></i> {{ $t("create_shopping_new") }} </a>
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#"> <i
class="fas fa-shopping-cart fa-fw"></i> {{ $t("create_shopping_new") }} </a>
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i class="fas fa-calendar fa-fw"></i> {{ $t("Add_to_Plan") }} </a>
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
class="fas fa-calendar fa-fw"></i> {{ $t("Add_to_Plan") }} </a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i class="fas fa-clipboard-list fa-fw"></i> {{ $t("Log_Cooking") }}</button>
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i
class="fas fa-clipboard-list fa-fw"></i> {{ $t("Log_Cooking") }}
</button>
</a>
<a href="javascript:void(0);">
<button class="dropdown-item" onclick="window.print()"><i class="fas fa-print fa-fw"></i> {{ $t("Print") }}</button>
<button class="dropdown-item" onclick="window.print()"><i class="fas fa-print fa-fw"></i>
{{ $t("Print") }}
</button>
</a>
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank" rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t("Export") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank"
rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t("Export") }}</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i class="fas fa-share-alt fa-fw"></i> {{ $t("Share") }}</button>
<button class="dropdown-item" @click="pinRecipe()"><i class="fas fa-thumbtack fa-fw"></i>
{{ $t("Pin") }}
</button>
</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i
class="fas fa-share-alt fa-fw"></i> {{ $t("Share") }}
</button>
</a>
</div>
</div>
@@ -44,10 +66,17 @@
<div class="row">
<div class="col col-md-12">
<label v-if="recipe_share_link !== undefined">{{ $t("Public share link") }}</label>
<input ref="share_link_ref" class="form-control" v-model="recipe_share_link" />
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary" @click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t("Close") }} </b-button>
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{ $t("Copy") }}</b-button>
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{ $t("Share") }} <i class="fa fa-share-alt"></i></b-button>
<input ref="share_link_ref" class="form-control" v-model="recipe_share_link"/>
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary"
@click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t("Close") }}
</b-button>
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{
$t("Copy")
}}
</b-button>
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{
$t("Share")
}} <i class="fa fa-share-alt"></i></b-button>
</div>
</div>
</b-modal>
@@ -62,12 +91,12 @@
:allow_delete="false"
:modal_title="$t('Create_Meal_Plan_Entry')"
></meal-plan-edit-modal>
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id" />
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id"/>
</div>
</template>
<script>
import { makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts } from "@/utils/utils"
import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils"
import CookLog from "@/components/CookLog"
import axios from "axios"
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
@@ -75,7 +104,7 @@ import MealPlanEditModal from "@/components/MealPlanEditModal"
import ShoppingModal from "@/components/Modals/ShoppingModal"
import moment from "moment"
import Vue from "vue"
import { ApiApiFactory } from "@/utils/openapi/api"
import {ApiApiFactory} from "@/utils/openapi/api"
Vue.prototype.moment = moment
@@ -121,6 +150,11 @@ export default {
this.servings_value = this.servings === -1 ? this.recipe.servings : this.servings
},
methods: {
pinRecipe: function () {
let pinnedRecipes = JSON.parse(localStorage.getItem('pinned_recipes')) || []
pinnedRecipes.push({id: this.recipe.id, name: this.recipe.name})
localStorage.setItem('pinned_recipes', JSON.stringify(pinnedRecipes))
},
saveMealPlan: function (entry) {
entry.date = moment(entry.date).format("YYYY-MM-DD")

View File

@@ -277,14 +277,11 @@
"copy_markdown_table": "Copy as Markdown Table",
"in_shopping": "In Shopping List",
"DelayUntil": "Delay Until",
"Pin": "Pin",
"mark_complete": "Mark Complete",
"QuickEntry": "Quick Entry",
"shopping_add_onhand_desc": "Mark food 'On Hand' when checked off shopping list.",
"shopping_add_onhand": "Auto On Hand",
"related_recipes": "Related Recipes",
"today_recipes": "Today's Recipes",
"mark_complete": "Mark Complete",
"QuickEntry": "Quick Entry",
"shopping_add_onhand_desc": "Mark food 'On Hand' when checked off shopping list.",
"shopping_add_onhand": "Auto On Hand"
"today_recipes": "Today's Recipes"
}