This commit is contained in:
Chris Scoggins 2022-01-24 18:06:54 -06:00
parent 5959914932
commit fcb8e520b7
No known key found for this signature in database
GPG Key ID: 41617A4206CCBAC6
11 changed files with 229 additions and 152 deletions

View File

@ -1,3 +1,4 @@
import json
from collections import Counter from collections import Counter
from datetime import timedelta from datetime import timedelta
@ -12,7 +13,7 @@ from cookbook.filters import RecipeFilter
from cookbook.helper.HelperFunctions import Round, str2bool from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.helper.permission_helper import has_group_permission from cookbook.helper.permission_helper import has_group_permission
from cookbook.managers import DICTIONARY from cookbook.managers import DICTIONARY
from cookbook.models import CookLog, Food, Keyword, Recipe, SearchPreference, ViewLog from cookbook.models import CookLog, CustomFilter, Food, Keyword, Recipe, SearchPreference, ViewLog
from recipes import settings from recipes import settings
@ -24,7 +25,14 @@ class RecipeSearch():
def __init__(self, request, **params): def __init__(self, request, **params):
self._request = request self._request = request
self._queryset = None self._queryset = None
self._params = {**params} if filter := params.get('filter', None):
try:
self._params = {**json.loads(CustomFilter.objects.get(id=filter).search)}
except CustomFilter.DoesNotExist:
self._params = {**(params or {})}
else:
self._params = {**(params or {})}
self._query = self._params.get('query', {}) or {}
if self._request.user.is_authenticated: if self._request.user.is_authenticated:
self._search_prefs = request.user.searchpreference self._search_prefs = request.user.searchpreference
else: else:
@ -53,12 +61,11 @@ class RecipeSearch():
self._units = self._params.get('units', None) self._units = self._params.get('units', None)
# TODO add created by # TODO add created by
# TODO image exists # TODO image exists
self._sort_order = self._params.get('sort_order', None) self._sort_order = self._params.get('sort_order', None) or self._query.get('sort_order', 0)
self._books_or = str2bool(self._params.get('books_or', True))
self._internal = str2bool(self._params.get('internal', False)) self._internal = str2bool(self._params.get('internal', False))
self._random = str2bool(self._params.get('random', False)) self._random = str2bool(self._params.get('random', False))
self._new = str2bool(self._params.get('new', False)) self._new = str2bool(self._params.get('new', False))
self._last_viewed = int(self._params.get('last_viewed', 0)) self._last_viewed = int(self._params.get('last_viewed', 0) or self._query.get('last_viewed', 0))
self._timescooked = self._params.get('timescooked', None) self._timescooked = self._params.get('timescooked', None)
self._lastcooked = self._params.get('lastcooked', None) self._lastcooked = self._params.get('lastcooked', None)
self._makenow = self._params.get('makenow', None) self._makenow = self._params.get('makenow', None)

View File

@ -33,4 +33,9 @@ class Migration(migrations.Migration):
model_name='customfilter', model_name='customfilter',
constraint=models.UniqueConstraint(fields=('space', 'name'), name='cf_unique_name_per_space'), constraint=models.UniqueConstraint(fields=('space', 'name'), name='cf_unique_name_per_space'),
), ),
migrations.AddField(
model_name='recipebook',
name='filter',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.customfilter'),
),
] ]

View File

@ -725,6 +725,7 @@ class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionMod
icon = models.CharField(max_length=16, blank=True, null=True) icon = models.CharField(max_length=16, blank=True, null=True)
shared = models.ManyToManyField(User, blank=True, related_name='shared_with') shared = models.ManyToManyField(User, blank=True, related_name='shared_with')
created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_by = models.ForeignKey(User, on_delete=models.CASCADE)
filter = models.ForeignKey('cookbook.CustomFilter', null=True, blank=True, on_delete=models.SET_NULL)
space = models.ForeignKey(Space, on_delete=models.CASCADE) space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space') objects = ScopedManager(space='space')

View File

