Merge branch 'develop' into feature/allauth

# Conflicts:
#	requirements.txt
This commit is contained in:
vabene1111
2021-01-28 12:07:30 +01:00
31 changed files with 837 additions and 205 deletions

View File

@@ -5,7 +5,7 @@ from .models import (Comment, CookLog, Food, Ingredient, InviteLink, Keyword,
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe,
Space, Step, Storage, Sync, SyncLog, Unit, UserPreference,
ViewLog)
ViewLog, Supermarket, SupermarketCategory, SupermarketCategoryRelation)
class SpaceAdmin(admin.ModelAdmin):
@@ -43,6 +43,18 @@ class SyncAdmin(admin.ModelAdmin):
admin.site.register(Sync, SyncAdmin)
class SupermarketCategoryInline(admin.TabularInline):
model = SupermarketCategoryRelation
class SupermarketAdmin(admin.ModelAdmin):
inlines = (SupermarketCategoryInline,)
admin.site.register(Supermarket, SupermarketAdmin)
admin.site.register(SupermarketCategory)
class SyncLogAdmin(admin.ModelAdmin):
list_display = ('sync', 'status', 'msg', 'created_at')

View File

@@ -47,7 +47,7 @@ class RecipeFilter(django_filters.FilterSet):
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2': # noqa: E501
queryset = queryset \
.annotate(similarity=TrigramSimilarity('name', value), ) \
.filter(Q(similarity__gt=0.1) | Q(name__icontains=value)) \
.filter(Q(similarity__gt=0.1) | Q(name__unaccent__icontains=value)) \
.order_by('-similarity')
else:
queryset = queryset.filter(name__icontains=value)

View File

@@ -212,7 +212,7 @@ class KeywordForm(forms.ModelForm):
class FoodForm(forms.ModelForm):
class Meta:
model = Food
fields = ('name', 'recipe')
fields = ('name', 'description', 'ignore_shopping', 'recipe', 'supermarket_category')
widgets = {'recipe': SelectWidget}
@@ -238,7 +238,7 @@ class StorageForm(forms.ModelForm):
class Meta:
model = Storage
fields = ('name', 'method', 'username', 'password', 'token', 'url')
fields = ('name', 'method', 'username', 'password', 'token', 'url', 'path')
help_texts = {
'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'), # noqa: E501

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.1.5 on 2021-01-22 18:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0100_recipe_servings_text'),
]
operations = [
migrations.AddField(
model_name='storage',
name='path',
field=models.CharField(blank=True, default='', max_length=256),
),
]

View File

