Allow to edit a recipe

This commit is contained in:
Lucas Verney 2018-03-11 16:49:05 +01:00
parent da844e0a08
commit d30e5f9053
10 changed files with 245 additions and 37 deletions

View File

@ -40,7 +40,7 @@ class Recipe(Model):
# Author # Author
author = CharField(null=True) author = CharField(null=True)
# Picture as a binary blob # Picture as a binary blob
picture = BlobField(null=True) picture = TextField(null=True)
# Short description # Short description
short_description = TextField(null=True) short_description = TextField(null=True)
# Number of persons as text, as it can be either "N persons" or "N parts" # Number of persons as text, as it can be either "N persons" or "N parts"
@ -69,7 +69,18 @@ class Recipe(Model):
setattr(self, field, value) setattr(self, field, value)
# Download picture and save it as a blob # Download picture and save it as a blob
if d.get('picture_url', None): if d.get('picture_url', None):
self.picture = requests.get(d['picture_url']).content try:
picture = requests.get(d['picture_url']).content
picture_mime = (
'data:%s;base64' % magic.from_buffer(picture,
mime=True)
)
self.picture = '%s,%s' % (
picture_mime,
base64.b64encode(picture).decode('utf-8')
)
except requests.exceptions.InvalidSchema:
self.picture = d['picture_url']
def update_from_weboob(self, weboob_obj): def update_from_weboob(self, weboob_obj):
""" """
@ -86,16 +97,4 @@ class Recipe(Model):
""" """
Dict conversion function, for serialization in the API. Dict conversion function, for serialization in the API.
""" """
serialized = model_to_dict(self) return model_to_dict(self)
# Dump picture as a base64 string, compatible with HTML `src` attribute
# for images.
if serialized['picture']:
picture_mime = (
'data:%s;base64' % magic.from_buffer(serialized['picture'],
mime=True)
)
serialized['picture'] = '%s,%s' % (
picture_mime,
base64.b64encode(serialized['picture']).decode('utf-8')
)
return serialized

View File