@ -604,8 +604,22 @@ class CommentSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
class CustomFilterSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
shared = UserNameSerializer(many=True, required=False)
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
return super().create(validated_data)
class Meta:
model = CustomFilter
fields = ('id', 'name', 'search', 'shared', 'created_by')
read_only_fields = ('created_by',)
class RecipeBookSerializer(SpacedModelSerializer, WritableNestedModelSerializer): class RecipeBookSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
shared = UserNameSerializer(many=True) shared = UserNameSerializer(many=True)
filter = CustomFilterSerializer(required=False)
def create(self, validated_data): def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user validated_data['created_by'] = self.context['request'].user
@ -613,8 +627,8 @@ class RecipeBookSerializer(SpacedModelSerializer, WritableNestedModelSerializer)
class Meta: class Meta:
model = RecipeBook model = RecipeBook
fields = ('id', 'name', 'description', 'icon', 'shared', 'created_by') fields = ('id', 'name', 'description', 'icon', 'shared', 'created_by', 'filter')
read_only_fields = ('created_by',) read_only_fields = ('created_by', )
class RecipeBookEntrySerializer(serializers.ModelSerializer): class RecipeBookEntrySerializer(serializers.ModelSerializer):
@ -976,16 +990,3 @@ class FoodShoppingUpdateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Recipe model = Recipe
fields = ['id', 'amount', 'unit', 'delete', ] fields = ['id', 'amount', 'unit', 'delete', ]
class CustomFilterSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
shared = UserNameSerializer(many=True, required=False)
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
return super().create(validated_data)
class Meta:
model = CustomFilter
fields = ('id', 'name', 'search', 'shared', 'created_by')
read_only_fields = ('created_by',)

View File

@ -526,7 +526,6 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet):
book_id = self.request.query_params.get('book', None) book_id = self.request.query_params.get('book', None)
if book_id is not None: if book_id is not None:
queryset = queryset.filter(book__pk=book_id) queryset = queryset.filter(book__pk=book_id)
return queryset return queryset
@ -669,8 +668,10 @@ class RecipeViewSet(viewsets.ModelViewSet):
if not (share and self.detail): if not (share and self.detail):
self.queryset = self.queryset.filter(space=self.request.space) self.queryset = self.queryset.filter(space=self.request.space)
# self.queryset = search_recipes(self.request, self.queryset, self.request.GET) if filter := (self.request.GET.get('query', {}) or {}).get('filter', None):
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)} params = {'filter': filter}
else:
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)}
search = RecipeSearch(self.request, **params) search = RecipeSearch(self.request, **params)
self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set') self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set')
return self.queryset return self.queryset

View File

@ -6,11 +6,7 @@
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-12 col-lg-10 mt-3 mb-3"> <div class="col-12 col-lg-10 mt-3 mb-3">
<b-input-group> <b-input-group>
<b-input <b-input class="form-control form-control-lg form-control-borderless form-control-search" v-model="search" v-bind:placeholder="$t('Search')"></b-input>
class="form-control form-control-lg form-control-borderless form-control-search"
v-model="search"
v-bind:placeholder="$t('Search')"
></b-input>
<b-input-group-append> <b-input-group-append>
<b-button variant="primary" v-b-tooltip.hover :title="$t('Create')" @click="createNew"> <b-button variant="primary" v-b-tooltip.hover :title="$t('Create')" @click="createNew">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
@ -48,13 +44,7 @@
<loading-spinner v-if="current_book === book.id && loading"></loading-spinner> <loading-spinner v-if="current_book === book.id && loading"></loading-spinner>
<transition name="slide-fade"> <transition name="slide-fade">
<cookbook-slider <cookbook-slider :recipes="recipes" :book="book" :key="`slider_${book.id}`" v-if="current_book === book.id && !loading" v-on:refresh="refreshData"></cookbook-slider>
:recipes="recipes"
:book="book"
:key="`slider_${book.id}`"
v-if="current_book === book.id && !loading"
v-on:refresh="refreshData"
></cookbook-slider>
</transition> </transition>
</div> </div>
</div> </div>

View File

