refactored Generic API

This commit is contained in:
smilerz 2021-08-29 13:55:00 -05:00
parent caeb47aee9
commit a1d1cbac5d
15 changed files with 252 additions and 163 deletions

View File

@ -1,4 +1,3 @@
from cookbook.models import SearchFields
from django.db import migrations from django.db import migrations

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -730,7 +730,6 @@
searchKeywords: function (query) { searchKeywords: function (query) {
this.keywords_loading = true this.keywords_loading = true
this.$http.get("{% url 'api:keyword-list' %}" + '?query=' + query + '&limit=10').then((response) => { this.$http.get("{% url 'api:keyword-list' %}" + '?query=' + query + '&limit=10').then((response) => {
console.log(response.data)
this.keywords = response.data.results; this.keywords = response.data.results;
this.keywords_loading = false this.keywords_loading = false
}).catch((err) => { }).catch((err) => {
@ -780,7 +779,7 @@
searchFoods: function (query) { searchFoods: function (query) {
this.foods_loading = true this.foods_loading = true
this.$http.get("{% url 'api:food-list' %}" + '?query=' + query + '&limit=10').then((response) => { this.$http.get("{% url 'api:food-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.foods = response.data this.foods = response.data.results
if (this.recipe !== undefined) { if (this.recipe !== undefined) {
for (let s of this.recipe.steps) { for (let s of this.recipe.steps) {

View File

@ -66,7 +66,6 @@ from cookbook.serializer import (FoodSerializer, IngredientSerializer,
class StandardFilterMixin(ViewSetMixin): class StandardFilterMixin(ViewSetMixin):
def get_queryset(self): def get_queryset(self):
queryset = self.queryset queryset = self.queryset
query = self.request.query_params.get('query', None) query = self.request.query_params.get('query', None)

View File

@ -1,10 +1,10 @@
<template> <template>
<div id="app" style="margin-bottom: 4vh"> <div id="app" style="margin-bottom: 4vh">
<generic-modal-form <generic-modal-form
:model="this_model" :model="this_model.name"
:action="this_action"/> :action="this_action"/>
<generic-split-lists <generic-split-lists
:list_name="this_model" :list_name="this_model.name"
@reset="resetList" @reset="resetList"
@get-list="getFoods" @get-list="getFoods"
@item-action="startAction" @item-action="startAction"
@ -13,7 +13,7 @@
<generic-horizontal-card <generic-horizontal-card
v-for="f in foods" v-bind:key="f.id" v-for="f in foods" v-bind:key="f.id"
:model=f :model=f
:model_name="this_model" :model_name="this_model.name"
:draggable="true" :draggable="true"
:merge="true" :merge="true"
:move="true" :move="true"
@ -28,7 +28,7 @@
<template v-slot:cards-right> <template v-slot:cards-right>
<generic-horizontal-card v-for="f in foods2" v-bind:key="f.id" <generic-horizontal-card v-for="f in foods2" v-bind:key="f.id"
:model=f :model=f
:model_name="this_model" :model_name="this_model.name"
:draggable="true" :draggable="true"
:merge="true" :merge="true"
:move="true" :move="true"
@ -90,7 +90,7 @@
:title="this.$t('Delete_Food')" :title="this.$t('Delete_Food')"
:ok-title="this.$t('Delete')" :ok-title="this.$t('Delete')"
:cancel-title="this.$t('Cancel')" :cancel-title="this.$t('Cancel')"
@ok="deleteThis(this_item.id, this_model)"> @ok="deleteThis(this_item.id)">
{{this.$t("delete_confimation", {'kw': this_item.name})}} {{this.$t("delete_confimation", {'kw': this_item.name})}}
</b-modal> </b-modal>
<!-- move modal --> <!-- move modal -->
@ -141,7 +141,9 @@ import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css' import 'bootstrap-vue/dist/bootstrap-vue.css'
import {ToastMixin, ApiMixin, CardMixin} from "@/utils/utils"; import {ApiMixin, CardMixin, ToastMixin} from "@/utils/utils";
import {Models, Actions} from "@/utils/models";
import {StandardToasts} from "@/utils/utils";
import GenericSplitLists from "@/components/GenericSplitLists"; import GenericSplitLists from "@/components/GenericSplitLists";
import GenericHorizontalCard from "@/components/GenericHorizontalCard"; import GenericHorizontalCard from "@/components/GenericHorizontalCard";
@ -152,11 +154,11 @@ Vue.use(BootstrapVue)
export default { export default {
name: 'FoodListView', name: 'FoodListView',
mixins: [ToastMixin, ApiMixin, CardMixin], mixins: [ApiMixin, CardMixin, ToastMixin],
components: {GenericHorizontalCard, GenericMultiselect, GenericSplitLists, GenericModalForm}, components: {GenericHorizontalCard, GenericMultiselect, GenericSplitLists, GenericModalForm},
data() { data() {
return { return {
this_model: 'Food', this_model: Models.FOOD,
this_action:'', this_action:'',
foods: [], foods: [],
foods2: [], foods2: [],
@ -249,7 +251,7 @@ export default {
getFoods: function(params, callback) { getFoods: function(params, callback) {
let column = params?.column ?? 'left' let column = params?.column ?? 'left'
this.genericAPI(this.this_model, 'list', params).then((result) => { this.genericAPI(this.this_model, Actions.LIST, params).then((result) => {
if (result.data.results.length){ if (result.data.results.length){
if (column ==='left') { if (column ==='left') {
this.foods = this.foods.concat(result.data.results) this.foods = this.foods.concat(result.data.results)
@ -266,18 +268,18 @@ export default {
callback(result.data.count < (column==="left" ? this.foods.length : this.foods2.length)) callback(result.data.count < (column==="left" ? this.foods.length : this.foods2.length))
}).catch((err) => { }).catch((err) => {
console.log(err) console.log(err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger') StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
}) })
}, },
getThis: function(id, callback){ getThis: function(id, callback){
return this.genericAPI(this.this_model, 'retrieve', {'id': id}) return this.genericAPI(this.this_model, Actions.FETCH, {'id': id})
}, },
saveFood: function () { saveFood: function () {
let food = {...this.this_item} let food = {...this.this_item}
food.supermarket_category = this.this_item.supermarket_category?.id ?? null food.supermarket_category = this.this_item.supermarket_category?.id ?? null
food.recipe = this.this_item.recipe?.id ?? null food.recipe = this.this_item.recipe?.id ?? null
if (!food?.id) { // if there is no item id assume it's a new item if (!food?.id) { // if there is no item id assume it's a new item
this.genericAPI(this.this_model, 'create', food).then((result) => { this.genericAPI(this.this_model, Actions.CREATE, food).then((result) => {
// place all new foods at the top of the list - could sort instead // place all new foods at the top of the list - could sort instead
this.foods = [result.data].concat(this.foods) this.foods = [result.data].concat(this.foods)
// this creates a deep copy to make sure that columns stay independent // this creates a deep copy to make sure that columns stay independent
@ -286,20 +288,24 @@ export default {
} else { } else {
this.foods2 = [] this.foods2 = []
} }
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}).catch((err) => { }).catch((err) => {
console.log(err) console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
}) })
} else { } else {
this.genericAPI(this.this_model, 'updatePartial', food).then((result) => { this.genericAPI(this.this_model, Actions.UPDATE, food).then((result) => {
this.refreshObject(this.this_item.id) this.refreshObject(food.id)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
}).catch((err) => { }).catch((err) => {
console.log(err) console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
}) })
} }
this.this_item = {...this.blank_item} this.this_item = {...this.blank_item}
}, },
moveFood: function (source_id, target_id) { moveFood: function (source_id, target_id) {
this.genericAPI(this.this_model, 'move', {'source': source_id, 'target': target_id}).then((result) => { this.genericAPI(this.this_model, Actions.MOVE, {'source': source_id, 'target': target_id}).then((result) => {
if (target_id === 0) { if (target_id === 0) {
let food = this.findCard(source_id, this.foods) || this.findCard(source_id, this.foods2) let food = this.findCard(source_id, this.foods) || this.findCard(source_id, this.foods2)
this.foods = [food].concat(this.destroyCard(source_id, this.foods)) // order matters, destroy old card before adding it back in at root this.foods = [food].concat(this.destroyCard(source_id, this.foods)) // order matters, destroy old card before adding it back in at root
@ -310,6 +316,8 @@ export default {
this.foods2 = this.destroyCard(source_id, this.foods2) this.foods2 = this.destroyCard(source_id, this.foods2)
this.refreshObject(target_id) this.refreshObject(target_id)
} }
// TODO make standard toast
this.makeToast(this.$t('Success'), 'Succesfully moved food', 'success')
}).catch((err) => { }).catch((err) => {
// TODO none of the error checking works because the openapi generated functions don't throw an error? // TODO none of the error checking works because the openapi generated functions don't throw an error?
// or i'm capturing it incorrectly // or i'm capturing it incorrectly
@ -318,7 +326,7 @@ export default {
}) })
}, },
mergeFood: function (source_id, target_id) { mergeFood: function (source_id, target_id) {
this.genericAPI(this.this_model, 'merge', {'source': source_id, 'target': target_id}).then((result) => { this.genericAPI(this.this_model, Actions.MERGE, {'source': source_id, 'target': target_id}).then((result) => {
this.foods = this.destroyCard(source_id, this.foods) this.foods = this.destroyCard(source_id, this.foods)
this.foods2 = this.destroyCard(source_id, this.foods2) this.foods2 = this.destroyCard(source_id, this.foods2)
this.refreshObject(target_id) this.refreshObject(target_id)
@ -326,6 +334,8 @@ export default {
console.log('Error', err) console.log('Error', err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger') this.makeToast(this.$t('Error'), err.bodyText, 'danger')
}) })
// TODO make standard toast
this.makeToast(this.$t('Success'), 'Succesfully merged food', 'success')
}, },
getChildren: function(col, food){ getChildren: function(col, food){
let parent = {} let parent = {}
@ -333,7 +343,7 @@ export default {
'root': food.id, 'root': food.id,
'pageSize': 200 'pageSize': 200
} }
this.genericAPI(this.this_model, 'list', options).then((result) => { this.genericAPI(this.this_model, Actions.LIST, options).then((result) => {
parent = this.findCard(food.id, col === 'left' ? this.foods : this.foods2) parent = this.findCard(food.id, col === 'left' ? this.foods : this.foods2)
if (parent) { if (parent) {
Vue.set(parent, 'children', result.data.results) Vue.set(parent, 'children', result.data.results)
@ -352,7 +362,7 @@ export default {
'pageSize': 200 'pageSize': 200
} }
this.genericAPI('recipe', 'list', options).then((result) => { this.genericAPI(Models.RECIPE, Actions.LIST, options).then((result) => {
parent = this.findCard(food.id, col === 'left' ? this.foods : this.foods2) parent = this.findCard(food.id, col === 'left' ? this.foods : this.foods2)
if (parent) { if (parent) {
Vue.set(parent, 'recipes', result.data.results) Vue.set(parent, 'recipes', result.data.results)
@ -366,6 +376,7 @@ export default {
}) })
}, },
refreshObject: function(id){ refreshObject: function(id){
console.log('refresh object', id)
this.getThis(id).then(result => { this.getThis(id).then(result => {
this.refreshCard(result.data, this.foods) this.refreshCard(result.data, this.foods)
this.refreshCard({...result.data}, this.foods2) this.refreshCard({...result.data}, this.foods2)
@ -382,12 +393,13 @@ export default {
this.this_item.icon = icon this.this_item.icon = icon
}, },
deleteThis: function(id, model) { deleteThis: function(id, model) {
this.genericAPI(this.this_model, 'destroy', {'id': id}).then((result) => { this.genericAPI(this.this_model, Actions.DELETE, {'id': id}).then((result) => {
this.foods = this.destroyCard(id, this.foods) this.foods = this.destroyCard(id, this.foods)
this.foods2 = this.destroyCard(id, this.foods2) this.foods2 = this.destroyCard(id, this.foods2)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
}).catch((err) => { }).catch((err) => {
console.log(err) console.log(err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger') StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
}) })
}, },
} }

View File

@ -107,6 +107,7 @@ export default {
}, },
methods: { methods: {
Button: function(e) { Button: function(e) {
console.log(typeof({}), typeof([]), typeof('this'), typeof(1))
this.action='new' this.action='new'
this.$bvModal.show('modal') this.$bvModal.show('modal')
}, },

103
vue/src/utils/models.js Normal file
View File

@ -0,0 +1,103 @@
/*
* Utility CLASS to define model configurations
* */
// TODO this needs rethought and simplified
// maybe a function that returns a single dictionary based on action?
export class Models {
// Arrays correspond to ORDERED list of parameters required by ApiApiFactory
// Inner arrays are used to construct a dictionary of key:value pairs
// MODEL configurations will override MODEL_TYPE configurations with will override ACTION configurations
// MODEL_TYPES - inherited by MODELS, inherits and takes precedence over ACTIONS
static TREE = {
'list': {
'params': ['query', 'root', 'tree', 'page', 'pageSize'],
'config': {
'root': {
'default': {
'function': 'CONDITIONAL',
'check': 'query',
'operator': 'not_exist',
'true': 0,
'false': undefined
}
},
'tree': {'default': undefined},
},
}
}
// MODELS - inherits and takes precedence over MODEL_TYPES and ACTIONS
static FOOD = {
'name': 'Food', // *OPTIONAL: parameters will be built model -> model_type -> default
'model_type': this.TREE, // *OPTIONAL* model specific params for api, if not present will attempt modeltype_create then default_create
// REQUIRED: unordered array of fields that can be set during create
'create': {
// if not defined partialUpdate will use the same parameters, prepending 'id'
'params': [['name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category']]
},
}
static KEYWORD = {}
static UNIT = {}
static RECIPE = {}
static SHOPPING_LIST = {}
static RECIPE = {
'name': 'Recipe',
'list': {
'params': ['query', 'keywords', 'foods', 'books', 'keywordsOr', 'foodsOr', 'booksOr', 'internal', 'random', '_new', 'page', 'pageSize', 'options'],
'config': {
'foods': {'type':'string'},
'keywords': {'type': 'string'},
'books': {'type': 'string'},
}
},
}
}
export class Actions {
static CREATE = {
"function": "create"
}
static UPDATE = {
"function": "partialUpdate",
}
static DELETE = {
"function": "destroy",
'params': ['id']
}
static FETCH = {
"function": "retrieve",
'params': ['id']
}
static LIST = {
"function": "list",
"suffix": "s",
"params": ['query', 'page', 'pageSize'],
"config": {
'query': {'default':undefined},
'page': {'default': 1},
'pageSize': {'default': 25}
}
}
static MERGE = {
"function": "merge",
'params': ['source', 'target'],
"config": {
'source': {'type':'string'},
'target': {'type': 'string'}
}
}
static MOVE = {
"function": "move",
'params': ['source', 'target'],
"config": {
'source': {'type':'string'},
'target': {'type': 'string'}
}
}
}

View File

@ -162,145 +162,120 @@ import axios from "axios";
axios.defaults.xsrfCookieName = 'csrftoken' axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN" axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
export const ApiMixin = { export const ApiMixin = {
data() { methods: {
return { /**
api_settings: { * constructs OpenAPI Generator function using named parameters
// TODO consider moving this to an API type dictionary that contains api types with details on the function call name, suffix, etc * @param {string} model string to define which model API to use
'suffix': { // if OpenApiGenerator adds a suffix to model name in function calls * @param {string} api string to define which of the API functions to use
'list': 's' * @param {object} options dictionary to define all of the parameters necessary to use API
}, */
// model specific settings, when not provided will use defaults instead genericAPI: function(model, action, options) {
'food': { // must be lowercase let setup = getConfig(model, action)
// *REQUIRED* name is name of the model used for translations and looking up APIFactory let func = setup.function
'name': 'Food', let config = setup?.config ?? {}
// *OPTIONAL: parameters will be built model -> model_type -> default let params = setup?.params ?? []
'model_type': 'tree', console.log('config', config, 'params', params)
// *OPTIONAL* model specific params for api, if not present will attempt modeltype_create then default_create let parameters = []
// an array will create a dict of name:value pairs
'create': [['name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category']], // required: unordered array of fields that can be set during create
// *OPTIONAL* model specific params for api, includes ordered list of parameters
// and an unordered array that will be converted to a dictionary and passed as the 2nd param
'partialUpdate': ['id', // required: ordered array of list params to patch existing object
['name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category'] // must include ordered array of field names that can be updated
],
// *OPTIONAL* provide model specific typing
// 'typing': {},
},
'keyword': {},
'unit': {},
'recipe': {
'name': 'Recipe',
'list': ['query', 'keywords', 'foods', 'books', 'keywordsOr', 'foodsOr', 'booksOr', 'internal', 'random', '_new', 'page', 'pageSize', 'options'],
'typing': {
'list': {
'foods': 'string',
'keywords': 'string',
'books': 'string',
}
},
}, let this_value = undefined
// collection of default attributes for model_type TREE. All settings except typing and values will be overwritten by model values params.forEach(function (item, index) {
'tree': { if (Array.isArray(item)) {
'values': { this_value = {}
'root': 'getFunction()', // if default value is exactly 'getFunction()' will call getFunction(model_type, param, params) // if the value is an array, convert it to a dictionary of key:value
'tree': undefined, // filtered based on OPTIONS passed
}, // maybe map/reduce is better?
'list': ['query', 'root', 'tree', 'page', 'pageSize'], // ordered array of list params for tree for (const [k, v] of Object.entries(options)) {
'typing': { if (item.includes(k)) {
'move': { this_value[k] = formatParam(config?.[k], v)
'source': 'string', }
'target': 'string',
}
}
},
// collection of global defaults. All settings except typing and values will be overwritten by model_type or model values
'default': {
'list': ['query', 'page', 'pageSize'], // ordered array of list params for default listApi
'destroy': ['id'], // ordered array of list params for default deleteApi
'retrieve': ['id'], // ordered array of list params for default retrieveApi
'merge': ['source', 'target'], // ordered array of list params for default mergeApi
'move': ['source', 'target'], // ordered array of list params for default moveApi
'create': [], // ordered array of list params for default createApi
'partialUpdate': [], // ordered array of list params for default updateApi
'values': {
'query': undefined, // default values for list API
'page': 1,
'pageSize': 25,
},
'typing': { // optional settings to force type on parameters
'merge': {
'source': 'string',
'target': 'string',
},
},
}
}
}
},
methods: {
/**
* constructs OpenAPI Generator function using named parameters
* @param {string} model string to define which model API to use
* @param {string} api string to define which of the API functions to use
* @param {object} options dictionary to define all of the parameters necessary to use API
*/
genericAPI: function(model, api, options) {
model = model.toLowerCase()
// construct settings for this model and this api - values are assigned in order (default is overwritten by api overwritten by model)
let settings = {...this.api_settings?.default ?? {}, ...this.api_settings?.[this.api_settings?.[model]?.model_type] ?? {}, ...this.api_settings[model]};
// values and typing are also dicts and need to be merged,
settings.values = {...this.api_settings?.default?.values ?? {},
...this.api_settings?.[this.api_settings?.[model]?.model_type]?.values ?? {},
...this.api_settings[model].values}
settings.typing = {...this.api_settings?.default?.typing?.[api] ?? {},
...this.api_settings?.[this.api_settings?.[model]?.model_type]?.typing?.[api] ?? {},
...this.api_settings[model].typing?.[api]}
let func = api + settings.name + (this.api_settings.suffix?.[api] ?? '')
// does model params exist?
let params = []
let this_value = undefined
settings[api].forEach(function (item, index) {
if (Array.isArray(item)) {
this_value = {}
for (const [k, v] of Object.entries(options)) { // filters options dict based on valus in array, I'm sure there's a better way to do this
if (item.includes(k)) {
this_value[k] = v
} }
} else {
this_value = options?.[item] ?? undefined
if (this_value) {this_value = formatParam(config?.[item], this_value)}
} }
} else { // if no value is found so far, get the default if it exists
this_value = options?.[item] ?? settings.values?.[item] ?? undefined // set the value or apply default if (!this_value) {
} this_value = getDefault(config?.[item], options)
if (this_value ==='getFunction()') {
this_value = getFunction(item, settings?.model_type, options)
}
if (Object.keys(settings?.typing).includes(item)) {
switch (settings.typing[item]) {
case 'string':
if (this_value) {this_value = String(this_value)}
break;
} }
} parameters.push(this_value)
params.push(this_value) });
});
console.log(func, 'parameters', parameters, 'passed options', options)
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
return apiClient[func](...params) return apiClient[func](...parameters)
},
} }
}
} }
/* // /*
* Utility functions to calculate default value // * local functions for ApiMixin
* */ // * */
export function getFunction(item, model_type, params) { function formatParam(config, value) {
if (item==='root' && model_type==='tree') { if (config) {
if ((!params?.query ?? undefined) || params?.query?.length == 0) { for (const [k, v] of Object.entries(config)) {
return 0 switch(k) {
case 'type':
switch(v) {
case 'string':
value = String(value)
break;
case 'integer':
value = parseInt(value)
}
break;
}
}
}
return value
}
function getDefault(config, options) {
let value = undefined
value = config?.default ?? undefined
if (typeof(value) === 'object') {
let condition = false
switch(value.function) {
// CONDITIONAL case requires 4 keys:
// - check: which other OPTIONS key to check against
// - operator: what type of operation to perform
// - true: what value to assign when true
// - false: what value to assign when false
case 'CONDITIONAL':
switch(value.operator) {
case 'not_exist':
condition = (
(!options?.[value.check] ?? undefined)
|| options?.[value.check]?.length == 0
)
if (condition) {
value = value.true
} else {
value = value.false
}
break;
}
break;
} }
return undefined
} }
return value
}
function getConfig(model, action) {
let f = action.function
// if not defined partialUpdate will use params from create
if (f === 'partialUpdate' && !model?.[f]?.params) {
model[f] = {'params': [...['id'], ...model.create.params]}
}
let config = {
'name': model.name,
'function': f + model.name + action?.suffix
}
// spread operator merges dictionaries - last item in list takes precedence
config = {...config, ...action, ...model.model_type?.[f], ...model?.[f]}
// nested dictionaries are not merged - so merge again on any nested keys
config.config = {...action?.config, ...model.model_type?.[f]?.config, ...model?.[f]?.config}
config.function = config.function + config.name + (config?.suffix ?? '') // parens are required to force optional chaining to evaluate before concat
return config
} }
@ -365,4 +340,5 @@ export const CardMixin = {
} }
}, },
} }
} }