shopping_list_category

This commit is contained in:
smilerz 2021-09-13 14:30:03 -05:00
parent 22bde0424c
commit 85aca8acf6
12 changed files with 407 additions and 55 deletions

View File

@ -276,8 +276,9 @@ class UnitSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerializer): class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
def create(self, validated_data): def create(self, validated_data):
obj, created = SupermarketCategory.objects.get_or_create(name=validated_data['name'], validated_data['name'] = validated_data['name'].strip()
space=self.context['request'].space) validated_data['space'] = self.context['request'].space
obj, created = SupermarketCategory.objects.get_or_create(**validated_data)
return obj return obj
def update(self, instance, validated_data): def update(self, instance, validated_data):
@ -285,7 +286,7 @@ class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerial
class Meta: class Meta:
model = SupermarketCategory model = SupermarketCategory
fields = ('id', 'name') fields = ('id', 'name', 'description')
class SupermarketCategoryRelationSerializer(WritableNestedModelSerializer): class SupermarketCategoryRelationSerializer(WritableNestedModelSerializer):
@ -301,7 +302,7 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer):
class Meta: class Meta:
model = Supermarket model = Supermarket
fields = ('id', 'name', 'category_to_supermarket') fields = ('id', 'name', 'description', 'category_to_supermarket')
class RecipeSimpleSerializer(serializers.ModelSerializer): class RecipeSimpleSerializer(serializers.ModelSerializer):

View File

@ -10,7 +10,7 @@ from cookbook.helper import dal
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe, from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList, RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList,
Storage, Sync, SyncLog, Unit, get_model_name) Storage, Supermarket, SupermarketCategory, Sync, SyncLog, Unit, get_model_name)
from .views import api, data, delete, edit, import_export, lists, new, views, telegram from .views import api, data, delete, edit, import_export, lists, new, views, telegram
router = routers.DefaultRouter() router = routers.DefaultRouter()
@ -176,7 +176,7 @@ for m in generic_models:
) )
) )
vue_models = [Food, Keyword, Unit] vue_models = [Food, Keyword, Unit, Supermarket, SupermarketCategory]
for m in vue_models: for m in vue_models:
py_name = get_model_name(m) py_name = get_model_name(m)
url_name = py_name.replace('_', '-') url_name = py_name.replace('_', '-')

View File

@ -359,7 +359,7 @@ class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMi
pagination_class = DefaultPagination pagination_class = DefaultPagination
def get_queryset(self): def get_queryset(self):
self.queryset = self.queryset.filter(supermarket__space=self.request.space) self.queryset = self.queryset.filter(supermarket__space=self.request.space).order_by('order')
return super().get_queryset() return super().get_queryset()

View File

@ -146,7 +146,39 @@ def unit(request):
"title": _("Units"), "title": _("Units"),
"config": { "config": {
'model': "UNIT", # *REQUIRED* name of the model in models.js 'model': "UNIT", # *REQUIRED* name of the model in models.js
'recipe_param': 'units' # *OPTIONAL* name of the listRecipes parameter if filtering on this attribute 'recipe_param': 'units', # *OPTIONAL* name of the listRecipes parameter if filtering on this attribute
}
}
)
@group_required('user')
def supermarket(request):
# recipe-param is the name of the parameters used when filtering recipes by this attribute
# model-name is the models.js name of the model, probably ALL-CAPS
return render(
request,
'generic/model_template.html',
{
"title": _("Supermarkets"),
"config": {
'model': "SUPERMARKET", # *REQUIRED* name of the model in models.js
}
}
)
@group_required('user')
def supermarket_category(request):
# recipe-param is the name of the parameters used when filtering recipes by this attribute
# model-name is the models.js name of the model, probably ALL-CAPS
return render(
request,
'generic/model_template.html',
{
"title": _("Shopping Categories"),
"config": {
'model': "SHOPPING_CATEGORY", # *REQUIRED* name of the model in models.js
} }
} }
) )

View File