@ -903,6 +903,7 @@ export default {
} }
if (filter) { if (filter) {
// this can be simplified by calling recipe API {query: {filter: filter_id}} but you lose loading all of the params into the UI
filter = JSON.parse(filter.search) filter = JSON.parse(filter.search)
let fields = ["keywords", "foods", "books"] let fields = ["keywords", "foods", "books"]
let operators = ["_or", "_and", "_or_not", "_and_not"] let operators = ["_or", "_and", "_or_not", "_and_not"]

View File

@ -1,130 +1,149 @@
<template> <template>
<b-card no-body v-hover> <b-card no-body v-hover>
<b-card-header class="p-4"> <b-card-header class="p-4">
<h5>{{ book_copy.icon }}&nbsp;{{ book_copy.name }} <h5>
<span class="float-right text-primary" @click="editOrSave"><i {{ book_copy.icon }}&nbsp;{{ book_copy.name }}
class="fa" v-bind:class="{ 'fa-pen': !editing, 'fa-save': editing }" <span class="float-right text-primary" @click="editOrSave"><i class="fa" v-bind:class="{ 'fa-pen': !editing, 'fa-save': editing }" aria-hidden="true"></i></span>
aria-hidden="true"></i></span></h5> </h5>
<b-badge class="font-weight-normal mr-1" v-for="u in book_copy.shared" v-bind:key="u.id" variant="primary" pill>{{u.username}}</b-badge> <b-badge class="font-weight-normal mr-1" v-for="u in book_copy.shared" v-bind:key="u.id" variant="primary" pill>{{ u.username }}</b-badge>
</b-card-header> </b-card-header>
<b-card-body class="p-4"> <b-card-body class="p-4">
<div class="form-group" v-if="editing"> <div class="form-group" v-if="editing">
<label for="inputName1">{{ $t('Name') }}</label> <label for="inputName1">{{ $t("Name") }}</label>
<input class="form-control" id="inputName1" <input class="form-control" id="inputName1" placeholder="Name" v-model="book_copy.name" />
placeholder="Name" v-model="book_copy.name"> </div>
</div> <div class="form-group" v-if="editing">
<div class="form-group" v-if="editing"> <emoji-input :field="'icon'" :label="$t('Icon')" :value="book_copy.icon"></emoji-input>
<emoji-input :field="'icon'" :label="$t('Icon')" :value="book_copy.icon"></emoji-input> </div>
</div> <div class="form-group" v-if="editing">
<div class="form-group" v-if="editing"> <label for="inputDesc1">{{ $t("Description") }}</label>
<label for="inputDesc1">{{ $t('Description') }}</label> <textarea class="form-control" id="inputDesc1" rows="3" v-model="book_copy.description"> </textarea>
<textarea class="form-control" id="inputDesc1" rows="3" v-model="book_copy.description"> </div>
<div class="form-group" v-if="editing">
</textarea> <label for="inputDesc1">{{ $t("Share") }}</label>
</div> <generic-multiselect
<div class="form-group" v-if="editing"> @change="book_copy.shared = $event.val"
<label for="inputDesc1">{{ $t('Share') }}</label> parent_variable="book.shared"
<generic-multiselect @change="book_copy.shared = $event.val" parent_variable="book.shared" :initial_selection="book.shared"
:initial_selection="book.shared" :label="'username'" :label="'username'"
:model="Models.USER_NAME" :model="Models.USER_NAME"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0" style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Share')" :limit="50"></generic-multiselect> v-bind:placeholder="$t('Share')"
</div> :limit="50"
<button v-if="editing" class="btn btn-danger" @click="deleteBook">{{ $t('Delete') }}</button> ></generic-multiselect>
<button v-if="editing" class="btn btn-primary float-right" @click="editOrSave">{{ $t('Save') }}</button> </div>
<b-card-text style="text-overflow: ellipsis;" v-if="!editing"> <div class="form-group" v-if="editing">
{{ book_copy.description }} <label for="inputDesc1">{{ $t("recipe_filter") }}</label>
</b-card-text> <generic-multiselect
@change="book_copy.filter = $event.val"
</b-card-body> parent_variable="book.filter"
</b-card> :initial_single_selection="book.filter"
:model="Models.CUSTOM_FILTER"
:multiple="false"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Custom Filter')"
:limit="50"
></generic-multiselect>
<small class="text-muted">{{ $t("book_filter_help") }}</small>
</div>
<button v-if="editing" class="btn btn-danger" @click="deleteBook">{{ $t("Delete") }}</button>
<button v-if="editing" class="btn btn-primary float-right" @click="editOrSave">{{ $t("Save") }}</button>
<b-card-text style="text-overflow: ellipsis" v-if="!editing">
{{ book_copy.description }}
</b-card-text>
</b-card-body>
</b-card>
</template> </template>
<script> <script>
import {ApiApiFactory} from "@/utils/openapi/api"; import { ApiApiFactory } from "@/utils/openapi/api"
import {ApiMixin, StandardToasts} from "@/utils/utils"; import { ApiMixin, StandardToasts } from "@/utils/utils"
import EmojiInput from "./Modals/EmojiInput"; import EmojiInput from "./Modals/EmojiInput"
import GenericMultiselect from "@/components/GenericMultiselect"; import GenericMultiselect from "@/components/GenericMultiselect"
export default { export default {
name: "CookbookEditCard", name: "CookbookEditCard",
components: {EmojiInput, GenericMultiselect}, components: { EmojiInput, GenericMultiselect },
mixins: [ApiMixin], mixins: [ApiMixin],
props: { props: {
book: Object book: Object,
},
data() {
return {
editing: false,
book_copy: {},
users: []
}
},
mounted() {
this.book_copy = this.book
this.$root.$on('change', this.updateEmoji);
},
directives: {
hover: {
inserted: function (el) {
el.addEventListener('mouseenter', () => {
el.classList.add("shadow")
});
el.addEventListener('mouseleave', () => {
el.classList.remove("shadow")
});
}
}
},
methods: {
editOrSave: function () {
if (!this.editing) {
this.editing = true
this.$emit("editing", true)
} else {
this.editing = false
this.saveData()
this.$emit("editing", false)
}
}, },
updateEmoji: function (item, value) { data() {
if (item === 'icon') { return {
this.book_copy.icon = value editing: false,
} book_copy: {},
users: [],
}
}, },
saveData: function () { mounted() {
let apiClient = new ApiApiFactory() this.book_copy = this.book
this.$root.$on("change", this.updateEmoji)
},
directives: {
hover: {
inserted: function (el) {
el.addEventListener("mouseenter", () => {
el.classList.add("shadow")
})
el.addEventListener("mouseleave", () => {
el.classList.remove("shadow")
})
},
},
},
methods: {
editOrSave: function () {
if (!this.editing) {
this.editing = true
this.$emit("editing", true)
} else {
this.editing = false
this.saveData()
this.$emit("editing", false)
}
},
updateEmoji: function (item, value) {
if (item === "icon") {
this.book_copy.icon = value
}
},
saveData: function () {
let apiClient = new ApiApiFactory()
apiClient.updateRecipeBook(this.book_copy.id, this.book_copy).then(result => { apiClient
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE) .updateRecipeBook(this.book_copy.id, this.book_copy)
}).catch(error => { .then((result) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE) console.log(result)
}) StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
}, })
refreshData: function () { .catch((error) => {
let apiClient = new ApiApiFactory() StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
},
refreshData: function () {
let apiClient = new ApiApiFactory()
apiClient.listUsers().then(result => { apiClient.listUsers().then((result) => {
this.users = result.data this.users = result.data
}) })
}, },
deleteBook: function () { deleteBook: function () {
if (confirm(this.$t('delete_confirmation', {source: this.book.name}))) { if (confirm(this.$t("delete_confirmation", { source: this.book.name }))) {
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
apiClient.destroyRecipeBook(this.book.id).then(result => { apiClient
this.$emit('refresh') .destroyRecipeBook(this.book.id)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE) .then((result) => {
}).catch(error => { this.$emit("refresh")
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE) StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
}) })
} .catch((error) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
})
}
},
}, },
}
} }
</script> </script>
<style scoped></style>
<style scoped>
</style>

