Add a form to manually add a recipe

This commit is contained in:
Lucas Verney 2018-03-10 11:43:14 +01:00
parent e128171ddc
commit f60b59d789
9 changed files with 190 additions and 70 deletions

View File

@ -56,21 +56,31 @@ class Recipe(Model):
class Meta: class Meta:
database = database database = database
def update_from_weboob(self, weboob_obj): def update_from_dict(self, d):
""" """
Update fields taking values from the Weboob object. Update field taking values from a dict of values.
""" """
# Set fields # Set fields
for field in ['title', 'url', 'author', 'picture_url', for field in ['title', 'url', 'author', 'picture_url',
'short_description', 'preparation_time', 'cooking_time', 'short_description', 'preparation_time', 'cooking_time',
'ingredients', 'instructions']: 'ingredients', 'instructions', 'nb_person']:
value = getattr(weboob_obj, field) value = d.get(field, None)
if value: if value:
setattr(self, field, value) setattr(self, field, value)
# Serialize number of person
self.nb_person = '-'.join(str(num) for num in weboob_obj.nb_person)
# Download picture and save it as a blob # Download picture and save it as a blob
self.picture = requests.get(weboob_obj.picture_url).content if d.get('picture_url', None):
self.picture = requests.get(d['picture_url']).content
def update_from_weboob(self, weboob_obj):
"""
Update fields taking values from the Weboob object.
"""
weboob_dict = dict(weboob_obj.iter_fields())
if weboob_dict.get('nb_person', None):
weboob_dict['nb_person'] = '-'.join(
str(num) for num in weboob_dict['nb_person']
)
self.update_from_dict(weboob_dict)
def to_dict(self): def to_dict(self):
""" """

View File

@ -28,4 +28,9 @@ export default {
color: rgba(0,0,0,.87); color: rgba(0,0,0,.87);
text-decoration: none; text-decoration: none;
} }
.panel {
max-width: 600px;
}
</style> </style>

View File

