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

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

View File

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

View File

@ -1,10 +1,10 @@
<template>
<div id="app" style="margin-bottom: 4vh">
<generic-modal-form
:model="this_model"
:model="this_model.name"
:action="this_action"/>
<generic-split-lists
:list_name="this_model"
:list_name="this_model.name"
@reset="resetList"
@get-list="getFoods"
@item-action="startAction"
@ -13,7 +13,7 @@
<generic-horizontal-card
v-for="f in foods" v-bind:key="f.id"
:model=f
:model_name="this_model"
:model_name="this_model.name"
:draggable="true"
:merge="true"
:move="true"
@ -28,7 +28,7 @@
<template v-slot:cards-right>
<generic-horizontal-card v-for="f in foods2" v-bind:key="f.id"
:model=f
:model_name="this_model"
:model_name="this_model.name"
:draggable="true"
:merge="true"
:move="true"
@ -90,7 +90,7 @@
:title="this.$t('Delete_Food')"
:ok-title="this.$t('Delete')"
: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})}}
</b-modal>
<!-- move modal -->
@ -141,7 +141,9 @@ import {BootstrapVue} from 'bootstrap-vue'
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 GenericHorizontalCard from "@/components/GenericHorizontalCard";
@ -152,11 +154,11 @@ Vue.use(BootstrapVue)
export default {
name: 'FoodListView',
mixins: [ToastMixin, ApiMixin, CardMixin],
mixins: [ApiMixin, CardMixin, ToastMixin],
components: {GenericHorizontalCard, GenericMultiselect, GenericSplitLists, GenericModalForm},
data() {
return {
this_model: 'Food',
this_model: Models.FOOD,
this_action:'',
foods: [],
foods2: [],
@ -249,7 +251,7 @@ export default {
getFoods: function(params, callback) {
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 (column ==='left') {
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))
}).catch((err) => {
console.log(err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
},
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 () {
let food = {...this.this_item}
food.supermarket_category = this.this_item.supermarket_category?.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
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
this.foods = [result.data].concat(this.foods)
// this creates a deep copy to make sure that columns stay independent
@ -286,20 +288,24 @@ export default {
} else {
this.foods2 = []
}
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
} else {
this.genericAPI(this.this_model, 'updatePartial', food).then((result) => {
this.refreshObject(this.this_item.id)
this.genericAPI(this.this_model, Actions.UPDATE, food).then((result) => {
this.refreshObject(food.id)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
}
this.this_item = {...this.blank_item}
},
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) {
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
@ -310,6 +316,8 @@ export default {
this.foods2 = this.destroyCard(source_id, this.foods2)
this.refreshObject(target_id)
}
// TODO make standard toast
this.makeToast(this.$t('Success'), 'Succesfully moved food', 'success')
}).catch((err) => {
// TODO none of the error checking works because the openapi generated functions don't throw an error?
// or i'm capturing it incorrectly
@ -318,7 +326,7 @@ export default {
})
},
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.foods2 = this.destroyCard(source_id, this.foods2)
this.refreshObject(target_id)
@ -326,6 +334,8 @@ export default {
console.log('Error', err)
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){
let parent = {}
@ -333,7 +343,7 @@ export default {
'root': food.id,
'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)
if (parent) {
Vue.set(parent, 'children', result.data.results)
@ -352,7 +362,7 @@ export default {
'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)
if (parent) {
Vue.set(parent, 'recipes', result.data.results)
@ -366,6 +376,7 @@ export default {
})
},
refreshObject: function(id){
console.log('refresh object', id)
this.getThis(id).then(result => {
this.refreshCard(result.data, this.foods)
this.refreshCard({...result.data}, this.foods2)
@ -382,12 +393,13 @@ export default {
this.this_item.icon = icon
},
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.foods2 = this.destroyCard(id, this.foods2)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
}).catch((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: {
Button: function(e) {
console.log(typeof({}), typeof([]), typeof('this'), typeof(1))
this.action='new'
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.xsrfHeaderName = "X-CSRFTOKEN"
export const ApiMixin = {
data() {
return {
api_settings: {
// TODO consider moving this to an API type dictionary that contains api types with details on the function call name, suffix, etc
'suffix': { // if OpenApiGenerator adds a suffix to model name in function calls
'list': 's'
},
// model specific settings, when not provided will use defaults instead
'food': { // must be lowercase
// *REQUIRED* name is name of the model used for translations and looking up APIFactory
'name': 'Food',
// *OPTIONAL: parameters will be built model -> model_type -> default
'model_type': 'tree',
// *OPTIONAL* model specific params for api, if not present will attempt modeltype_create then default_create
// 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',
}
},
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, action, options) {
let setup = getConfig(model, action)
let func = setup.function
let config = setup?.config ?? {}
let params = setup?.params ?? []
console.log('config', config, 'params', params)
let parameters = []
},
// collection of default attributes for model_type TREE. All settings except typing and values will be overwritten by model values
'tree': {
'values': {
'root': 'getFunction()', // if default value is exactly 'getFunction()' will call getFunction(model_type, param, params)
'tree': undefined,
},
'list': ['query', 'root', 'tree', 'page', 'pageSize'], // ordered array of list params for tree
'typing': {
'move': {
'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
let this_value = undefined
params.forEach(function (item, index) {
if (Array.isArray(item)) {
this_value = {}
// if the value is an array, convert it to a dictionary of key:value
// filtered based on OPTIONS passed
// maybe map/reduce is better?
for (const [k, v] of Object.entries(options)) {
if (item.includes(k)) {
this_value[k] = formatParam(config?.[k], v)
}
}
} else {
this_value = options?.[item] ?? undefined
if (this_value) {this_value = formatParam(config?.[item], this_value)}
}
} else {
this_value = options?.[item] ?? settings.values?.[item] ?? undefined // set the value or apply default
}
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;
// if no value is found so far, get the default if it exists
if (!this_value) {
this_value = getDefault(config?.[item], options)
}
}
params.push(this_value)
});
let apiClient = new ApiApiFactory()
return apiClient[func](...params)
parameters.push(this_value)
});
console.log(func, 'parameters', parameters, 'passed options', options)
let apiClient = new ApiApiFactory()
return apiClient[func](...parameters)
},
}
}
}
/*
* Utility functions to calculate default value
* */
export function getFunction(item, model_type, params) {
if (item==='root' && model_type==='tree') {
if ((!params?.query ?? undefined) || params?.query?.length == 0) {
return 0
// /*
// * local functions for ApiMixin
// * */
function formatParam(config, value) {
if (config) {
for (const [k, v] of Object.entries(config)) {
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 = {
}
},
}
}
}