View File

@ -325,5 +325,7 @@
"date_created": "Date Created", "date_created": "Date Created",
"show_sortby": "Show Sort By", "show_sortby": "Show Sort By",
"search_rank": "Search Rank", "search_rank": "Search Rank",
"make_now": "Make Now" "make_now": "Make Now",
"recipe_filter": "Recipe Filter",
"book_filter_help": "Include recipes from recipe filter instead of assigning each recipe"
} }

View File

@ -249,7 +249,7 @@ export class Models {
name: "Recipe_Book", name: "Recipe_Book",
apiName: "RecipeBook", apiName: "RecipeBook",
create: { create: {
params: [["name", "description", "icon"]], params: [["name", "description", "icon", "filter"]],
form: { form: {
name: { name: {
form_field: true, form_field: true,
@ -271,6 +271,13 @@ export class Models {
field: "icon", field: "icon",
label: i18n.t("Icon"), label: i18n.t("Icon"),
}, },
filter: {
form_field: true,
type: "lookup",
field: "filter",
label: i18n.t("Custom Filter"),
list: "CUSTOM_FILTER",
},
}, },
}, },
} }

View File

@ -1555,6 +1555,12 @@ export interface RecipeBook {
* @memberof RecipeBook * @memberof RecipeBook
*/ */
created_by?: string; created_by?: string;
/**
*
* @type {RecipeBookFilter}
* @memberof RecipeBook
*/
filter?: RecipeBookFilter;
} }
/** /**
* *
@ -1593,6 +1599,43 @@ export interface RecipeBookEntry {
*/ */
recipe_content?: string; recipe_content?: string;
} }
/**
*
* @export
* @interface RecipeBookFilter
*/
export interface RecipeBookFilter {
/**
*
* @type {number}
* @memberof RecipeBookFilter
*/
id?: number;
/**
*
* @type {string}
* @memberof RecipeBookFilter
*/
name: string;
/**
*
* @type {string}
* @memberof RecipeBookFilter
*/
search: string;
/**
*
* @type {Array<CustomFilterShared>}
* @memberof RecipeBookFilter
*/
shared?: Array<CustomFilterShared>;
/**
*
* @type {string}
* @memberof RecipeBookFilter
*/
created_by?: string;
}
/** /**
* *
* @export * @export