@ -66,6 +66,15 @@ export function postRecipeManually(recipe) {
} }
export function editRecipe(id, recipe) {
return fetch(`${constants.API_URL}api/v1/recipe/${id}`, {
method: 'POST',
body: JSON.stringify(recipe),
})
.then(_postProcessRecipes);
}
export function deleteRecipe(id) { export function deleteRecipe(id) {
return fetch(`${constants.API_URL}api/v1/recipe/${id}`, { return fetch(`${constants.API_URL}api/v1/recipe/${id}`, {
method: 'DELETE', method: 'DELETE',

View File

@ -1,7 +1,11 @@
<template> <template>
<v-flex xs12> <v-flex xs12>
<h2>{{ $t('new.add_manually') }}</h2> <ErrorDialog :v-model="error" :description="$t('error.title')" />
<v-form v-model="isValidAdd">
<h2 v-if="recipe">{{ $t('new.edit_recipe') }}</h2>
<h2 v-else>{{ $t('new.add_manually') }}</h2>
<v-form v-model="isValidForm">
<v-text-field <v-text-field
:label="$t('new.title')" :label="$t('new.title')"
v-model="title" v-model="title"
@ -13,6 +17,9 @@
v-model="picture_url" v-model="picture_url"
:rules="urlRules" :rules="urlRules"
></v-text-field> ></v-text-field>
<p>
<img :src="picture_url" />
</p>
<v-text-field <v-text-field
:label="$t('new.short_description')" :label="$t('new.short_description')"
v-model="short_description" v-model="short_description"
@ -71,9 +78,17 @@
required required
></v-text-field> ></v-text-field>
<v-btn
@click="submitEdit"
:disabled="!isValidForm || isImporting"
v-if="recipe"
>
{{ $t('new.edit') }}
</v-btn>
<v-btn <v-btn
@click="submitAdd" @click="submitAdd"
:disabled="!isValidAdd || isImporting" :disabled="!isValidForm || isImporting"
v-else
> >
{{ $t('new.add') }} {{ $t('new.add') }}
</v-btn> </v-btn>
@ -85,8 +100,17 @@
import * as api from '@/api'; import * as api from '@/api';
import * as rules from '@/rules'; import * as rules from '@/rules';
import ErrorDialog from '@/components/ErrorDialog';
export default { export default {
components: {
ErrorDialog,
},
props: { props: {
recipe: {
default: null,
type: Object,
},
value: Boolean, value: Boolean,
}, },
computed: { computed: {
@ -100,18 +124,31 @@ export default {
}, },
}, },
data() { data() {
let defaultPreparationTime = null;
if (this.recipe &&
this.recipe.preparation_time !== null && this.recipe.preparation_time !== undefined
) {
defaultPreparationTime = this.recipe.preparation_time;
}
let defaultCookingTime = null;
if (this.recipe &&
this.recipe.cooking_time !== null && this.recipe.cooking_time !== undefined
) {
defaultCookingTime = this.recipe.cooking_time;
}
return { return {
error: null,
url: null, url: null,
isValidAdd: false, isValidForm: false,
title: null, title: (this.recipe && this.recipe.title) || null,
picture_url: null, picture_url: (this.recipe && this.recipe.picture) || null,
short_description: null, short_description: (this.recipe && this.recipe.short_description) || null,
nb_person: null, nb_person: (this.recipe && this.recipe.nb_person) || null,
preparation_time: null, preparation_time: defaultPreparationTime,
cooking_time: null, cooking_time: defaultCookingTime,
new_ingredient: null, new_ingredient: null,
ingredients: [], ingredients: (this.recipe && this.recipe.ingredients) || [],
instructions: null, instructions: (this.recipe && this.recipe.instructions.join('\n\n').replace(/\n{2,}/, '\n\n')) || null,
urlRules: rules.url, urlRules: rules.url,
}; };
}, },
@ -149,6 +186,29 @@ export default {
this.error = error; this.error = error;
}); });
}, },
submitEdit() {
this.isImporting = true;
api.editRecipe(this.recipe.id, {
title: this.title,
picture_url: this.picture_url,
short_description: this.short_description,
preparation_time: this.preparation_time,
cooking_time: this.cooking_time,
nb_person: this.nb_person,
ingredients: this.ingredients,
instructions: this.instructions,
})
.then(response => this.$router.push({
name: 'Recipe',
params: {
recipeId: response.recipes[0].id,
},
}))
.catch((error) => {
this.isImporting = false;
this.error = error;
});
},
}, },
}; };
</script> </script>
@ -157,4 +217,8 @@ export default {
.transparent { .transparent {
background: transparent; background: transparent;
} }
img {
max-height: 150px;
}
</style> </style>

View File

@ -1,8 +1,11 @@
export default { export default {
error: { error: {
title: 'Error', title: 'Error',
unable_delete_recipe: 'Unable to delete recipe: ',
unable_fetch_recipe: 'Unable to fetch recipe: ',
unable_import_recipe: 'Unable to import recipe: ', unable_import_recipe: 'Unable to import recipe: ',
unable_load_recipes: 'Unable to load recipes: ', unable_load_recipes: 'Unable to load recipes: ',
unable_refetch_recipe: 'Unable to refetch recipe: ',
}, },
home: { home: {
onboarding: 'Start by adding a recipe with the "+" button on the top right corner!', onboarding: 'Start by adding a recipe with the "+" button on the top right corner!',
@ -16,6 +19,8 @@ export default {
add_ingredient: 'Add ingredient', add_ingredient: 'Add ingredient',
add_manually: 'Add manually', add_manually: 'Add manually',
cooking_time: 'Cooking time', cooking_time: 'Cooking time',
edit: 'Edit',
edit_recipe: 'Edit recipe',
import: 'Import', import: 'Import',
import_from_url: 'Import from URL', import_from_url: 'Import from URL',
importing: 'Importing…', importing: 'Importing…',
@ -30,6 +35,7 @@ export default {
short_description: 'Short description', short_description: 'Short description',
title: 'Title', title: 'Title',
title_is_required: 'Title is required', title_is_required: 'Title is required',
updating: 'Updating…',
url_is_required: 'URL is required', url_is_required: 'URL is required',
url_must_be_valid: 'URL must be a valid one', url_must_be_valid: 'URL must be a valid one',
}, },
@ -38,6 +44,7 @@ export default {
delete: 'Delete', delete: 'Delete',
delete_recipe: 'Delete recipe', delete_recipe: 'Delete recipe',
delete_recipe_description: 'This will delete this recipe. Are you sure?', delete_recipe_description: 'This will delete this recipe. Are you sure?',
edit: 'Edit',
ingredients: 'Ingredients', ingredients: 'Ingredients',
instructions: 'Instructions', instructions: 'Instructions',
none: 'Aucun', none: 'Aucun',

View File

@ -1,8 +1,11 @@
export default { export default {
error: { error: {
title: 'Erreur', title: 'Erreur',
unable_delete_recipe: 'Impossible de supprimer la recette : ',
unable_fetch_recipe: 'Impossible de récupérer la recette : ',
unable_import_recipe: 'Impossible d\'importer la recette : ', unable_import_recipe: 'Impossible d\'importer la recette : ',
unable_load_recipes: 'Impossible de charger les recettes : ', unable_load_recipes: 'Impossible de charger les recettes : ',
unable_refetch_recipe: 'Impossible de réactualiser la recette : ',
}, },
home: { home: {
onboarding: 'Commencez par ajouter une recette avec le bouton "+" dans le coin supérieur droit !', onboarding: 'Commencez par ajouter une recette avec le bouton "+" dans le coin supérieur droit !',
@ -16,6 +19,8 @@ export default {
add_ingredient: 'Ajouter un ingrédient', add_ingredient: 'Ajouter un ingrédient',
add_manually: 'Ajouter manuellement', add_manually: 'Ajouter manuellement',
cooking_time: 'Temps de cuisson', cooking_time: 'Temps de cuisson',
edit: 'Modifier',
edit_recipe: 'Modifier une recette',
import: 'Importer', import: 'Importer',
import_from_url: 'Importer depuis l\'URL', import_from_url: 'Importer depuis l\'URL',
importing: 'En cours d\'import…', importing: 'En cours d\'import…',
@ -30,6 +35,7 @@ export default {
short_description: 'Description brève', short_description: 'Description brève',
title: 'Titre', title: 'Titre',
title_is_required: 'Un titre est requis', title_is_required: 'Un titre est requis',
updating: 'En cours de mise à jour…',
url_is_required: 'Une URL est requise', url_is_required: 'Une URL est requise',
url_must_be_valid: 'L\'URL est invalide', url_must_be_valid: 'L\'URL est invalide',
}, },
@ -38,6 +44,7 @@ export default {
delete: 'Supprimer', delete: 'Supprimer',
delete_recipe: 'Suppression d\'une recette', delete_recipe: 'Suppression d\'une recette',
delete_recipe_description: 'Vous allez supprimer cette recette. En êtes-vous sûr ?', delete_recipe_description: 'Vous allez supprimer cette recette. En êtes-vous sûr ?',
edit: 'Modifier',
ingredients: 'Ingrédients', ingredients: 'Ingrédients',
instructions: 'Instructions', instructions: 'Instructions',
none: 'Aucun', none: 'Aucun',

View File

@ -1,6 +1,8 @@
import Vue from 'vue'; import Vue from 'vue';
import Router from 'vue-router'; import Router from 'vue-router';
import Home from '@/views/Home'; import Home from '@/views/Home';
import Edit from '@/views/Edit';
import New from '@/views/New'; import New from '@/views/New';
import Recipe from '@/views/Recipe'; import Recipe from '@/views/Recipe';
@ -23,5 +25,10 @@ export default new Router({
name: 'Recipe', name: 'Recipe',
component: Recipe, component: Recipe,
}, },
{
path: '/edit/:recipeId',
name: 'Edit',
component: Edit,
},
], ],
}); });

View File

@ -0,0 +1,72 @@
<template>
<v-container text-xs-center v-if="isLoading">
<v-layout row>
<Loader></Loader>
</v-layout>
</v-container>
<v-container text-xs-center v-else-if="isImporting">
<v-layout row wrap>
<Loader></Loader>
<v-flex xs12>
<p>{{ $t('new.updating') }}</p>
</v-flex>
</v-layout>
</v-container>
<v-container text-xs-center v-else class="panel">
<v-layout row wrap>
<EditForm v-model="isImporting" :recipe="recipe"></EditForm>
</v-layout>
</v-container>
</template>
<script>
import * as api from '@/api';
import EditForm from '@/components/EditForm';
import Loader from '@/components/Loader';
export default {
components: {
EditForm,
Loader,
},
data() {
return {
isImporting: false,
isLoading: false,
error: null,
recipe: null,
};
},
created() {
this.loadRecipe();
},
watch: {
// call again the method if the route changes
$route: 'loadRecipe',
},
methods: {
handleRecipesResponse(response) {
if (response.recipes.length < 1) {
this.$router.replace({
name: 'Home',
});
}
this.recipe = response.recipes[0];
this.isLoading = false;
},
loadRecipe() {
this.isLoading = true;
api.loadRecipe(this.$route.params.recipeId)
.then(this.handleRecipesResponse)
.catch((error) => {
this.isLoading = false;
this.error = error;
});
},
},
};
</script>

View File

@ -18,13 +18,15 @@
<h3 class="headline mb-0">{{ recipe.title }}</h3> <h3 class="headline mb-0">{{ recipe.title }}</h3>
</div> </div>
</v-card-title> </v-card-title>
<v-layout row text-xs-center wrap>
<v-flex xs12 v-if="recipe.short_description">
<p>{{ recipe.short_description }}</p> <p>{{ recipe.short_description }}</p>
<v-layout row text-xs-center>
<v-flex xs6>
<p><v-icon>timelapse</v-icon> {{ $tc('misc.Nmins', recipe.preparation_time, { count: recipe.preparation_time ? recipe.preparation_time : '?' }) }}</p>
</v-flex> </v-flex>
<v-flex xs6> <v-flex xs6>
<p><v-icon>whatshot</v-icon> {{ $tc('misc.Nmins', recipe.cooking_time, { count: recipe.cooking_time ? recipe.cooking_time : '?' }) }}</p> <p><v-icon>timelapse</v-icon> {{ $tc('misc.Nmins', recipe.preparation_time, { count: timeOrUnknown(recipe.preparation_time) }) }}</p>
</v-flex>
<v-flex xs6>
<p><v-icon>whatshot</v-icon> {{ $tc('misc.Nmins', recipe.cooking_time, { count: timeOrUnknown(recipe.cooking_time) }) }}</p>
</v-flex> </v-flex>
</v-layout> </v-layout>
</v-card> </v-card>
@ -72,6 +74,12 @@ export default {
this.isLoading = false; this.isLoading = false;
}); });
}, },
timeOrUnknown(time) {
if (time !== null && time !== undefined) {
return time;
}
return '?';
},
}, },
}; };
</script> </script>