@@ -0,0 +1,46 @@
# Generated by Django 3.1.5 on 2021-01-25 10:47
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0101_storage_path'),
]
operations = [
migrations.CreateModel(
name='Supermarket',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128, unique=True, validators=[django.core.validators.MinLengthValidator(1)])),
('description', models.TextField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='SupermarketCategory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128, unique=True, validators=[django.core.validators.MinLengthValidator(1)])),
('description', models.TextField(blank=True, null=True)),
],
),
migrations.AddField(
model_name='food',
name='description',
field=models.TextField(blank=True, default=''),
),
migrations.AlterField(
model_name='storage',
name='method',
field=models.CharField(choices=[('DB', 'Dropbox'), ('NEXTCLOUD', 'Nextcloud'), ('LOCAL', 'Local')], default='DB', max_length=128),
),
migrations.AddField(
model_name='food',
name='supermarket_category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.supermarketcategory'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.1.5 on 2021-01-25 13:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0102_auto_20210125_1147'),
]
operations = [
migrations.AddField(
model_name='food',
name='ignore_shopping',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.1.5 on 2021-01-25 20:33
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0103_food_ignore_shopping'),
]
operations = [
migrations.CreateModel(
name='SupermarketCategoryRelation',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.IntegerField(default=0)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.supermarketcategory')),
('supermarket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.supermarket')),
],
),
migrations.AddField(
model_name='supermarket',
name='categories',
field=models.ManyToManyField(through='cookbook.SupermarketCategoryRelation', to='cookbook.SupermarketCategory'),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.1.5 on 2021-01-26 15:04
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0104_auto_20210125_2133'),
]
operations = [
migrations.AlterField(
model_name='supermarketcategoryrelation',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_to_supermarket', to='cookbook.supermarketcategory'),
),
migrations.AlterField(
model_name='supermarketcategoryrelation',
name='supermarket',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_to_supermarket', to='cookbook.supermarket'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.1.5 on 2021-01-26 15:21
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0105_auto_20210126_1604'),
]
operations = [
migrations.AddField(
model_name='shoppinglist',
name='supermarket',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.supermarket'),
),
]

View File

@@ -114,7 +114,8 @@ class UserPreference(models.Model):
class Storage(models.Model):
DROPBOX = 'DB'
NEXTCLOUD = 'NEXTCLOUD'
STORAGE_TYPES = ((DROPBOX, 'Dropbox'), (NEXTCLOUD, 'Nextcloud'))
LOCAL = 'LOCAL'
STORAGE_TYPES = ((DROPBOX, 'Dropbox'), (NEXTCLOUD, 'Nextcloud'), (LOCAL, 'Local'))
name = models.CharField(max_length=128)
method = models.CharField(
@@ -124,6 +125,7 @@ class Storage(models.Model):
password = models.CharField(max_length=128, blank=True, null=True)
token = models.CharField(max_length=512, blank=True, null=True)
url = models.URLField(blank=True, null=True)
path = models.CharField(blank=True, default='', max_length=256)
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
def __str__(self):
@@ -142,6 +144,32 @@ class Sync(models.Model):
return self.path
class SupermarketCategory(models.Model):
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
description = models.TextField(blank=True, null=True)
def __str__(self):
return self.name
class Supermarket(models.Model):
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
description = models.TextField(blank=True, null=True)
categories = models.ManyToManyField(SupermarketCategory, through='SupermarketCategoryRelation')
def __str__(self):
return self.name
class SupermarketCategoryRelation(models.Model):
supermarket = models.ForeignKey(Supermarket, on_delete=models.CASCADE, related_name='category_to_supermarket')
category = models.ForeignKey(SupermarketCategory, on_delete=models.CASCADE, related_name='category_to_supermarket')
order = models.IntegerField(default=0)
class Meta:
ordering = ('order',)
class SyncLog(models.Model):
sync = models.ForeignKey(Sync, on_delete=models.CASCADE)
status = models.CharField(max_length=32)
@@ -167,9 +195,7 @@ class Keyword(models.Model):
class Unit(models.Model):
name = models.CharField(
unique=True, max_length=128, validators=[MinLengthValidator(1)]
)
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
description = models.TextField(blank=True, null=True)
def __str__(self):
@@ -177,12 +203,11 @@ class Unit(models.Model):
class Food(models.Model):
name = models.CharField(
unique=True, max_length=128, validators=[MinLengthValidator(1)]
)
recipe = models.ForeignKey(
'Recipe', null=True, blank=True, on_delete=models.SET_NULL
)
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
ignore_shopping = models.BooleanField(default=False)
description = models.TextField(default='', blank=True)
def __str__(self):
return self.name
@@ -408,9 +433,8 @@ class ShoppingList(models.Model):
note = models.TextField(blank=True, null=True)
recipes = models.ManyToManyField(ShoppingListRecipe, blank=True)
entries = models.ManyToManyField(ShoppingListEntry, blank=True)
shared = models.ManyToManyField(
User, blank=True, related_name='list_share'
)
shared = models.ManyToManyField(User, blank=True, related_name='list_share')
supermarket = models.ForeignKey(Supermarket, null=True, blank=True, on_delete=models.SET_NULL)
finished = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -0,0 +1,59 @@
import io
import os
import tempfile
from datetime import datetime
from os import listdir
from os.path import isfile, join
from cookbook.models import Recipe, RecipeImport, SyncLog
from cookbook.provider.provider import Provider
class Local(Provider):
@staticmethod
def import_all(monitor):
files = [f for f in listdir(monitor.path) if isfile(join(monitor.path, f))]
import_count = 0
for file in files:
path = monitor.path + '/' + file
if not Recipe.objects.filter(file_path__iexact=path).exists() \
and not RecipeImport.objects.filter(file_path=path).exists(): # noqa: E501
name = os.path.splitext(file)[0]
new_recipe = RecipeImport(
name=name,
file_path=path,
storage=monitor.storage
)
new_recipe.save()
import_count += 1
log_entry = SyncLog(
status='SUCCESS',
msg='Imported ' + str(import_count) + ' recipes',
sync=monitor
)
log_entry.save()
monitor.last_checked = datetime.now()
monitor.save()
return True
@staticmethod
def get_file(recipe):
file = io.BytesIO(open(recipe.file_path, 'rb').read())
return file
@staticmethod
def rename_file(recipe, new_name):
os.rename(recipe.file_path, os.path.join(os.path.dirname(recipe.file_path), (new_name + os.path.splitext(recipe.file_path)[1])))
return True
@staticmethod
def delete_file(recipe):
os.remove(recipe.file_path)
return True

View File

@@ -20,6 +20,8 @@ class Nextcloud(Provider):
'webdav_password': storage.password,
'webdav_root': '/remote.php/dav/files/' + storage.username
}
if storage.path != '':
options['webdav_root'] = storage.path
return wc.Client(options)
@staticmethod

View File

@@ -11,7 +11,7 @@ from cookbook.models import (Comment, CookLog, Food, Ingredient, Keyword,
RecipeBook, RecipeBookEntry, RecipeImport,
ShareLink, ShoppingList, ShoppingListEntry,
ShoppingListRecipe, Step, Storage, Sync, SyncLog,
Unit, UserPreference, ViewLog)
Unit, UserPreference, ViewLog, SupermarketCategory, Supermarket, SupermarketCategoryRelation)
from cookbook.templatetags.custom_tags import markdown
@@ -140,7 +140,40 @@ class UnitSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
read_only_fields = ('id',)
class FoodSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
def create(self, validated_data):
# since multi select tags dont have id's
# duplicate names might be routed to create
obj, created = SupermarketCategory.objects.get_or_create(**validated_data)
return obj
def update(self, instance, validated_data):
return super(SupermarketCategorySerializer, self).update(instance, validated_data)
class Meta:
model = SupermarketCategory
fields = ('id', 'name')
class SupermarketCategoryRelationSerializer(serializers.ModelSerializer):
category = SupermarketCategorySerializer()
class Meta:
model = SupermarketCategoryRelation
fields = ('id', 'category', 'supermarket', 'order')
class SupermarketSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
category_to_supermarket = SupermarketCategoryRelationSerializer(many=True, read_only=True)
class Meta:
model = Supermarket
fields = ('id', 'name', 'category_to_supermarket')
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
def create(self, validated_data):
# since multi select tags dont have id's
@@ -153,8 +186,7 @@ class FoodSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
class Meta:
model = Food
fields = ('id', 'name', 'recipe')
read_only_fields = ('id',)
fields = ('id', 'name', 'recipe', 'ignore_shopping', 'supermarket_category')
class IngredientSerializer(WritableNestedModelSerializer):
@@ -310,7 +342,7 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
food = FoodSerializer(allow_null=True)
unit = UnitSerializer(allow_null=True)
unit = UnitSerializer(allow_null=True, required=False)
amount = CustomDecimalField()
class Meta:
@@ -318,7 +350,6 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
fields = (
'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked'
)
read_only_fields = ('id',)
class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
@@ -331,12 +362,13 @@ class ShoppingListSerializer(WritableNestedModelSerializer):
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
entries = ShoppingListEntrySerializer(many=True, allow_null=True)
shared = UserNameSerializer(many=True)
supermarket = SupermarketSerializer(allow_null=True)
class Meta:
model = ShoppingList
fields = (
'id', 'uuid', 'note', 'recipes', 'entries',
'shared', 'finished', 'created_by', 'created_at'
'shared', 'finished', 'supermarket', 'created_by', 'created_at'
)
read_only_fields = ('id',)

