This commit is contained in:
Arthur
2019-04-20 15:15:32 +02:00
parent d8ac11921f
commit 2e7553b70c
46 changed files with 1871 additions and 721 deletions

View File

@@ -39,7 +39,12 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
# Custom settings
'webpack_loader', 'webpack_loader',
'rest_framework',
'rest_framework_nested',
'recipe_book',
'planning',
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@@ -14,11 +14,29 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path, include
from django.views.generic import TemplateView from django.views.generic import TemplateView
from recipe_book import views
from rest_framework_nested import routers
router = routers.DefaultRouter()
router.register(r'recips', views.RecipesViewSet)
router.register(r'ingdts', views.IngredientViewSet)
amounts_router = routers.NestedDefaultRouter(
router, r'recips', lookup="recipe"
)
amounts_router.register(
r'amounts', views.IngredientWAmountViewSet,
base_name="recipe-ingdt-amounts"
)
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/', include(router.urls)),
path('api/', include(amounts_router.urls)),
path('', path('',
TemplateView.as_view( TemplateView.as_view(
template_name="application.html" template_name="application.html"

File diff suppressed because it is too large Load Diff

View File

@@ -10,12 +10,13 @@
"dependencies": { "dependencies": {
"core-js": "^2.6.5", "core-js": "^2.6.5",
"vue": "^2.6.6", "vue": "^2.6.6",
"vue-router": "^3.0.2",
"vuetify": "^1.5.5" "vuetify": "^1.5.5"
}, },
"devDependencies": { "devDependencies": {
"@vue/cli-plugin-babel": "^3.5.0", "@vue/cli-plugin-babel": "^3.5.0",
"@vue/cli-plugin-eslint": "^3.5.0", "@vue/cli-plugin-eslint": "^3.5.0",
"@vue/cli-service": "^3.5.0", "@vue/cli-service": "^3.6.0",
"babel-eslint": "^10.0.1", "babel-eslint": "^10.0.1",
"eslint": "^5.8.0", "eslint": "^5.8.0",
"eslint-plugin-vue": "^5.0.0", "eslint-plugin-vue": "^5.0.0",

View File

@@ -8,6 +8,8 @@
<title>frontend</title> <title>frontend</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons">
</head> </head>
<body> <body>
<noscript> <noscript>

View File

@@ -1,27 +1,119 @@
<template> <template>
<v-app> <v-app>
<ToolBar/> <v-toolbar app tabs>
<v-toolbar-side-icon
@click.stop="showNav = !showNav"
></v-toolbar-side-icon>
<v-toolbar-title>{{ title }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon
v-for="(tool, idx) in tools"
:key="`tool-${idx}`"
>
<v-icon
:color="tool.color ? tool.color : 'dark'"
v-html="tool.icon"
></v-icon>
</v-btn>
<template v-slot:extension>
<router-view name="extension"></router-view>
</template>
</v-toolbar>
<v-navigation-drawer
fixed
app
clipped
v-model="showNav"
>
<v-list dense>
<v-list-tile
v-for="link in links"
:key="link.path"
:to="link.path"
>
<v-list-tile-avatar>
<v-icon v-html="link.icon"
></v-icon>
</v-list-tile-avatar>
<v-list-tile-title v-html="link.text"
></v-list-tile-title>
</v-list-tile>
</v-list>
</v-navigation-drawer>
<v-content> <v-content>
<HelloWorld/> <router-view></router-view>
</v-content> </v-content>
</v-app> </v-app>
</template> </template>
<script> <script>
import HelloWorld from './components/HelloWorld'
import ToolBar from './components/ToolBar' import NotFound from './routes/NotFound'
export const RecipeCategories = {
0: "Petit-déjeuner",
1: "Entrée",
2: "Plat",
3: "Dessert"
}
// *** TOOLS ***
//
// * create_recipe: go to creation page
// * import_recipe: go to import page
// ---- Recipe list
// * category_tabs: navigate through categories
// * search: show input inside extension
// ---- Recipe details
// * go_back: go back to previous page
// * edit: edit fields of model details
// * add_to_menu: show menu slot picker in bottom sheet
// ---- Planning
// * complete_menu: ask for automatic completion of menu
export default { export default {
name: 'App', name: 'App',
components: {
HelloWorld,
ToolBar
},
data () { data () {
return { return {
// links: [],
categories: RecipeCategories,
showNav: null,//
} }
},
created () {
// Build navigation links from router.js config
for (var idx in this.$router.options.routes) {
var route = this.$router.options.routes[idx];
this.links.push({
path: route.path,
text: route.meta.title,
icon: route.meta.icon,
})
}
},
computed: {
tools: function() {
return [{icon: 'add'},]
},
// TODO: move this inside routing events
title: function() {
var title = "CookAssistant"
for (var key in this.$route.matched) {
var route = this.$route.matched[key];
if (route.hasOwnProperty('meta')) {
if (route.meta.title != undefined) {
title = route.meta.title
}
}
}
return title;
},
} }
} }
</script> </script>
<style>
html { overflow-y: auto }
</style>

153
frontend/src/api.js Normal file
View File

@@ -0,0 +1,153 @@
/*
TODO:
Implement 'create'/'edit'/'read' modes with generic
configuration retrieved from OPTIONS request (using api.js)
'read' would use GET methods
'edit' would use PUT actions
'create' would use POST action
*/
const baseUrl = "http://localhost:8000/api"
/*
Check that the response status is what's expected.
Returns an object with :
* success: bool
* data: response data
*/
const processStatusCode = function(expected_code) {
return function(response) {
const status_code = response.status;
let data;
if (expected_code != 204) {
data = response.json();
} else {
data = null
}
return Promise.all([status_code, data])
.then(ret => ({
success: ret[0] == expected_code,
data: ret[1]
}))
}
}
/*
Returns data on success or throw an error with data.
*/
const processSuccess = function(result) {
if (!result.success) {
throw result.data;
}
return result.data;
}
const _headers = {
'Content-Type': 'application/json'
}
const _request = {
get (url) {
return fetch(
url,
{ method: 'GET',
})
.then(processStatusCode(200))
.then(processSuccess);
},
post (url, data) {
return fetch(
url,
{ method: 'POST',
body: JSON.stringify(data),
headers: _headers,
})
.then(processStatusCode(201))
.then(processSuccess)
},
put (url, data) {
return fetch(
url,
{ method: 'PUT',
body: JSON.stringify(data),
headers: _headers,
})
.then(processStatusCode(200))
.then(processSuccess)
},
delete(url) {
return fetch(
url,
{ method: 'DELETE',
headers: _headers, })
.then(processStatusCode(204))
.then(processSuccess)
},
options (url) {
return fetch(
url,
{ method: 'OPTIONS',
})
.then(processStatusCode(200))
.then(processSuccess)
}
}
const mountEndPoint = function(url) {
return {
read (id) {
return _request.get(url + id + '/')
},
create (data) {
return _request.post(url, data)
},
edit (id, data) {
return _request.put(url + id + '/', data)
},
delete (id) {
return _request.delete(url + id + '/')
},
options() { return _request.options(url) }
}
}
const mountNestedEndPoint = function(url) {
const getUrl = function(parent_id) {
// TODO: Make it safe against url injection !
return url.replace('*', parent_id)
}
return {
read (parent_id, id) {
return _request.get(getUrl(parent_id) + id + '/')
},
create (parent_id, data) {
return _request.post(
getUrl(parent_id),
data,
)
},
edit (parent_id, id, data) {
return _request.put(
getUrl(parent_id) + id + '/',
data,
)
},
delete (parent_id, id) {
return _request.delete( getUrl(parent_id) + id + '/' )
},
options () {
return _request.options( getUrl(1) )
},
list (parent_id) {
return _request.get( getUrl(parent_id) )
}
}
}
export default {
recipes: mountEndPoint(`${baseUrl}/recips/`),
ingredients: mountNestedEndPoint(
`${baseUrl}/recips/*/amounts/`
),
}

View File

@@ -0,0 +1,40 @@
<template>
<!-- Categories list -->
<span>
<v-tabs v-if="showTabs" :mandatory="false">
<v-tab v-for="key in Object.keys(categories)"
:key="`cat-${key}`"
:to="`/recipes/category/${key}`">
{{ categories[key] }}
</v-tab>
</v-tabs>
<template v-else>
<v-toolbar-title>
Détails
</v-toolbar-title>
<v-btn v-show="!showTabs"
icon
@click="$router.go(-1)"
><v-icon>arrow_back</v-icon></v-btn>
</template>
</span>
</template>
<script>
import { RecipeCategories } from '../App'
export default {
data: () => ({
categories: RecipeCategories,
}),
computed: {
showTabs: function() {
return !(this.$route.path.startsWith("/recipes/id"))
},
}
}
</script>
<style scoped>
</style>

View File

@@ -1,147 +0,0 @@
<template>
<v-container>
<v-layout
text-xs-center
wrap
>
<v-flex xs12>
<v-img
:src="require('../assets/logo.svg')"
class="my-3"
contain
height="200"
></v-img>
</v-flex>
<v-flex mb-4>
<h1 class="display-2 font-weight-bold mb-3">
Welcome to Vuetify
</h1>
<p class="subheading font-weight-regular">
For help and collaboration with other Vuetify developers,
<br>please join our online
<a href="https://community.vuetifyjs.com" target="_blank">Discord Community</a>
</p>
</v-flex>
<v-flex
mb-5
xs12
>
<h2 class="headline font-weight-bold mb-3">What's next?</h2>
<v-layout justify-center>
<a
v-for="(next, i) in whatsNext"
:key="i"
:href="next.href"
class="subheading mx-3"
target="_blank"
>
{{ next.text }}
</a>
</v-layout>
</v-flex>
<v-flex
xs12
mb-5
>
<h2 class="headline font-weight-bold mb-3">Important Links</h2>
<v-layout justify-center>
<a
v-for="(link, i) in importantLinks"
:key="i"
:href="link.href"
class="subheading mx-3"
target="_blank"
>
{{ link.text }}
</a>
</v-layout>
</v-flex>
<v-flex
xs12
mb-5
>
<h2 class="headline font-weight-bold mb-3">Ecosystem</h2>
<v-layout justify-center>
<a
v-for="(eco, i) in ecosystem"
:key="i"
:href="eco.href"
class="subheading mx-3"
target="_blank"
>
{{ eco.text }}
</a>
</v-layout>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
export default {
data: () => ({
ecosystem: [
{
text: 'vuetify-loader',
href: 'https://github.com/vuetifyjs/vuetify-loader'
},
{
text: 'github',
href: 'https://github.com/vuetifyjs/vuetify'
},
{
text: 'awesome-vuetify',
href: 'https://github.com/vuetifyjs/awesome-vuetify'
}
],
importantLinks: [
{
text: 'Documentation',
href: 'https://vuetifyjs.com'
},
{
text: 'Chat',
href: 'https://community.vuetifyjs.com'
},
{
text: 'Made with Vuetify',
href: 'https://madewithvuetifyjs.com'
},
{
text: 'Twitter',
href: 'https://twitter.com/vuetifyjs'
},
{
text: 'Articles',
href: 'https://medium.com/vuetify'
}
],
whatsNext: [
{
text: 'Explore components',
href: 'https://vuetifyjs.com/components/api-explorer'
},
{
text: 'Select a layout',
href: 'https://vuetifyjs.com/layout/pre-defined'
},
{
text: 'Frequently Asked Questions',
href: 'https://vuetifyjs.com/getting-started/frequently-asked-questions'
}
]
})
}
</script>
<style>
</style>

View File

@@ -0,0 +1,26 @@
<template>
<v-select v-if="type == 'choice'"
:label="label"
placeholder="----"
:items="choices"
item-text="display_name"
:value="value"
@change="val => $emit('input', val)"
/>
<v-text-field v-else
:label="label"
:type="type"
:value="value"
@input="val => $emit('input', val)"
/>
</template>
<script>
//TODO: work with nested object fields...
export default {
props: ['value', 'label', 'read_only', 'required', 'type', 'choices'],
data () {
return {}
}
}
</script>

View File

@@ -1,37 +0,0 @@
<template>
<v-toolbar dark color="primary">
<!-- <v-toolbar-side-icon></v-toolbar-side-icon> -->
<v-toolbar-title class="white--text">
Cook Assistant
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon>
<v-icon>search</v-icon>
</v-btn>
<v-btn icon>
<v-icon>apps</v-icon>
</v-btn>
<v-btn icon>
<v-icon>refresh</v-icon>
</v-btn>
<v-btn icon>
<v-icon>more_vert</v-icon>
</v-btn>
</v-toolbar>
</template>
<script>
export default {
data: () => ({})
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,87 @@
<template>
<v-card-text class="py-1" v-if="!editing">
{{ item.display }}
</v-card-text>
<v-input v-else
:append-icon="append_icon"
@click:append="appendIconAction"
:messages="errors"
>
<v-container fluid class="pa-0 ma-0">
<v-layout>
<v-flex xs3>
<RField
v-bind="fields.amount"
v-model="item.amount"
@input="markUpdated"
/>
</v-flex>
<v-flex xs3>
<RField
v-bind="fields.unit"
v-model="item.unit"
@input="markUpdated"
/>
</v-flex>
<v-flex xs6>
<RField
v-bind="fields.ingredient.children.name"
v-model="item.ingredient.name"
@input="markUpdated"
/>
</v-flex>
</v-layout>
</v-container>
</v-input>
</template>
<script>
import api from '../../api.js'
import RField from '../RessourceField'
export default {
components: { RField },
props: ["item", "fields", "editing"],
data() {
return {
errors: [],
updated: false,
append_icon: "delete"
}
},
computed: {
isNew () {
return !this.item.ingredient.id
},
},
created () {
if (this.isNew) {
this.append_icon = "add"
}
},
methods: {
appendIconAction () {
if (this.isNew) {
this.$emit('create', this)
} else {
// TODO: should ask for confirmation
this.$emit('delete', this)
}
},
markUpdated () {
this.updated = true
}
},
watch: {
editing(newValue) {
if (newValue == false && this.updated) {
// TODO: check if there has been updates
this.$emit('save', this)
this.updated = false
}
},
},
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<v-card class="pa-1">
<v-card-title>
<strong>Ingrédients</strong>
</v-card-title>
<ingredient-details
v-for="ingdt in edited_ingdts"
:item="ingdt"
:fields="fields"
:editing="editing"
@delete="deleteItem"
@save="saveItem"
/>
<!-- New ingredient in editing mode -->
<p v-if="editing">
(+) <ingredient-details
:item="newItem()"
:fields="fields"
:editing="editing"
@create="createItem"
/>
</p>
</v-card>
</template>
<script>
import api from '../../api.js'
import Ingredient from './Ingredient'
function NullIngredient (recipe_id) {
this.recipe = recipe_id
this.ingredient = { name: "" }
this.amount = 0
this.unit = ""
this.display = ""
}
export default {
components: {
'ingredient-details': Ingredient,
},
props: ['ingredients', 'editing', 'recipe'],
data () {
return {
fields: {},
edited_ingdts: [],
}
},
mounted () {
// Copy the property to mutate it
api.ingredients.options()
.then(opts => {
this.fields = opts.actions.POST
})
console.log("get list:", this.recipe)
api.ingredients.list(this.recipe)
.then(data => this.edited_ingdts = data)
.catch(err => console.error("error reading ingdt:", err))
},
methods: {
newItem () {
return new NullIngredient(this.recipe)
},
createItem (comp) {
let item = comp.item
item.recipe = this.recipe
api.ingredients.create(
this.recipe,
item
)
.then(data => {
this.edited_ingdts.push(data)
//TODO: reset additionnal Ingredient
})
.catch(err => comp.errors.push(JSON.stringify(err)))
},
saveItem(comp) {
let item = comp.item
item.recipe = this.recipe // Inject recipe's id into data
api.ingredients.edit(
item.recipe,
item.ingredient.id,
item,
)
.then(data => {
// TODO: better identification for ingredients
// BUG: will not work if ingredient changes!!
let idx = this.edited_ingdts.indexOf(it => it.ingredient == data.ingredient)
this.edited_ingdts[idx] = data
})
.catch(err => comp.errors.push(JSON.stringify(err)))
},
deleteItem(comp) {
let item = comp.item
api.ingredients.delete(this.recipe, item.ingredient.id)
.then(() => {
var idx = this.edited_ingdts.indexOf(item)
this.edited_ingdts.splice(idx, 1)
})
.catch(err => comp.errors.push(JSON.stringify(err)))
}
}
}
</script>

View File

@@ -0,0 +1,194 @@
<!-- Details view of a Recipe
Has read/edit modes
-->
<template>
<v-container fluid>
<v-layout wrap>
<v-flex xs10> <!-- Heading -->
<v-alert v-for="(error,idx) in errors"
:key="`alert-${idx}`"
:value="true">
Error: {{ error }}
</v-alert>
<v-card-text v-if="!editing">
<h6 class="title pb-3">
{{item.name}}
</h6>
<p class="subheading pb-3">
{{categories[item.category]}}
</p>
</v-card-text>
<template v-else>
<RField
v-bind="fields.name"
v-model="item.name"
/>
<RField
v-bind="fields.category"
v-model="item.category"
/>
</template>
</v-flex>
<v-flex xs2> <!-- Buttons -->
<!-- Add to planning -->
<v-bottom-sheet v-show="!editing"
v-model="sheetSelectSlot"
>
<template v-slot:activator>
<v-btn
fab dark
color="success"
><v-icon>add</v-icon></v-btn>
</template>
<!-- Put a meal picker here... -->
<v-card elevation="10" style="min-height: 300px;">
<v-card-text>
Content of bottom sheet
</v-card-text>
</v-card>
</v-bottom-sheet>
<!-- Edit mode -->
<v-fab-transition>
<v-btn
fab dark
:small="!editing"
color="error"
@click="switchEdit"
>
<v-icon v-if="editing">save</v-icon>
<v-icon v-else>edit</v-icon>
</v-btn>
</v-fab-transition>
</v-flex>
<v-flex xs12 md4>
<IngredientList v-if="!isLoading"
:editing="editing"
:recipe="item.id"
:ingredients="item.ingredients"
/>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import RField from '../RessourceField'
import { RecipeCategories } from '../../App'
import api from '../../api.js'
import NotFound from '../../routes/NotFound'
const IngredientList = import('./IngredientList')
function NullRecipe() {
this.name = String();
this.category = Number();
this.ingredients = [];
}
export default {
components: {
IngredientList: () => ({
component: IngredientList,
loading: NotFound, //TODO: actual loading view
error: NotFound,
delay: 100,
timeout: 3000,
}),
RField,
},
data() {
// Transform categories for v-select component
var select_items = []
Object.keys(RecipeCategories)
.map(function(key) {
select_items.push({
text: RecipeCategories[key],
value: key,
});
});
return {
isLoading: true,
sheetSelectSlot: false,
editing: false,
select_items,
categories: RecipeCategories,
errors: [],
// Will be populated by initRecipeItem method
item: new NullRecipe,
}
},
methods: {
switchEdit: function() {
if (this.editing) {
var data = {
name: this.item.name,
category: this.item.category,
}
if (this.item.id) {
// Edit recipe
api.recipes.edit(this.item.id, data)
.then(data => {
this.item = data
this.editing = false
})
.catch(err => this.errors.push(err))
} else {
// Create recipe
api.recipes.create(data)
.then(data => {
this.item = data
this.editing = false
})
.catch(err => this.errors.push(err))
}
} else {
this.editing = true
}
},
initRecipeItem: function(id) {
// An id of 0 means we want a creation page
if (id == 0) {
this.item = new NullRecipe
this.editing = true
} else {
api.recipes.read(id)
.then(data => {
this.errors = []
this.item = data
})
.catch(err => {
this.errors.push(err)
this.item = new NullRecipe
})
.then(() => this.isLoading = false)
}
},
updateRecip: function(form_data) {
console.log(form_data);
var messages = [];
return messages;
}
},
created () {
api.recipes.options()
.then(data => this.fields = data.actions.POST)
.then(() => console.log(this.fields))
},
beforeRouteEnter (to, from, next) {
next(vm => {
const id = to.params.id;
vm.initRecipeItem(id);
})
},
beforeRouteUpdate (to, from, next) {
const id = to.params.id;
this.initRecipeItem(id);
next()
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,48 @@
/* List view of recipes */
<template>
<v-list>
<template v-for="(item, idx) in items">
<v-list-tile
:key="item.title"
:to="`/recipes/id/${item.id}`">
<v-list-tile-content>
{{item.name}}
</v-list-tile-content>
</v-list-tile>
<v-divider v-if="idx < (items.length - 1)"
:key="idx"></v-divider>
</template>
</v-list>
</template>
<script>
export default {
data() {
return {
// Recipes from db grouped by categories id
content: {
0: [], 1: [], 2: [], 3: []
}
}
},
computed: {
items: function() {
return this.content[this.$route.params.cat];
}
},
created: function() {
const vm = this;
fetch('http://localhost:8000/api/recips/')
.then((response) => response.json())
.then(function(data) {
for (var idx in data) {
var obj = data[idx];
vm.content[parseInt(obj.category)].push(obj);
}
});
}
}
</script>
<style scoped>
</style>

View File

@@ -1,9 +1,11 @@
import Vue from 'vue' import Vue from 'vue'
import './plugins/vuetify' import './plugins/vuetify'
import App from './App.vue' import App from './App.vue'
import router from './router'
Vue.config.productionTip = false Vue.config.productionTip = false
new Vue({ new Vue({
router,
render: h => h(App), render: h => h(App),
}).$mount('#app') }).$mount('#app')

46
frontend/src/router.js Normal file
View File

@@ -0,0 +1,46 @@
import Vue from 'vue'
import Router from 'vue-router'
import Index from './routes/Index'
import NotFound from './routes/NotFound'
import Recipes from './routes/Recipes'
import Planning from './routes/Planning'
import RecipeDetails from './components/recipes/RecipeDetails'
import RecipeList from './components/recipes/RecipeList'
import CategoryTabs from './components/CategoryTabs'
Vue.use(Router)
const router = new Router({
routes: [
{ path: '/',
component: Index,
meta: { title: "Home", icon: "home" }
},
{ path: '/recipes',
components: { default: Recipes,
extension: CategoryTabs, },
meta: { title: "Recettes",
icon:"book" },
children: [
{ path: 'id/:id',
component: RecipeDetails,
meta: { subtitle: "Détails", } },
{ path: 'category/:cat',
component: RecipeList,
meta: { subtitle: "Liste", } },
{ path: '*',
component: NotFound }
]
},
{ path: '/planning',
component: Planning,
meta: { title: "Menu", icon: "calendar_today" },
}
]
})
export default router

View File

@@ -0,0 +1,21 @@
<template>
<section>
<v-img
:src="require('../assets/logo.svg')"
class="ma-3"
contain
height="200"
></v-img>
</section>
</template>
<script>
export default {
data: () => ({
})
}
</script>
<style>
</style>

View File

@@ -0,0 +1,9 @@
<template>
<v-alert value="true">Not found</v-alert>
</template>
<script>
export default {
data: () => ({})
}
</script>

View File

@@ -0,0 +1,91 @@
<template>
<v-list two-line overflow-y>
<template v-for="(item, index) in items">
<v-subheader v-if="item.header"
:key="item.header"
>{{item.header}}</v-subheader>
<v-divider v-else-if="item.divider"></v-divider>
<v-list-tile v-else
:key="index">
<v-list-tile-content>
<v-list-tile-sub-title
>{{ item.subtitle }}</v-list-tile-sub-title>
<v-list-tile-title
>{{ item.title }}</v-list-tile-title>
</v-list-tile-content>
<v-list-tile-action v-show="item.title">
<v-btn icon color="primary" small
:to="`/recipes/id/${item.id}`"
><v-icon>remove_red_eye</v-icon></v-btn>
</v-list-tile-action>
<v-list-tile-action>
<v-btn icon color="warning" small>
<v-icon>
{{ item.title ? 'delete' : 'add' }}
</v-icon>
</v-btn>
</v-list-tile-action>
</v-list-tile>
</template>
</v-list>
</template>
<script>
const MockPlanning = {
"Lundi": {
"Midi": "Raclette",
"Soir": "Soupe",
},
"Mardi": {
"Midi": null,
"Soir": null,
},
"Mercredi": {
"Midi": null,
"Soir": null,
},
"Jeudi": {
"Midi": null,
"Soir": null,
},
"Vendredi": {
"Midi": null,
"Soir": null,
},
"Samedi": {
"Midi": null,
"Soir": null,
},
"Dimanche": {
"Midi": null,
"Soir": null,
},
}
export default {
data() {
var items = [];
for (var day in MockPlanning) {
items.push({ divider: true })
items.push({ header: day });
for (var meal in MockPlanning[day]) {
items.push({ id: 0, subtitle: meal, title: MockPlanning[day][meal] });
}
}
return {
items,
toolActions: [
{ icon: "find_replace", color: "success" },
],
planning: MockPlanning,
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,14 @@
<template>
<router-view></router-view>
</template>
<script>
export default {
data: () => ({
}),
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<template>
<HelloWorld />
</template>
<script>
import HelloWorld from '../components/HelloWorld'
export default {
components: {
HelloWorld
}
}
</script>

View File

@@ -1,7 +1,7 @@
const BundleTracker = require("webpack-bundle-tracker"); const BundleTracker = require("webpack-bundle-tracker");
module.exports = { module.exports = {
baseUrl: "http://127.0.0.1:8080/", publicPath: "http://localhost:8080/",
outputDir: './dist/', outputDir: './dist/',
chainWebpack: config => { chainWebpack: config => {
@@ -17,8 +17,8 @@ module.exports = {
.set('__STATIC__', 'static') .set('__STATIC__', 'static')
config.devServer config.devServer
.public('http://0.0.0.0:8080') .public('http://localhost:8080')
.host('0.0.0.0') .host('localhost')
.port(8080) .port(8080)
.hotOnly(true) .hotOnly(true)
.watchOptions({poll: 1000}) .watchOptions({poll: 1000})

View File

@@ -1 +1 @@
{"status":"done","publicPath":"http://127.0.0.1:8080/","chunks":{"app":[{"name":"app.js","publicPath":"http://127.0.0.1:8080/app.js","path":"C:\\Users\\lecor\\Documents\\Dev\\Python\\cookAssistant\\frontend\\dist\\app.js"},{"name":"app.a2bf8f46c2ce5a8648c5.hot-update.js","publicPath":"http://127.0.0.1:8080/app.a2bf8f46c2ce5a8648c5.hot-update.js","path":"C:\\Users\\lecor\\Documents\\Dev\\Python\\cookAssistant\\frontend\\dist\\app.a2bf8f46c2ce5a8648c5.hot-update.js"}]},"error":"ModuleError","message":"Module Error (from ./node_modules/vue-loader/lib/loaders/templateLoader.js):\n(Emitted value instead of an instance of Error) \n\n Errors compiling template:\n\n tag <v-toolbar-items> has no matching end tag.\n\n 7 | </v-toolbar-title>\n 8 | <v-spacer></v-spacer>\n 9 | <v-toolbar-items>\n | ^^^^^^^^^^^^^^^^^\n 10 | <v-btn\n 11 | flat\n"} {"status":"done","publicPath":"http://localhost:8080/","chunks":{"null":[{"name":"0.js","publicPath":"http://localhost:8080/0.js","path":"/home/artus/Documents/cookAssistant/frontend/dist/0.js"},{"name":"0.880af34e5f0e1c2d5443.hot-update.js","publicPath":"http://localhost:8080/0.880af34e5f0e1c2d5443.hot-update.js","path":"/home/artus/Documents/cookAssistant/frontend/dist/0.880af34e5f0e1c2d5443.hot-update.js"}],"app":[{"name":"app.js","publicPath":"http://localhost:8080/app.js","path":"/home/artus/Documents/cookAssistant/frontend/dist/app.js"}]},"error":"ModuleBuildError","message":"Module build failed (from ./node_modules/babel-loader/lib/index.js):\nSyntaxError: /home/artus/Documents/cookAssistant/frontend/src/components/recipes/IngredientList.vue: Unexpected token (85:70)\n\n 83 | )\n 84 | .then(data => {\n> 85 | var idx = this.edited_ingdts.indexOf(i => i.ingredient.id ==)\n | ^\n 86 | })\n 87 | .catch(err => comp.errors.push(JSON.stringify(err)))\n 88 | },\n at Object.raise (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:3851:17)\n at Object.unexpected (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5167:16)\n at Object.parseExprAtom (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:6328:20)\n at Object.parseExprAtom (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:3570:20)\n at Object.parseExprSubscripts (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5914:23)\n at Object.parseMaybeUnary (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5894:21)\n at Object.parseExprOpBaseRightExpr (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5854:34)\n at Object.parseExprOpRightExpr (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5847:21)\n at Object.parseExprOp (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5826:27)\n at Object.parseExprOps (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5791:17)\n at Object.parseMaybeConditional (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5754:23)\n at Object.parseMaybeAssign (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5701:21)\n at Object.parseFunctionBody (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:6891:24)\n at Object.parseArrowExpression (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:6851:10)\n at Object.parseExprAtom (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:6213:18)\n at Object.parseExprAtom (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:3570:20)"}

0
planning/__init__.py Normal file
View File

3
planning/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
planning/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class PlanningConfig(AppConfig):
name = 'planning'

View File

12
planning/models.py Normal file
View File

@@ -0,0 +1,12 @@
from django.db import models
from recipe_book.models import Recipe
# Create your models here.
class WeekPlanning(models.Model):
pass
class DayMeals(models.Model):
midi = models.ForeignKey(
Recipe,
on_delete=models.CASCADE )

3
planning/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
planning/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

0
recipe_book/__init__.py Normal file
View File

13
recipe_book/admin.py Normal file
View File

@@ -0,0 +1,13 @@
from django.contrib import admin
from .models import Recipe,IngredientWithAmount, Ingredient
# Register your models here.
class IngredientWithAmountInline(admin.TabularInline):
model=IngredientWithAmount
class RecipeAdmin(admin.ModelAdmin):
inlines = [ IngredientWithAmountInline, ]
admin.site.register(Recipe, RecipeAdmin)
admin.site.register(Ingredient)

5
recipe_book/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class RecipeBookConfig(AppConfig):
name = 'recipe_book'

View File

@@ -0,0 +1,45 @@
# Generated by Django 2.2 on 2019-04-11 13:20
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Ingredient',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=256)),
],
),
migrations.CreateModel(
name='IngredientWithAmount',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=6)),
('unit', models.CharField(choices=[('gr', 'Grammes'), ('u', 'Units'), ('l', 'Litres')], max_length=2)),
('ingredients', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipe_book.Ingredient')),
],
),
migrations.CreateModel(
name='Recipe',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=512)),
('category', models.CharField(choices=[('0', 'Petit-déjeuner'), ('1', 'Entrée'), ('2', 'Plat'), ('3', 'Dessert')], max_length=2)),
('ingredients', models.ManyToManyField(through='recipe_book.IngredientWithAmount', to='recipe_book.Ingredient')),
],
),
migrations.AddField(
model_name='ingredientwithamount',
name='recipe',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipe_book.Recipe'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-04-11 13:56
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recipe_book', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='ingredientwithamount',
old_name='ingredients',
new_name='ingredient',
),
]

View File

103
recipe_book/models.py Normal file
View File

@@ -0,0 +1,103 @@
from django.db import models
# Create your models here.
"""
Recipe's creation workflow :
1. Create recipe with required info, optional preparation
2. Create recipe's list of ingredients
a. Create new ingredients instances
b. Create IngredientWithAmount instances
3. Add preparation steps if necessary
"""
class Recipe(models.Model):
name = models.CharField(max_length=512)
category = models.CharField(
max_length=2,
choices=(
('0', 'Petit-déjeuner'),
('1', 'Entrée'),
('2', 'Plat'),
('3', 'Dessert'),
))
ingredients = models.ManyToManyField(
'Ingredient',
through='IngredientWithAmount'
)
def __str__(self):
return "{}".format(self.name)
class Ingredient(models.Model):
name = models.CharField(max_length=256)
def __str__(self):
return "{}".format(self.name)
class IngredientWithAmount(models.Model):
""" A recipe-ingredient relation associated with amounts
"""
recipe = models.ForeignKey(
'Recipe',
models.CASCADE)
ingredient = models.ForeignKey(
'Ingredient',
models.CASCADE )
amount = models.DecimalField(
max_digits=6,
decimal_places=2,
)
unit = models.CharField(
max_length=2,
choices=(
('gr', 'Grammes'),
('u', 'Units'),
('l', 'Litres'),
))
def __repr__(self):
return "<{}>".format(self.display())
def display(self):
""" Print info in a human friendly way.
* Correct grammar
* Apropriate units according to amount
"""
# Note: amount has a .is_integer() method :)
amount = self.amount
name = self.ingredient.name.lower()
particule = ""
unit = ""
if self.unit == 'u':
integer = int(amount)
fraction = amount - integer
# Print subfractions of one as ratio
if fraction: #TODO: if is not integer...
fraction = "{}/{}".format(
*amount.as_integer_ratio()
)
else:
fraction = ""
if integer:
# Plural
if integer >= 2:
name += "s"
integer = str(integer)
else:
integer = ""
amount = "{}{}".format(integer, fraction)
else:
particule = "de "
unit = self.unit
return "{}{} {}{}".format(
amount, unit, particule, name
)

View File

@@ -0,0 +1,56 @@
from rest_framework import serializers as ser
from .models import Recipe, Ingredient, IngredientWithAmount
class RecipeSerializer(ser.ModelSerializer):
class Meta:
model = Recipe
fields = ('id', 'name', 'category', 'ingredients')
class IngredientSerializer(ser.ModelSerializer):
class Meta:
model = Ingredient
fields = ('id', 'name')
class IngredientWithAmountSerializer(ser.ModelSerializer):
ingredient = IngredientSerializer()
class Meta:
model = IngredientWithAmount
fields = ('recipe', 'ingredient', 'amount', 'unit', 'display')
extra_kwargs = {
'recipe': { 'write_only': True },
}
def create(self, validated_data):
# TODO: better management of ingredient finding
ingredient, created = Ingredient.objects.get_or_create(
**validated_data.pop('ingredient')
)
if created:
ingredient.save()
validated_data['ingredient'] = ingredient
return IngredientWithAmount.objects.create(
**validated_data
)
def update(self, instance, validated_data):
for attr in ('recipe', 'amount', 'unit'):
setattr(
instance,
attr,
validated_data.get(attr,
getattr(instance, attr))
)
if 'ingredient' in validated_data:
ingdt, created = Ingredient.objects.get_or_create(
**validated_data['ingredient']
)
if created:
ingdt.save()
instance.ingredient = ingdt
instance.save()
return instance

3
recipe_book/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

33
recipe_book/views.py Normal file
View File

@@ -0,0 +1,33 @@
from django.shortcuts import render
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from recipe_book.serializers import (
RecipeSerializer,
IngredientSerializer,
IngredientWithAmountSerializer,
)
from recipe_book.models import (
Recipe,
Ingredient,
IngredientWithAmount,
)
class RecipesViewSet(viewsets.ModelViewSet):
queryset = Recipe.objects.all()
serializer_class = RecipeSerializer
class IngredientViewSet(viewsets.ModelViewSet):
queryset = Ingredient.objects.all()
serializer_class = IngredientSerializer
class IngredientWAmountViewSet(viewsets.ModelViewSet):
serializer_class = IngredientWithAmountSerializer
lookup_field = 'ingredient'
def get_queryset(self):
return IngredientWithAmount.objects.filter(
recipe=self.kwargs['recipe_pk']
)

View File

@@ -1,2 +1,4 @@
django==2.2 django==2.2
django-webpack-loader==0.6 django-webpack-loader==0.6
djangorestframework
drf-nested-routers

View File

@@ -11,7 +11,7 @@
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons">
</head> </head>
<body> <body class="overflow-hidden">
<noscript> <noscript>
<strong>We're sorry but frontend doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> <strong>We're sorry but frontend doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript> </noscript>