View File

@ -2,9 +2,9 @@
<v-container grid-list-md class="panel"> <v-container grid-list-md class="panel">
<Loader v-if="isLoading"></Loader> <Loader v-if="isLoading"></Loader>
<v-layout row v-else> <v-layout row v-else>
<ErrorDialog :v-model="errorDelete" description="Unable to delete recipe: " /> <ErrorDialog :v-model="errorDelete" :description="$t('error.unable_delete_recipe')" />
<ErrorDialog :v-model="errorFetch" description="Unable to fetch recipe: " /> <ErrorDialog :v-model="errorFetch" :description="$t('error.unable_fetch_recipe')" />
<ErrorDialog :v-model="errorRefetch" description="Unable to refetch recipe: " /> <ErrorDialog :v-model="errorRefetch" :description="$t('error.unable_refetch_recipe')" />
<v-dialog v-model="refetchConfirm" max-width="500px"> <v-dialog v-model="refetchConfirm" max-width="500px">
<v-card> <v-card>
@ -46,8 +46,8 @@
<p>{{ recipe.nb_person }}</p> <p>{{ recipe.nb_person }}</p>
</v-flex> </v-flex>
<v-flex xs6> <v-flex xs6>
<p><v-icon>timelapse</v-icon> {{ $t('recipe.preparation') }} {{ $tc('misc.Nmins', recipe.preparation_time, { count: recipe.preparation_time ? recipe.preparation_time : '?' }) }}</p> <p><v-icon>timelapse</v-icon> {{ $t('recipe.preparation') }} {{ $tc('misc.Nmins', recipe.preparation_time, { count: timeOrUnknown(recipe.preparation_time) }) }}</p>
<p><v-icon>whatshot</v-icon> {{ $t('recipe.cooking') }} {{ $tc('misc.Nmins', recipe.cooking_time, { count: recipe.cooking_time ? recipe.cooking_time : '?' }) }}</p> <p><v-icon>whatshot</v-icon> {{ $t('recipe.cooking') }} {{ $tc('misc.Nmins', recipe.cooking_time, { count: timeOrUnknown(recipe.cooking_time) }) }}</p>
</v-flex> </v-flex>
</v-layout> </v-layout>
<p>{{ recipe.short_description }}</p> <p>{{ recipe.short_description }}</p>
@ -68,6 +68,9 @@
<v-btn :href="recipe.url" :title="$t('recipe.website')" v-if="recipe.url"> <v-btn :href="recipe.url" :title="$t('recipe.website')" v-if="recipe.url">
<v-icon class="fa-icon">fa-external-link</v-icon> <v-icon class="fa-icon">fa-external-link</v-icon>
</v-btn> </v-btn>
<v-btn :to="{name: 'Edit', params: { recipeId: recipe.id }}" :title="$t('recipe.edit')">
<v-icon>edit</v-icon>
</v-btn>
<v-btn @click.stop="deleteConfirm = true" :title="$t('recipe.delete')"> <v-btn @click.stop="deleteConfirm = true" :title="$t('recipe.delete')">
<v-icon>delete</v-icon> <v-icon>delete</v-icon>
</v-btn> </v-btn>
@ -110,6 +113,12 @@ export default {
$route: 'loadRecipe', $route: 'loadRecipe',
}, },
methods: { methods: {
timeOrUnknown(time) {
if (time !== null && time !== undefined) {
return time;
}
return '?';
},
handleRecipesResponse(response) { handleRecipesResponse(response) {
if (response.recipes.length < 1) { if (response.recipes.length < 1) {
this.$router.replace({ this.$router.replace({

View File

@ -119,6 +119,32 @@ def api_v1_recipe(id):
} }
@app.route('/api/v1/recipe/:id', ['POST', 'OPTIONS'])
def api_v1_recipe_edit(id):
"""
Edit a given recipe from db
"""
# CORS
if bottle.request.method == 'OPTIONS':
return ''
data = json.loads(bottle.request.body.read().decode('utf-8'))
recipe = db.Recipe.select().where(
db.Recipe.id == id
).first()
if not recipe:
return bottle.abort(400, 'No recipe with id %s.' % id)
recipe.update_from_dict(data)
recipe.save()
return {
'recipes': [
recipe.to_dict()
]
}
@app.route('/api/v1/recipe/:id/refetch', ['GET', 'OPTIONS']) @app.route('/api/v1/recipe/:id/refetch', ['GET', 'OPTIONS'])
def api_v1_recipe_refetch(id): def api_v1_recipe_refetch(id):
""" """