@ -1,37 +1,80 @@
<template> <template>
<div id="app" style="margin-bottom: 4vh"> <div id="app" style="margin-bottom: 4vh" v-if="this_model">
<!-- v-if prevents component from loading before this_model has been assigned -->
<generic-modal-form v-if="this_model" <generic-modal-form v-if="this_model"
:model="this_model" :model="this_model"
:action="this_action" :action="this_action"
:item1="this_item" :item1="this_item"
:item2="this_target" :item2="this_target"
:show="show_modal" :show="show_modal"
@finish-action="finishAction"/> @finish-action="finishAction"/>
<generic-split-lists v-if="this_model"
:list_name="this_model.name"
:right_counts="right_counts"
:left_counts="left_counts"
@reset="resetList"
@get-list="getItems"
@item-action="startAction">
<template v-slot:cards-left>
<generic-horizontal-card
v-for="i in items_left" v-bind:key="i.id"
:item=i
:model="this_model"
:draggable="true"
@item-action="startAction($event, 'left')"/>
</template>
<template v-slot:cards-right>
<generic-horizontal-card v-for="i in items_right" v-bind:key="i.id"
:item=i
:model="this_model"
:draggable="true"
@item-action="startAction($event, 'right')"/>
</template>
</generic-split-lists>
<div class="row">
<div class="col-md-2 d-none d-md-block">
</div>
<div class="col-xl-8 col-12">
<div class="container-fluid d-flex flex-column flex-grow-1">
<div class="row">
<div class="col-md-6" style="margin-top: 1vh">
<h3>
<!-- <span><b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button></span> -->
<model-menu/>
<span>{{this.this_model.name}}</span>
<span><b-button variant="link" size="lg" @click="startAction({'action':'new'})"><i class="fas fa-plus-circle"></i></b-button></span>
</h3>
</div>
<div class="col-md-3" />
<div class="col-md-3" style="position: relative; margin-top: 1vh">
<b-form-checkbox v-model="show_split" name="check-button" v-if="paginated"
class="shadow-none"
style="position:relative;top: 50%; transform: translateY(-50%);" switch>
{{ $t('show_split_screen') }}
</b-form-checkbox>
</div>
</div>
<div class="row" >
<div class="col" :class="{'col-md-6' : show_split}">
<!-- model isn't paginated and loads in one API call -->
<div v-if="!paginated">
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id"
:item=i
:model="this_model"
@item-action="startAction($event, 'left')"/>
</div>
<!-- model is paginated and needs managed -->
<generic-infinite-cards v-if="paginated"
:card_counts="left_counts"
:scroll="show_split"
@search="getItems($event, 'left')"
@reset="resetList('left')">
<template v-slot:cards>
<generic-horizontal-card
v-for="i in items_left" v-bind:key="i.id"
:item=i
:model="this_model"
@item-action="startAction($event, 'left')"/>
</template>
</generic-infinite-cards>
</div>
<div class="col col-md-6" v-if="show_split">
<generic-infinite-cards v-if="this_model"
:card_counts="right_counts"
:scroll="show_split"
@search="getItems($event, 'right')"
@reset="resetList('right')">
<template v-slot:cards>
<generic-horizontal-card
v-for="i in items_right" v-bind:key="i.id"
:item=i
:model="this_model"
@item-action="startAction($event, 'right')"/>
</template>
</generic-infinite-cards>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
@ -46,9 +89,10 @@ import 'bootstrap-vue/dist/bootstrap-vue.css'
import {CardMixin, ApiMixin} from "@/utils/utils"; import {CardMixin, ApiMixin} from "@/utils/utils";
import {StandardToasts, ToastMixin} from "@/utils/utils"; import {StandardToasts, ToastMixin} from "@/utils/utils";
import GenericSplitLists from "@/components/GenericSplitLists"; import GenericInfiniteCards from "@/components/GenericInfiniteCards";
import GenericHorizontalCard from "@/components/GenericHorizontalCard"; import GenericHorizontalCard from "@/components/GenericHorizontalCard";
import GenericModalForm from "@/components/Modals/GenericModalForm"; import GenericModalForm from "@/components/Modals/GenericModalForm";
import ModelMenu from "@/components/ModelMenu";
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
@ -57,7 +101,7 @@ export default {
// or i'm capturing it incorrectly // or i'm capturing it incorrectly
name: 'ModelListView', name: 'ModelListView',
mixins: [CardMixin, ApiMixin, ToastMixin], mixins: [CardMixin, ApiMixin, ToastMixin],
components: {GenericHorizontalCard, GenericSplitLists, GenericModalForm}, components: {GenericHorizontalCard, GenericModalForm, GenericInfiniteCards, ModelMenu},
data() { data() {
return { return {
// this.Models and this.Actions inherited from ApiMixin // this.Models and this.Actions inherited from ApiMixin
@ -66,11 +110,14 @@ export default {
right_counts: {'max': 9999, 'current': 0}, right_counts: {'max': 9999, 'current': 0},
left_counts: {'max': 9999, 'current': 0}, left_counts: {'max': 9999, 'current': 0},
this_model: undefined, this_model: undefined,
model_menu: undefined,
this_action: undefined, this_action: undefined,
this_recipe_param: undefined, this_recipe_param: undefined,
this_item: {}, this_item: {},
this_target: {}, this_target: {},
show_modal: false show_modal: false,
show_split: false,
paginated: false,
} }
}, },
mounted() { mounted() {
@ -78,6 +125,13 @@ export default {
let model_config = JSON.parse(document.getElementById('model_config').textContent) let model_config = JSON.parse(document.getElementById('model_config').textContent)
this.this_model = this.Models[model_config?.model] this.this_model = this.Models[model_config?.model]
this.this_recipe_param = model_config?.recipe_param this.this_recipe_param = model_config?.recipe_param
this.paginated = this.this_model?.paginated ?? false
this.$nextTick(() => {
if (!this.paginated) {
this.getItems()
}
})
}, },
methods: { methods: {
// this.genericAPI inherited from ApiMixin // this.genericAPI inherited from ApiMixin
@ -165,13 +219,14 @@ export default {
} }
this.clearState() this.clearState()
}, },
getItems: function (params) { getItems: function (params, col) {
let column = params?.column ?? 'left' let column = col || 'left'
this.genericAPI(this.this_model, this.Actions.LIST, params).then((result) => { this.genericAPI(this.this_model, this.Actions.LIST, params).then((result) => {
if (result.data.results.length) { let results = result.data?.results ?? result.data
this['items_' + column] = this['items_' + column].concat(result.data?.results) if (results?.length) {
this[column + '_counts']['max'] = result.data.count this['items_' + column] = this['items_' + column].concat(results)
this[column + '_counts']['current'] = this['items_' + column].length this[column + '_counts']['max'] = result.data?.count ?? 0
this[column + '_counts']['current'] = this['items_' + column]?.length
} else { } else {
this[column + '_counts']['max'] = 0 this[column + '_counts']['max'] = 0
this[column + '_counts']['current'] = 0 this[column + '_counts']['current'] = 0

View File

@ -155,7 +155,6 @@
<script> <script>
import Vue from 'vue' import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue' import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css' import 'bootstrap-vue/dist/bootstrap-vue.css'
import {apiLoadRecipe} from "@/utils/api"; import {apiLoadRecipe} from "@/utils/api";

View File

@ -0,0 +1,92 @@
<template>
<div id="app" style="margin-bottom: 4vh">
<div class="row flex-shrink-0">
<div class="col col-md">
<!-- search box -->
<b-input-group class="mt-3">
<b-input class="form-control" type="search" v-model="search_left"
v-bind:placeholder="this.text.search"></b-input>
</b-input-group>
</div>
</div>
<div class="row" :class="{'vh-100 mh-100 overflow-auto' : scroll}">
<div class="col col-md">
<slot name="cards"></slot>
<infinite-loading
:identifier='column'
@infinite="infiniteHandler"
spinner="waveDots">
<template v-slot:no-more><span/></template>
<template v-slot:no-results><span>{{$t('No_Results')}}</span></template>
</infinite-loading>
</div>
</div>
</div>
</template>
<script>
import 'bootstrap-vue/dist/bootstrap-vue.css'
import _debounce from 'lodash/debounce'
import InfiniteLoading from 'vue-infinite-loading';
export default {
name: 'GenericInfiniteCards',
components: {InfiniteLoading},
props: {
card_list: {type: Array, default(){return []}},
card_counts: {type: Object},
scroll: {type:Boolean, default: false}
},
data() {
return {
search: '',
page: 0,
state: undefined,
column: +new Date(),
text: {
'new': '',
'search': this.$t('Search')
},
}
},
mounted() {
},
watch: {
search: _debounce(function() {
this.page = 0
this.$emit('reset')
this.column += 1
}, 700),
card_counts: {
deep: true,
handler(newVal, oldVal) {
if (newVal.current > 0) {
this.state.loaded()
}
if (newVal.current >= newVal.max) {
this.state.complete()
}
}
},
},
methods: {
infiniteHandler: function($state, col) {
let params = {
'query': this.search,
'page': this.page + 1
}
this.state = $state
this.$emit('search', params)
this.page+= 1
},
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,44 @@
<template>
<div v-if="itemList">
<span :key="k.id" v-for="k in itemList" class="pl-1">
<b-badge pill :variant="color">{{thisLabel(k)}}</b-badge>
</span>
</div>
</template>
<script>
export default {
name: 'GenericPill',
props: {
item_list: {required: true, type: Array},
label: {type: String, default: 'name'},
color: {type: String, default: 'light'}
},
computed: {
itemList: function() {
if(Array.isArray(this.item_list)) {
return this.item_list
} else if (!this.item_list?.id) {
return false
} else {
return [this.item_list]
}
},
},
mounted() {
},
methods: {
thisLabel: function (item) {
let fields = this.label.split('::')
let value = item
fields.forEach(x => {
value = value[x]
});
return value
}
}
}
</script>

View File

@ -26,8 +26,11 @@
</td> </td>
<td v-if="detailed"> <td v-if="detailed">
<div v-if="ingredient.note"> <div v-if="ingredient.note">
<span v-b-popover.hover="ingredient.note" <span v-b-popover.hover="ingredient.note" v-if="ingredient.note.length > 15"
class="d-print-none"> <i class="far fa-comment"></i> class="d-print-none touchable"> <i class="far fa-comment"></i>
</span>
<span v-else>
{{ ingredient.note }}
</span> </span>
<div class="d-none d-print-block"> <div class="d-none d-print-block">
@ -72,3 +75,13 @@ export default {
} }
} }
</script> </script>
<style scoped>
/* increase size of hover/touchable space without changing spacing */
.touchable {
padding-right: 2em;
padding-left: 2em;
margin-right: -2em;
margin-left: -2em;
}
</style>

View File

@ -0,0 +1,61 @@
<template>
<!-- <b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button> -->
<span>
<b-dropdown variant="link" toggle-class="text-decoration-none text-dark shadow-none" no-caret style="boundary:window">
<template #button-content>
<i class="fas fa-chevron-down">
</template>
<b-dropdown-item :href="resolveDjangoUrl('list_food')">
<i class="fas fa-leaf fa-fw"></i> {{ Models['FOOD'].name }}
</b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_keyword')">
<i class="fas fa-tags fa-fw"></i> {{ Models['KEYWORD'].name }}
</b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_unit')">
<i class="fas fa-balance-scale fa-fw"></i> {{ Models['UNIT'].name }}
</b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket')">
<i class="fas fa-store-alt fa-fw"></i> {{ Models['SUPERMARKET'].name }}
</b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket_category')">
<i class="fas fa-cubes fa-fw"></i> {{ Models['SHOPPING_CATEGORY'].name }}
</b-dropdown-item>
</b-dropdown>
</span>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import {Models} from "@/utils/models";
import {ResolveUrlMixin} from "@/utils/utils";
Vue.use(BootstrapVue)
export default {
name: 'ModelMenu',
mixins: [ResolveUrlMixin],
data() {
return {
Models: Models
}
},
mounted() {
},
methods: {
gotoURL: function(model) {
return
}
}
}
</script>

View File

@ -12,7 +12,7 @@
"all_fields_optional": "All fields are optional and can be left empty.", "all_fields_optional": "All fields are optional and can be left empty.",
"convert_internal": "Convert to internal recipe", "convert_internal": "Convert to internal recipe",
"show_only_internal": "Show only internal recipes", "show_only_internal": "Show only internal recipes",
"show_split_screen": "Show split view", "show_split_screen": "Split View",
"Log_Recipe_Cooking": "Log Recipe Cooking", "Log_Recipe_Cooking": "Log Recipe Cooking",
"External_Recipe_Image": "External Recipe Image", "External_Recipe_Image": "External Recipe Image",

View File

@ -62,9 +62,13 @@ export class Models {
'name': i18n.t('Food'), // *OPTIONAL* : parameters will be built model -> model_type -> default 'name': i18n.t('Food'), // *OPTIONAL* : parameters will be built model -> model_type -> default
'apiName': 'Food', // *REQUIRED* : the name that is used in api.ts for this model 'apiName': 'Food', // *REQUIRED* : the name that is used in api.ts for this model
'model_type': this.TREE, // *OPTIONAL* : model specific params for api, if not present will attempt modeltype_create then default_create 'model_type': this.TREE, // *OPTIONAL* : model specific params for api, if not present will attempt modeltype_create then default_create
'paginated': true,
'move': true,
'merge': true,
'badges': { 'badges': {
'linked_recipe': true 'linked_recipe': true,
}, },
'tags': [{'field': 'supermarket_category', 'label': 'name', 'color': 'info'}],
// REQUIRED: unordered array of fields that can be set during create // REQUIRED: unordered array of fields that can be set during create
'create': { 'create': {
// if not defined partialUpdate will use the same parameters, prepending 'id' // if not defined partialUpdate will use the same parameters, prepending 'id'
@ -113,6 +117,9 @@ export class Models {
'name': i18n.t('Keyword'), // *OPTIONAL: parameters will be built model -> model_type -> default 'name': i18n.t('Keyword'), // *OPTIONAL: parameters will be built model -> model_type -> default
'apiName': 'Keyword', 'apiName': 'Keyword',
'model_type': this.TREE, 'model_type': this.TREE,
'paginated': true,
'move': true,
'merge': true,
'badges': { 'badges': {
'icon': true 'icon': true
}, },
@ -146,6 +153,7 @@ export class Models {
static UNIT = { static UNIT = {
'name': i18n.t('Unit'), 'name': i18n.t('Unit'),
'apiName': 'Unit', 'apiName': 'Unit',
'paginated': true,
'create': { 'create': {
'params': [['name', 'description']], 'params': [['name', 'description']],
'form': { 'form': {
@ -165,7 +173,7 @@ export class Models {
} }
} }
}, },
'move': false 'merge': true
} }
static SHOPPING_LIST = {} static SHOPPING_LIST = {}
static RECIPE_BOOK = { static RECIPE_BOOK = {
@ -220,6 +228,53 @@ export class Models {
} }
}, },
} }
static SHOPPING_CATEGORY_RELATION = {
'name': i18n.t('Shopping_Category'),
'apiName': 'SupermarketCategory',
'create': {
'params': [['category', 'supermarket', 'order']],
'form': {
'name': {
'form_field': true,
'type': 'text',
'field': 'name',
'label': i18n.t('Name'),
'placeholder': ''
},
'description': {
'form_field': true,
'type': 'text',
'field': 'description',
'label': i18n.t('Description'),
'placeholder': ''
}
}
},
}
static SUPERMARKET = {
'name': i18n.t('Supermarket'),
'apiName': 'Supermarket',
'tags': [{'field': 'category_to_supermarket', 'label': 'category::name', 'color': 'info'}],
'create': {
'params': [['name', 'description', 'category_to_supermarket']],
'form': {
'name': {
'form_field': true,
'type': 'text',
'field': 'name',
'label': i18n.t('Name'),
'placeholder': ''
},
'description': {
'form_field': true,
'type': 'text',
'field': 'description',
'label': i18n.t('Description'),
'placeholder': ''
},
}
},
}
static RECIPE = { static RECIPE = {
'name': i18n.t('Recipe'), 'name': i18n.t('Recipe'),