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:
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
for field in ['title', 'url', 'author', 'picture_url',
'short_description', 'preparation_time', 'cooking_time',
'ingredients', 'instructions']:
value = getattr(weboob_obj, field)
'ingredients', 'instructions', 'nb_person']:
value = d.get(field, None)
if 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
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):
"""

View File

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

View File

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

View File

@ -12,7 +12,7 @@
:key="recipe.title"
xs12 sm6 md3 lg2>
<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>
<div>
<h3 class="headline mb-0">{{ recipe.title }}</h3>
@ -21,10 +21,10 @@
<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 }) }}</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 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-layout>
</v-card>

View File

@ -8,55 +8,50 @@
</v-flex>
</v-layout>
</v-container>
<v-container text-xs-center v-else>
<v-container text-xs-center v-else class="panel">
<v-layout row wrap>
<ErrorDialog v-model="error" :description="$t('error.unable_import_recipe')" />
<v-flex xs12>
<h2>{{ $t('new.import_from_url') }}</h2>
<v-form v-model="validImport">
<v-form v-model="isValidImport">
<v-text-field
label="URL"
v-model="url"
required
:rules="urlRules"
:rules="requiredUrlRules"
></v-text-field>
<v-btn
@click="submitImport"
:disabled="!validImport || isImporting"
:disabled="!isValidImport || isImporting"
>
{{ $t('new.import') }}
</v-btn>
</v-form>
</v-flex>
</v-layout>
<v-layout row wrap mt-5 v-if="featureAddManually">
<v-layout row wrap mt-5>
<v-flex xs12>
<h2>{{ $t('new.add_manually') }}</h2>
<v-form v-model="validAdd">
<v-form v-model="isValidAdd">
<v-text-field
:label="$t('new.title')"
v-model="title"
required
:rules="[v => !!v || $t('new.title_is_required')]"
></v-text-field>
<v-text-field
:label="$t('new.picture')"
v-model="picture"
:label="$t('new.picture_url')"
v-model="picture_url"
:rules="urlRules"
></v-text-field>
<v-text-field
:label="$t('new.short_description')"
v-model="short_description"
textarea
></v-text-field>
<v-layout row>
<v-flex xs4 mr-3>
<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-layout row wrap>
<v-flex xs12 md5>
<v-text-field
:label="$t('new.preparation_time')"
v-model="preparation_time"
@ -64,7 +59,7 @@
:suffix="$t('new.mins')"
></v-text-field>
</v-flex>
<v-flex xs4 ml-3>
<v-flex xs12 md5 offset-md2>
<v-text-field
:label="$t('new.cooking_time')"
v-model="cooking_time"
@ -74,20 +69,43 @@
</v-flex>
</v-layout>
<v-text-field
:label="$t('new.ingredients')"
v-model="ingredients"
textarea
:label="$t('new.nb_persons')"
v-model="nb_person"
></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
:label="$t('new.instructions')"
v-model="instructions"
:rules="[v => !!v || $t('new.instructions_are_required')]"
textarea
required
></v-text-field>
<v-btn
@click="submitAdd"
:disabled="!validAdd"
:disabled="!isValidAdd || isImporting"
>
{{ $t('new.add') }}
</v-btn>
@ -110,38 +128,56 @@ export default {
Loader,
},
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 {
error: null,
url: null,
validImport: false,
isValidImport: false,
isImporting: false,
validAdd: false,
isValidAdd: false,
title: null,
picture: null,
picture_url: null,
short_description: null,
nb_person: null,
preparation_time: null,
cooking_time: null,
ingredients: null,
new_ingredient: null,
ingredients: [],
instructions: null,
urlRules: [
v => !!v || 'URL is required',
(v) => {
try {
new URL(v); // eslint-disable-line no-new
return true;
} catch (e) {
return 'URL must be valid';
}
},
],
featureAddManually: false,
requiredUrlRules: Array.concat(
[],
[v => !!v || $t('new.url_is_required')],
urlRules,
),
urlRules,
};
},
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() {
this.isImporting = true;
api.postRecipe({ url: this.url })
api.postRecipeByUrl({ url: this.url })
.then(response => this.$router.push({
name: 'Recipe',
params: {
@ -154,8 +190,34 @@ export default {
});
},
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>
<style scoped>
.transparent {
background: transparent;
}
</style>

View File

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

View File

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

View File

@ -13,19 +13,25 @@ export default {
},
new: {
add: 'Ajouter',
add_ingredient: 'Ajouter un ingrédient',
add_manually: 'Ajouter manuellement',
cooking_time: 'Temps de cuisson',
import: 'Importer',
import_from_url: 'Importer depuis l\'URL',
importing: 'En cours d\'import…',
ingredients: 'Ingrédients',
ingredients: 'Ingrédients :',
instructions: 'Instructions',
instructions_are_required: 'Des instructions sont requises',
mins: 'mins',
nb_opersons: 'Nombre de personnes',
picture: 'Photo',
nb_persons: 'Nombre de personnes / parts',
none: 'Aucun',
picture_url: 'URL d\'une photo',
preparation_time: 'Temps de préparation',
short_description: 'Description brève',
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: {
cooking: 'Cuisson :',
@ -34,6 +40,7 @@ export default {
delete_recipe_description: 'Vous allez supprimer cette recette. En êtes-vous sûr ?',
ingredients: 'Ingrédients',
instructions: 'Instructions',
none: 'Aucun',
preparation: 'Préparation :',
refetch: 'Réactualiser',
refetch_recipe: 'Réactualiser la recette',

View File

@ -56,8 +56,8 @@ def api_v1_recipes():
}
@app.post('/api/v1/recipes')
def api_v1_recipes_post():
@app.post('/api/v1/recipes/by_url')
def api_v1_recipes_post_by_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)
@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'])
def api_v1_recipe(id):
"""