@ -16,7 +16,7 @@ function _postProcessRecipes(response) {
parsed.recipes = parsed.recipes.map(item => Object.assign( parsed.recipes = parsed.recipes.map(item => Object.assign(
item, item,
{ {
instructions: item.instructions.split(/\r\n/).map( instructions: item.instructions.split(/[\r\n]\n/).map(
line => line.trim(), line => line.trim(),
), ),
}, },
@ -48,8 +48,17 @@ export function refetchRecipe(id) {
} }
export function postRecipe(recipe) { export function postRecipeByUrl(recipe) {
return fetch(`${constants.API_URL}api/v1/recipes`, { return fetch(`${constants.API_URL}api/v1/recipes/by_url`, {
method: 'POST',
body: JSON.stringify(recipe),
})
.then(_postProcessRecipes);
}
export function postRecipeManually(recipe) {
return fetch(`${constants.API_URL}api/v1/recipes/manually`, {
method: 'POST', method: 'POST',
body: JSON.stringify(recipe), body: JSON.stringify(recipe),
}) })

View File

@ -12,7 +12,7 @@
:key="recipe.title" :key="recipe.title"
xs12 sm6 md3 lg2> xs12 sm6 md3 lg2>
<v-card :to="{name: 'Recipe', params: { recipeId: recipe.id }}"> <v-card :to="{name: 'Recipe', params: { recipeId: recipe.id }}">
<v-card-media :src="recipe.picture" height="200px"></v-card-media> <v-card-media :src="recipe.picture" height="200px" class="grey"></v-card-media>
<v-card-title primary-title> <v-card-title primary-title>
<div> <div>
<h3 class="headline mb-0">{{ recipe.title }}</h3> <h3 class="headline mb-0">{{ recipe.title }}</h3>
@ -21,10 +21,10 @@
<p>{{ recipe.short_description }}</p> <p>{{ recipe.short_description }}</p>
<v-layout row text-xs-center> <v-layout row text-xs-center>
<v-flex xs6> <v-flex xs6>
<p><v-icon>timelapse</v-icon> {{ $tc('misc.Nmins', recipe.preparation_time, { count: recipe.preparation_time }) }}</p> <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 }) }}</p> <p><v-icon>whatshot</v-icon> {{ $tc('misc.Nmins', recipe.cooking_time, { count: recipe.cooking_time ? recipe.cooking_time : '?' }) }}</p>
</v-flex> </v-flex>
</v-layout> </v-layout>
</v-card> </v-card>

View File

@ -8,55 +8,50 @@
</v-flex> </v-flex>
</v-layout> </v-layout>
</v-container> </v-container>
<v-container text-xs-center v-else> <v-container text-xs-center v-else class="panel">
<v-layout row wrap> <v-layout row wrap>
<ErrorDialog v-model="error" :description="$t('error.unable_import_recipe')" /> <ErrorDialog v-model="error" :description="$t('error.unable_import_recipe')" />
<v-flex xs12> <v-flex xs12>
<h2>{{ $t('new.import_from_url') }}</h2> <h2>{{ $t('new.import_from_url') }}</h2>
<v-form v-model="validImport"> <v-form v-model="isValidImport">
<v-text-field <v-text-field
label="URL" label="URL"
v-model="url" v-model="url"
required required
:rules="urlRules" :rules="requiredUrlRules"
></v-text-field> ></v-text-field>
<v-btn <v-btn
@click="submitImport" @click="submitImport"
:disabled="!validImport || isImporting" :disabled="!isValidImport || isImporting"
> >
{{ $t('new.import') }} {{ $t('new.import') }}
</v-btn> </v-btn>
</v-form> </v-form>
</v-flex> </v-flex>
</v-layout> </v-layout>
<v-layout row wrap mt-5 v-if="featureAddManually"> <v-layout row wrap mt-5>
<v-flex xs12> <v-flex xs12>
<h2>{{ $t('new.add_manually') }}</h2> <h2>{{ $t('new.add_manually') }}</h2>
<v-form v-model="validAdd"> <v-form v-model="isValidAdd">
<v-text-field <v-text-field
:label="$t('new.title')" :label="$t('new.title')"
v-model="title" v-model="title"
required required
:rules="[v => !!v || $t('new.title_is_required')]"
></v-text-field> ></v-text-field>
<v-text-field <v-text-field
:label="$t('new.picture')" :label="$t('new.picture_url')"
v-model="picture" v-model="picture_url"
:rules="urlRules"
></v-text-field> ></v-text-field>
<v-text-field <v-text-field
:label="$t('new.short_description')" :label="$t('new.short_description')"
v-model="short_description" v-model="short_description"
textarea textarea
></v-text-field> ></v-text-field>
<v-layout row> <v-layout row wrap>
<v-flex xs4 mr-3> <v-flex xs12 md5>
<v-text-field
:label="$t('new.nb_persons')"
v-model="nb_person"
type="number"
></v-text-field>
</v-flex>
<v-flex xs4 mx-3>
<v-text-field <v-text-field
:label="$t('new.preparation_time')" :label="$t('new.preparation_time')"
v-model="preparation_time" v-model="preparation_time"
@ -64,7 +59,7 @@
:suffix="$t('new.mins')" :suffix="$t('new.mins')"
></v-text-field> ></v-text-field>
</v-flex> </v-flex>
<v-flex xs4 ml-3> <v-flex xs12 md5 offset-md2>
<v-text-field <v-text-field
:label="$t('new.cooking_time')" :label="$t('new.cooking_time')"
v-model="cooking_time" v-model="cooking_time"
@ -74,20 +69,43 @@
</v-flex> </v-flex>
</v-layout> </v-layout>
<v-text-field <v-text-field
:label="$t('new.ingredients')" :label="$t('new.nb_persons')"
v-model="ingredients" v-model="nb_person"
textarea
></v-text-field> ></v-text-field>
<v-layout row>
<v-flex xs12 class="text-xs-left">
<h3>{{ $t('new.ingredients') }}</h3>
<v-list v-if="ingredients.length" class="transparent">
<v-list-tile v-for="ingredient in ingredients" :key="ingredient">
<v-list-tile-action>
<v-btn flat icon color="red" v-on:click="() => removeIngredient(ingredient)">
<v-icon>delete</v-icon>
</v-btn>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>{{ ingredient }}</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list>
<p class="ml-5 my-3" v-else>{{ $t('new.none') }}</p>
<v-text-field
:label="$t('new.add_ingredient')"
v-model="new_ingredient"
@keyup.enter.native="addIngredient"
></v-text-field>
</v-flex>
</v-layout>
<v-text-field <v-text-field
:label="$t('new.instructions')" :label="$t('new.instructions')"
v-model="instructions" v-model="instructions"
:rules="[v => !!v || $t('new.instructions_are_required')]"
textarea textarea
required required
></v-text-field> ></v-text-field>
<v-btn <v-btn
@click="submitAdd" @click="submitAdd"
:disabled="!validAdd" :disabled="!isValidAdd || isImporting"
> >
{{ $t('new.add') }} {{ $t('new.add') }}
</v-btn> </v-btn>
@ -110,38 +128,56 @@ export default {
Loader, Loader,
}, },
data() { data() {
const urlRules = [
(v) => {
if (!v) {
return true;
}
try {
new URL(v); // eslint-disable-line no-new
return true;
} catch (e) {
return $t('new.url_must_be_valid');
}
},
];
return { return {
error: null, error: null,
url: null, url: null,
validImport: false, isValidImport: false,
isImporting: false, isImporting: false,
validAdd: false, isValidAdd: false,
title: null, title: null,
picture: null, picture_url: null,
short_description: null, short_description: null,
nb_person: null, nb_person: null,
preparation_time: null, preparation_time: null,
cooking_time: null, cooking_time: null,
ingredients: null, new_ingredient: null,
ingredients: [],
instructions: null, instructions: null,
urlRules: [ requiredUrlRules: Array.concat(
v => !!v || 'URL is required', [],
(v) => { [v => !!v || $t('new.url_is_required')],
try { urlRules,
new URL(v); // eslint-disable-line no-new ),
return true; urlRules,
} catch (e) {
return 'URL must be valid';
}
},
],
featureAddManually: false,
}; };
}, },
methods: { methods: {
addIngredient() {
this.ingredients.push(this.new_ingredient);
this.new_ingredient = null;
},
removeIngredient(ingredient) {
const index = this.ingredients.indexOf(ingredient);
if (index !== -1) {
this.ingredients.splice(index, 1);
}
},
submitImport() { submitImport() {
this.isImporting = true; this.isImporting = true;
api.postRecipe({ url: this.url }) api.postRecipeByUrl({ url: this.url })
.then(response => this.$router.push({ .then(response => this.$router.push({
name: 'Recipe', name: 'Recipe',
params: { params: {
@ -154,8 +190,34 @@ export default {
}); });
}, },
submitAdd() { submitAdd() {
// TODO this.isImporting = true;
api.postRecipeManually({
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>
<style scoped>
.transparent {
background: transparent;
}
</style>

View File

@ -46,23 +46,26 @@
<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 }) }}</p> <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>whatshot</v-icon> {{ $t('recipe.cooking') }} {{ $tc('misc.Nmins', recipe.cooking_time, { count: recipe.cooking_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>
</v-flex> </v-flex>
</v-layout> </v-layout>
<p>{{ recipe.short_description }}</p> <p>{{ recipe.short_description }}</p>
<h2>{{ $t('recipe.ingredients') }}</h2> <h2>{{ $t('recipe.ingredients') }}</h2>
<ul class="ml-5"> <ul class="ml-5" v-if="recipe.ingredients && recipe.ingredients.length">
<li v-for="ingredient in recipe.ingredients"> <li v-for="ingredient in recipe.ingredients">
{{ ingredient }} {{ ingredient }}
</li> </li>
</ul> </ul>
<p class="ml-5 my-3" v-else>{{ $t('new.none') }}</p>
<h2 class="mt-3">{{ $t('recipe.instructions') }}</h2> <h2 class="mt-3">{{ $t('recipe.instructions') }}</h2>
<p v-for="item in recipe.instructions"> <p v-for="item in recipe.instructions">
{{ item }} {{ item }}
</p> </p>
<p v-if="recipe.url" class="text-xs-center"> <p class="text-xs-center">
<v-btn :href="recipe.url" :title="$t('recipe.website')"> <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 @click.stop="deleteConfirm = true" :title="$t('recipe.delete')"> <v-btn @click.stop="deleteConfirm = true" :title="$t('recipe.delete')">
@ -156,10 +159,6 @@ img {
width: 100%; width: 100%;
} }
.panel {
max-width: 600px;
}
.fa-icon { .fa-icon {
font-size: 20px; font-size: 20px;
} }

View File

@ -13,19 +13,25 @@ export default {
}, },
new: { new: {
add: 'Add', add: 'Add',
add_ingredient: 'Add ingredient',
add_manually: 'Add manually', add_manually: 'Add manually',
cooking_time: 'Cooking time', cooking_time: 'Cooking time',
import: 'Import', import: 'Import',
import_from_url: 'Import from URL', import_from_url: 'Import from URL',
importing: 'Importing…', importing: 'Importing…',
ingredients: 'Ingredients', ingredients: 'Ingredients:',
instructions: 'Instructions', instructions: 'Instructions',
instructions_are_required: 'Instructions are required',
mins: 'mins', mins: 'mins',
nb_opersons: 'Number of persons', nb_persons: 'Serves',
picture: 'Picture', none: 'None',
picture_url: 'Picture URL',
preparation_time: 'Preparation time', preparation_time: 'Preparation time',
short_description: 'Short description', short_description: 'Short description',
title: 'Title', title: 'Title',
title_is_required: 'Title is required',
url_is_required: 'URL is required',
url_must_be_valid: 'URL must be a valid one',
}, },
recipe: { recipe: {
cooking: 'Cooking:', cooking: 'Cooking:',
@ -34,6 +40,7 @@ export default {
delete_recipe_description: 'This will delete this recipe. Are you sure?', delete_recipe_description: 'This will delete this recipe. Are you sure?',
ingredients: 'Ingredients', ingredients: 'Ingredients',
instructions: 'Instructions', instructions: 'Instructions',
none: 'Aucun',
preparation: 'Preparation:', preparation: 'Preparation:',
refetch: 'Refetch', refetch: 'Refetch',
refetch_recipe: 'Refetch recipe', refetch_recipe: 'Refetch recipe',

View File

@ -13,19 +13,25 @@ export default {
}, },
new: { new: {
add: 'Ajouter', add: 'Ajouter',
add_ingredient: 'Ajouter un ingrédient',
add_manually: 'Ajouter manuellement', add_manually: 'Ajouter manuellement',
cooking_time: 'Temps de cuisson', cooking_time: 'Temps de cuisson',
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…',
ingredients: 'Ingrédients', ingredients: 'Ingrédients :',
instructions: 'Instructions', instructions: 'Instructions',
instructions_are_required: 'Des instructions sont requises',
mins: 'mins', mins: 'mins',
nb_opersons: 'Nombre de personnes', nb_persons: 'Nombre de personnes / parts',
picture: 'Photo', none: 'Aucun',
picture_url: 'URL d\'une photo',
preparation_time: 'Temps de préparation', preparation_time: 'Temps de préparation',
short_description: 'Description brève', short_description: 'Description brève',
title: 'Titre', title: 'Titre',
title_is_required: 'Un titre est requis',
url_is_required: 'Une URL est requise',
url_must_be_valid: 'L\'URL est invalide',
}, },
recipe: { recipe: {
cooking: 'Cuisson :', cooking: 'Cuisson :',
@ -34,6 +40,7 @@ export default {
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 ?',
ingredients: 'Ingrédients', ingredients: 'Ingrédients',
instructions: 'Instructions', instructions: 'Instructions',
none: 'Aucun',
preparation: 'Préparation :', preparation: 'Préparation :',
refetch: 'Réactualiser', refetch: 'Réactualiser',
refetch_recipe: 'Réactualiser la recette', refetch_recipe: 'Réactualiser la recette',

View File

@ -56,8 +56,8 @@ def api_v1_recipes():
} }
@app.post('/api/v1/recipes') @app.post('/api/v1/recipes/by_url')
def api_v1_recipes_post(): def api_v1_recipes_post_by_url():
""" """
Create a new recipe from URL Create a new recipe from URL
""" """
@ -80,6 +80,27 @@ def api_v1_recipes_post():
bottle.redirect('/api/v1/recipe/%s' % recipe.id, 301) bottle.redirect('/api/v1/recipe/%s' % recipe.id, 301)
@app.post('/api/v1/recipes/manually')
def api_v1_recipes_post_manual():
"""
Create a new recipe manually
"""
data = json.loads(bottle.request.body.read().decode('utf-8'))
try:
# Try to add
recipe = db.Recipe()
recipe.update_from_dict(data)
recipe.save()
return {
'recipes': [recipe.to_dict()]
}
except peewee.IntegrityError:
return {
'error': 'Duplicate recipe.'
}
@app.route('/api/v1/recipe/:id', ['GET', 'OPTIONS']) @app.route('/api/v1/recipe/:id', ['GET', 'OPTIONS'])
def api_v1_recipe(id): def api_v1_recipe(id):
""" """