File diff suppressed because one or more lines are too long

View File

@@ -95,7 +95,7 @@
class="fas fa-edit fa-fw"></i> {% trans 'Batch Edit' %}</a>
</div>
</li>
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'list_storage,data_sync,list_recipe_import,list_sync_log,data_stats,edit_food' %}active{% endif %}">
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'list_storage,data_sync,list_recipe_import,list_sync_log,data_stats,edit_food,edit_storage' %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"><i class="fas fa-database"></i> {% trans 'Storage Data' %}
</a>
@@ -121,6 +121,9 @@
<ul class="navbar-nav ml-auto">
{% if user.is_authenticated %}
{% page_help request.resolver_match.url_name as help_button %}
{% if help_button %}{{ help_button|safe }}{% endif %}
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_settings,view_history,view_system,docs_markdown' %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"><i

View File

@@ -109,23 +109,29 @@
</div>
</div>
<table class="table table-sm table-striped" style="margin-top: 1vh">
<table class="table table-sm" style="margin-top: 1vh">
<tbody is="draggable" group="people" :list="display_entries" tag="tbody" :empty-insert-threshold="10"
handle=".handle" @sort="sortEntries()">
<tr v-for="(element, index) in display_entries" :key="element.id">
<!--<td class="handle"><i class="fas fa-sort"></i></td>-->
<td>[[element.amount]]</td>
<td>[[element.unit.name]]</td>
<td>[[element.food.name]]</td>
<td>
<button class="btn btn-sm btn-outline-danger" v-if="element.list_recipe === null"
@click="shopping_list.entries = shopping_list.entries.filter(item => item.id !== element.id)">
<i class="fa fa-trash"></i></button>
</td>
</tr>
</tbody>
<template v-for="c in display_categories">
<thead>
<tr>
<th colspan="5">[[c.name]]</th>
</tr>
</thead>
<tbody is="draggable" :list="c.entries" tag="tbody" group="people" @sort="sortEntries"
@change="dragChanged(c, $event)">
<tr v-for="(element, index) in c.entries" :key="element.id">
<td class="handle"><i class="fas fa-sort"></i></td>
<td>[[element.amount]]</td>
<td>[[element.unit.name]]</td>
<td>[[element.food.name]]</td>
<td>
<button class="btn btn-sm btn-outline-danger" v-if="element.list_recipe === null"
@click="shopping_list.entries = shopping_list.entries.filter(item => item.id !== element.id)">
<i class="fa fa-trash"></i></button>
</td>
</tr>
</tbody>
</template>
</table>
@@ -199,6 +205,28 @@
</div>
</div>
<div class="row">
<div class="col" style="margin-top: 1vh">
<multiselect
v-tabindex
v-model="shopping_list.supermarket"
:options="supermarkets"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
placeholder="{% trans 'Select Supermarket' %}"
select-label="{% trans 'Select' %}"
label="name"
track-by="id"
:multiple="false"
:loading="supermarkets_loading"
@search-change="searchSupermarket">
</multiselect>
</div>
</div>
<div class="row">
<div class="col" style="margin-top: 1vh">
<multiselect
@@ -238,27 +266,41 @@
<div class="row" style="margin-top: 8px">
<div class="col col-md-12">
<table class="table">
<tr v-for="x in display_entries">
<template v-if="!x.checked">
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
@change="entryChecked(x)">
</td>
<td>[[x.amount]]</td>
<td>[[x.unit.name]]</td>
<td>[[x.food.name]]</td>
<template v-for="c in display_categories">
<template v-if="c.entries.filter(item => item.checked === false).length > 0">
<tr>
<td colspan="4">[[c.name]]</td>
</tr>
<tr v-for="x in c.entries">
<template v-if="!x.checked">
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
@change="entryChecked(x)">
</td>
<td>[[x.amount]]</td>
<td>[[x.unit.name]]</td>
<td>[[x.food.name]] <span class="text-muted" v-if="x.recipes.length > 0">([[x.recipes.join(', ')]])</span></td>
</template>
</tr>
</template>
</template>
<tr>
<td colspan="4"></td>
</tr>
<template v-for="c in display_categories">
<tr v-for="x in c.entries" class="text-muted">
<template v-if="x.checked">
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
@change="entryChecked(x)">
</td>
<td>[[x.amount]]</td>
<td>[[x.unit.name]]</td>
<td>[[x.food.name]]</td>
</template>
</tr>
</template>
<tr v-for="x in display_entries" class="text-muted">
<template v-if="x.checked">
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
@change="entryChecked(x)">
</td>
<td>[[x.amount]]</td>
<td>[[x.unit.name]]</td>
<td>[[x.food.name]]</td>
</template>
</tr>
</table>
</div>
</div>
@@ -299,13 +341,12 @@
</b-modal>
</template>
{% endblock %}
{% block script %}
<script src="{% url 'javascript-catalog' %}"></script>
<script type="application/javascript">
let csrftoken = Cookies.get('csrftoken');
@@ -336,6 +377,8 @@
foods_loading: false,
units: [],
units_loading: false,
supermarkets: [],
supermarkets_loading: false,
users: [],
users_loading: false,
onLine: navigator.onLine,
@@ -355,45 +398,104 @@
})
return cache
},
display_entries() {
let entries = []
recipe_cache() {
let cache = {}
this.shopping_list.recipes.forEach((r) => {
cache[r.id] = r.recipe_name;
})
return cache
},
display_categories() {
let categories = {
no_category: {
name: gettext('Uncategorized'),
id: -1,
entries: [],
order: 99999999
}
}
//TODO merge multiple ingredients of same unit
this.shopping_list.entries.forEach((e) => {
if (e.food.supermarket_category !== null) {
categories[e.food.supermarket_category.id] = {
name: e.food.supermarket_category.name,
id: e.food.supermarket_category.id,
order: 0,
entries: []
};
}
})
if (this.shopping_list.supermarket !== null) {
this.shopping_list.supermarket.category_to_supermarket.forEach(el => {
categories[el.category.id] = {
name: el.category.name,
id: el.category.id,
order: el.order,
entries: []
};
})
}
this.shopping_list.entries.forEach(element => {
let item = {}
Object.assign(item, element);
if (item.list_recipe !== null) {
item.amount = item.amount * this.servings_cache[item.list_recipe]
item.recipes = []
let entry = this.findMergeEntry(categories, item)
if (entry !== undefined) {
entry.amount += item.amount * this.servings_cache[item.list_recipe]
if (item.list_recipe !== null && entry.recipes.indexOf(this.recipe_cache[item.list_recipe]) === -1) {
entry.recipes.push(this.recipe_cache[item.list_recipe])
}
entry.entries.push(item.id)
} else {
if (item.list_recipe !== null) {
item.amount = item.amount * this.servings_cache[item.list_recipe]
}
item.unit = ((element.unit !== undefined && element.unit !== null) ? element.unit : {'name': ''})
item.entries = [element.id]
if (element.list_recipe !== null) {
item.recipes.push(this.recipe_cache[element.list_recipe])
}
if (item.food.supermarket_category !== null) {
categories[item.food.supermarket_category.id].entries.push(item)
} else {
categories['no_category'].entries.push(item)
}
}
item.unit = ((element.unit !== undefined && element.unit !== null) ? element.unit : {'name': ''})
entries.push(item)
});
return entries
let ordered_categories = []
for (let [i, v] of Object.entries(categories)) {
ordered_categories.push(v)
}
ordered_categories.sort(function (a, b) {
if (a.order < b.order) {
return -1
} else if (a.order > b.order) {
return 1
} else {
return 0
}
})
return ordered_categories
},
export_text() {
let text = ''
for (let e of this.display_entries.filter(item => item.checked === false)) {
text += `${this.export_text_prefix}${e.amount} ${e.unit.name} ${e.food.name} \n`
for (let c of this.display_categories) {
for (let e of c.entries.filter(item => item.checked === false)) {
text += `${this.export_text_prefix}${e.amount} ${e.unit.name} ${e.food.name} \n`
}
}
return text
}
},
/*
watch: {
recipe: {
deep: true,
handler() {
this.recipe_changed = this.recipe_changed !== undefined;
}
}
},
created() {
window.addEventListener('beforeunload', this.warnPageLeave)
},
*/
mounted: function () {
this.loadShoppingList()
@@ -422,22 +524,35 @@
{% endif %}
this.searchUsers('')
this.searchSupermarket('')
this.searchUnits('')
this.searchFoods('')
},
methods: {
findMergeEntry: function (categories, entry) {
for (let [i, e] of Object.entries(categories)) {
let found_entry = e.entries.find(item => {
if (entry.food.id === item.food.id) {
if (entry.unit === null && item.unit === null) {
return true
} else if (entry.unit !== null && item.unit !== null && entry.unit.id === item.unit.id) {
return true
}
}
})
if (found_entry !== undefined) {
return found_entry
}
}
return undefined
},
updateOnlineStatus(e) {
const {
type
} = e;
this.onLine = type === 'online';
},
/*
warnPageLeave: function (event) {
if (this.recipe_changed) {
event.returnValue = ''
return ''
}
},
*/
makeToast: function (title, message, variant = null) {
//TODO remove duplicate function in favor of central one
this.$bvToast.toast(message, {
@@ -489,7 +604,8 @@
"shared": [{% for u in request.user.userpreference.plan_share.all %}
{'id': {{ u.pk }}, 'username': '{{ u.get_user_name }}'},
{% endfor %}],
"created_by": 1
"created_by": {{ request.user.pk }},
"supermarket": null
}
this.loading = false
@@ -554,16 +670,39 @@
})
},
sortEntries: function () {
this.display_entries.forEach((item, index) => {
sortEntries: function (a, b) {
//TODO implement me (might be difficult because of computed drag changed stuff)
},
dragChanged: function (category, evt) {
if (evt.added !== undefined) {
if (evt.added.element.id === undefined) {
this.makeToast(gettext('Warning'), gettext('This feature is only available after saving the shopping list'), 'warning')
} else {
this.shopping_list.entries.forEach(entry => {
if (entry.id === evt.added.element.id) {
if (category.id === -1) {
entry.food.supermarket_category = null
} else {
entry.food.supermarket_category = {
name: category.name,
id: category.id
}
}
this.$http.put(("{% url 'api:food-detail' 123456 %}").replace('123456', entry.food.id), entry.food).then((response) => {
})
console.log("IMPLEMENT ME", this.display_entries)
}).catch((err) => {
this.makeToast(gettext('Error'), gettext('There was an error updating a resource!') + err.bodyText, 'danger')
})
}
})
}
}
},
entryChecked: function (entry) {
console.log("checked entry: ", entry)
this.shopping_list.entries.forEach((item) => {
if (item.id === entry.id) { //TODO unwrap once same entries are merged
if (entry.entries.includes(item.id)) {
item.checked = entry.checked
this.$http.put("{% url 'api:shoppinglistentry-detail' 123456 %}".replace('123456', item.id), item, {}).then((response) => {
@@ -572,7 +711,6 @@
this.makeToast(gettext('Error'), gettext('There was an error updating a resource!') + err.bodyText, 'danger')
this.loading = false
})
}
})
},
@@ -584,7 +722,7 @@
'unit': this.new_entry.unit,
'amount': parseFloat(this.new_entry.amount),
'order': 0,
'checked': false
'checked': false,
})
this.new_entry = {
@@ -625,22 +763,25 @@
"recipe_name": recipe.name,
"servings": servings,
}
this.shopping_list.recipes.push(slr)
for (let s of recipe.steps) {
for (let i of s.ingredients) {
if (!i.is_header && i.food !== null) {
this.shopping_list.entries.push({
'list_recipe': slr.id,
'food': i.food,
'unit': i.unit,
'amount': i.amount,
'order': 0
})
this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe.id)).then((response) => {
for (let s of response.data.steps) {
for (let i of s.ingredients) {
if (!i.is_header && i.food !== null && i.food.ignore_shopping === false) {
this.shopping_list.entries.push({
'list_recipe': slr.id,
'food': i.food,
'unit': i.unit,
'amount': i.amount,
'order': 0
})
}
}
}
}
}).catch((err) => {
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
},
removeRecipeFromList: function (slr) {
this.shopping_list.entries = this.shopping_list.entries.filter(item => item.list_recipe !== slr.id)
@@ -676,7 +817,7 @@
})
},
addFoodType: function (tag, index) { //TODO move to central component
let new_food = {'name': tag}
let new_food = {'name': tag, supermarket_category: null}
this.foods.push(new_food)
this.new_entry.food = new_food
},
@@ -694,6 +835,17 @@
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
},
searchSupermarket: function (query) { //TODO move to central component
this.supermarkets_loading = true
this.$http.get("{% url 'api:supermarket-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.supermarkets = response.data
this.supermarkets_loading = false
}).catch((err) => {
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
},
},
beforeDestroy() {
window.removeEventListener('online', this.updateOnlineStatus);

View File

@@ -8,6 +8,7 @@ from django import template
from django.db.models import Avg
from django.urls import NoReverseMatch, reverse
from recipes import settings
from gettext import gettext as _
register = template.Library()
@@ -80,6 +81,21 @@ def recipe_last(recipe, user):
return ''
@register.simple_tag
def page_help(page_name):
help_pages = {
'edit_storage': 'https://vabene1111.github.io/recipes/features/external_recipes/',
'view_shopping': 'https://vabene1111.github.io/recipes/features/shopping/',
}
link = help_pages.get(page_name, '')
if link != '':
return f'<li class="nav-item"><a class="nav-link" target="_blank" rel="nofollow noreferrer" href="{link}"><i class="far fa-question-circle"></i>&zwnj;<span class="d-lg-none"> {_("Help")}</span></a></li>'
else:
return None
@register.simple_tag
def message_of_the_day():
return Space.objects.first().message

View File

@@ -10,12 +10,12 @@ class TestEditsRecipe(TestBase):
# flake8: noqa
def test_ld_json(self):
test_list = [
{'file': 'cookbook/tests/resources/websites/ld_json_1.html', 'result_length': 3222},
{'file': 'cookbook/tests/resources/websites/ld_json_1.html', 'result_length': 3237},
{'file': 'cookbook/tests/resources/websites/ld_json_2.html', 'result_length': 1510},
{'file': 'cookbook/tests/resources/websites/ld_json_3.html', 'result_length': 1629},
{'file': 'cookbook/tests/resources/websites/ld_json_4.html', 'result_length': 1729},
{'file': 'cookbook/tests/resources/websites/ld_json_4.html', 'result_length': 1744},
{'file': 'cookbook/tests/resources/websites/ld_json_itemList.html', 'result_length': 3206},
{'file': 'cookbook/tests/resources/websites/ld_json_multiple.html', 'result_length': 1606},
{'file': 'cookbook/tests/resources/websites/ld_json_multiple.html', 'result_length': 1621},
{'file': 'cookbook/tests/resources/websites/micro_data_1.html', 'result_length': 1079},
{'file': 'cookbook/tests/resources/websites/micro_data_2.html', 'result_length': 1438},
{'file': 'cookbook/tests/resources/websites/micro_data_3.html', 'result_length': 1148},

View File

@@ -34,6 +34,7 @@ router.register(r'view-log', api.ViewLogViewSet)
router.register(r'cook-log', api.CookLogViewSet)
router.register(r'recipe-book', api.RecipeBookViewSet)
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
router.register(r'supermarket', api.SupermarketViewSet)
urlpatterns = [
path('', views.index, name='index'),

View File

@@ -36,8 +36,9 @@ from cookbook.models import (CookLog, Food, Ingredient, Keyword, MealPlan,
MealType, Recipe, RecipeBook, ShoppingList,
ShoppingListEntry, ShoppingListRecipe, Step,
Storage, Sync, SyncLog, Unit, UserPreference,
ViewLog, RecipeBookEntry)
ViewLog, RecipeBookEntry, Supermarket)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
from cookbook.serializer import (FoodSerializer, IngredientSerializer,
KeywordSerializer, MealPlanSerializer,
@@ -50,10 +51,35 @@ from cookbook.serializer import (FoodSerializer, IngredientSerializer,
StorageSerializer, SyncLogSerializer,
SyncSerializer, UnitSerializer,
UserNameSerializer, UserPreferenceSerializer,
ViewLogSerializer, CookLogSerializer, RecipeBookEntrySerializer, RecipeOverviewSerializer)
ViewLogSerializer, CookLogSerializer, RecipeBookEntrySerializer, RecipeOverviewSerializer, SupermarketSerializer)
from recipes.settings import DEMO
class StandardFilterMixin(ViewSetMixin):
def get_queryset(self):
queryset = self.queryset
query = self.request.query_params.get('query', None)
if query is not None:
queryset = queryset.filter(name__icontains=query)
updated_at = self.request.query_params.get('updated_at', None)
if updated_at is not None:
try:
queryset = queryset.filter(updated_at__gte=updated_at)
except FieldError:
pass
limit = self.request.query_params.get('limit', None)
random = self.request.query_params.get('random', False)
if limit is not None:
if random:
queryset = queryset.random(int(limit))
else:
queryset = queryset[:int(limit)]
return queryset
class UserNameViewSet(viewsets.ReadOnlyModelViewSet):
"""
list:
@@ -115,29 +141,10 @@ class SyncLogViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [CustomIsAdmin, ]
class StandardFilterMixin(ViewSetMixin):
def get_queryset(self):
queryset = self.queryset
query = self.request.query_params.get('query', None)
if query is not None:
queryset = queryset.filter(name__icontains=query)
updated_at = self.request.query_params.get('updated_at', None)
if updated_at is not None:
try:
queryset = queryset.filter(updated_at__gte=updated_at)
except FieldError:
pass
limit = self.request.query_params.get('limit', None)
random = self.request.query_params.get('random', False)
if limit is not None:
if random:
queryset = queryset.random(int(limit))
else:
queryset = queryset[:int(limit)]
return queryset
class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin):
queryset = Supermarket.objects.all()
serializer_class = SupermarketSerializer
permission_classes = [CustomIsUser]
class KeywordViewSet(viewsets.ModelViewSet, StandardFilterMixin):
@@ -169,7 +176,7 @@ class FoodViewSet(viewsets.ModelViewSet, StandardFilterMixin):
class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):
queryset = RecipeBook.objects.all()
serializer_class = RecipeBookSerializer
permission_classes = [CustomIsOwner, CustomIsAdmin]
permission_classes = [CustomIsOwner]
def get_queryset(self):
self.queryset = super(RecipeBookViewSet, self).get_queryset()
@@ -181,7 +188,7 @@ class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):
class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet):
queryset = RecipeBookEntry.objects.all()
serializer_class = RecipeBookEntrySerializer
permission_classes = [CustomIsOwner, CustomIsAdmin]
permission_classes = [CustomIsOwner]
def get_queryset(self):
if self.request.user.is_superuser:
@@ -200,7 +207,7 @@ class MealPlanViewSet(viewsets.ModelViewSet):
"""
queryset = MealPlan.objects.all()
serializer_class = MealPlanSerializer
permission_classes = [permissions.IsAuthenticated] # TODO fix permissions
permission_classes = [CustomIsOwner]
def get_queryset(self):
queryset = MealPlan.objects.filter(
@@ -225,11 +232,10 @@ class MealTypeViewSet(viewsets.ModelViewSet):
"""
queryset = MealType.objects.order_by('order').all()
serializer_class = MealTypeSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [CustomIsOwner]
def get_queryset(self):
queryset = MealType.objects.order_by('order', 'id') \
.filter(created_by=self.request.user).all()
queryset = MealType.objects.order_by('order', 'id').filter(created_by=self.request.user).all()
return queryset
@@ -310,17 +316,19 @@ class RecipeViewSet(viewsets.ModelViewSet, StandardFilterMixin):
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
queryset = ShoppingListRecipe.objects.all()
serializer_class = ShoppingListRecipeSerializer
permission_classes = [CustomIsUser, ] # TODO add custom validation
permission_classes = [CustomIsOwner, ]
# TODO custom get qs
def get_queryset(self):
return self.queryset.filter(shoppinglist__created_by=self.request.user).all()
class ShoppingListEntryViewSet(viewsets.ModelViewSet):
queryset = ShoppingListEntry.objects.all()
serializer_class = ShoppingListEntrySerializer
permission_classes = [CustomIsOwner, ] # TODO add custom validation
permission_classes = [CustomIsOwner, ]
# TODO custom get qs
def get_queryset(self):
return self.queryset.filter(shoppinglist__created_by=self.request.user).all()
class ShoppingListViewSet(viewsets.ModelViewSet):
@@ -345,12 +353,10 @@ class ShoppingListViewSet(viewsets.ModelViewSet):
class ViewLogViewSet(viewsets.ModelViewSet):
queryset = ViewLog.objects.all()
serializer_class = ViewLogSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [CustomIsOwner]
def get_queryset(self):
queryset = ViewLog.objects \
.filter(created_by=self.request.user).all()[:5]
return queryset
return CookLog.objects.filter(created_by=self.request.user).all()[:5]
class CookLogViewSet(viewsets.ModelViewSet):
@@ -359,7 +365,7 @@ class CookLogViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsOwner]
def get_queryset(self):
queryset = ViewLog.objects.filter(created_by=self.request.user).all()[:5]
queryset = CookLog.objects.filter(created_by=self.request.user).all()[:5]
return queryset
@@ -370,6 +376,8 @@ def get_recipe_provider(recipe):
return Dropbox
elif recipe.storage.method == Storage.NEXTCLOUD:
return Nextcloud
elif recipe.storage.method == Storage.LOCAL:
return Local
else:
raise Exception('Provider not implemented')
@@ -394,15 +402,15 @@ def get_external_file_link(request, recipe_id):
@group_required('user')
def get_recipe_file(request, recipe_id):
recipe = Recipe.objects.get(id=recipe_id)
if not recipe.cors_link:
update_recipe_links(recipe)
# if not recipe.cors_link:
# update_recipe_links(recipe)
return FileResponse(get_recipe_provider(recipe).get_file(recipe))
@group_required('user')
def sync_all(request):
if DEMO or True:
if DEMO:
messages.add_message(
request, messages.ERROR, _('This feature is not available in the demo version!')
)
@@ -420,6 +428,10 @@ def sync_all(request):
ret = Nextcloud.import_all(monitor)
if not ret:
error = True
if monitor.storage.method == Storage.LOCAL:
ret = Local.import_all(monitor)
if not ret:
error = True
if not error:
messages.add_message(

View File

@@ -13,6 +13,7 @@ from cookbook.models import (Comment, InviteLink, Keyword, MealPlan, Recipe,
RecipeBook, RecipeBookEntry, RecipeImport,
Storage, Sync)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
@@ -37,6 +38,8 @@ def delete_recipe_source(request, pk):
Dropbox.delete_file(recipe)
if recipe.storage.method == Storage.NEXTCLOUD:
Nextcloud.delete_file(recipe)
if recipe.storage.method == Storage.LOCAL:
Local.delete_file(recipe)
recipe.storage = None
recipe.file_path = ''

View File

@@ -18,6 +18,7 @@ from cookbook.models import (Comment, Food, Ingredient, Keyword, MealPlan,
MealType, Recipe, RecipeBook, RecipeImport,
Storage, Sync)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
@@ -231,6 +232,8 @@ class ExternalRecipeUpdate(GroupRequiredMixin, UpdateView):
Dropbox.rename_file(old_recipe, self.object.name)
if self.object.storage.method == Storage.NEXTCLOUD:
Nextcloud.rename_file(old_recipe, self.object.name)
if self.object.storage.method == Storage.LOCAL:
Local.rename_file(old_recipe, self.object.name)
self.object.file_path = "%s/%s%s" % (
os.path.dirname(self.object.file_path),

View File

@@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import Avg, Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404, render, redirect
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext as _
@@ -28,6 +28,7 @@ from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,
RecipeBook, RecipeBookEntry, ViewLog)
from cookbook.tables import (CookLogTable, RecipeTable, RecipeTableSmall,
ViewLogTable)
from recipes.settings import DEMO
from recipes.version import BUILD_REF, VERSION_NUMBER
@@ -263,6 +264,10 @@ def shopping_list(request, pk=None):
@group_required('guest')
def user_settings(request):
if DEMO:
messages.add_message(request, messages.ERROR, _('This feature is not available in the demo version!'))
return redirect('index')
up = request.user.userpreference
user_name_form = UserNameForm(instance=request.user)