Merge branch 'feature/shopping-ui' into develop

This commit is contained in:
vabene1111 2024-02-10 09:26:46 +01:00
commit b2a4b084d7
30 changed files with 2246 additions and 1976 deletions

View File

@ -1,6 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="vaben">
<words>
<w>mealplan</w>
<w>pinia</w>
<w>selfhosted</w>
<w>unapplied</w>

View File

@ -27,9 +27,6 @@ def shopping_helper(qs, request):
elif checked in ['true', 1, '1']:
qs = qs.filter(checked=True)
elif checked in ['recent']:
today_start = timezone.now().replace(hour=0, minute=0, second=0)
week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
supermarket_order = ['checked'] + supermarket_order
return qs.distinct().order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')

View File

@ -6,11 +6,12 @@ from django.conf import settings
from django.db import migrations, models
from django_scopes import scopes_disabled
from cookbook.models import PermissionModelMixin, ShoppingListEntry
from cookbook.models import PermissionModelMixin
def copy_values_to_sle(apps, schema_editor):
with scopes_disabled():
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
entries = ShoppingListEntry.objects.all()
for entry in entries:
if entry.shoppinglist_set.first():

View File

@ -1,25 +1,22 @@
# Generated by Django 3.2.7 on 2021-10-01 22:34
import datetime
from datetime import timedelta
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db import migrations
from django.utils import timezone
from django.utils.timezone import utc
from django_scopes import scopes_disabled
from cookbook.models import FoodInheritField, ShoppingListEntry
def delete_orphaned_sle(apps, schema_editor):
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
with scopes_disabled():
# shopping list entry is orphaned - delete it
ShoppingListEntry.objects.filter(shoppinglist=None).delete()
def create_inheritfields(apps, schema_editor):
FoodInheritField = apps.get_model('cookbook', 'FoodInheritField')
FoodInheritField.objects.create(name='Supermarket Category', field='supermarket_category')
FoodInheritField.objects.create(name='On Hand', field='food_onhand')
FoodInheritField.objects.create(name='Diet', field='diet')
@ -29,6 +26,7 @@ def create_inheritfields(apps, schema_editor):
def set_completed_at(apps, schema_editor):
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
today_start = timezone.now().replace(hour=0, minute=0, second=0)
# arbitrary - keeping all of the closed shopping list items out of the 'recent' view
month_ago = today_start - timedelta(days=30)

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-01-28 10:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0209_remove_space_use_plural'),
]
operations = [
migrations.AddField(
model_name='shoppinglistentry',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@ -763,7 +763,7 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
objects = ScopedManager(space='space')
def __str__(self):
return f'{self.pk}: {self.amount} {self.food.name} {self.unit.name}'
return f'{self.pk}: {self.amount} ' + (self.food.name if self.food else ' ') + (self.unit.name if self.unit else '')
class Meta:
ordering = ['order', 'pk']
@ -1099,6 +1099,8 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
checked = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
completed_at = models.DateTimeField(null=True, blank=True)
delay_until = models.DateTimeField(null=True, blank=True)

View File

@ -57,7 +57,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
api_serializer = None
# extended values are computationally expensive and not needed in normal circumstances
try:
if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
if str2bool(
self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
return fields
except (AttributeError, KeyError):
pass
@ -81,12 +82,14 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
class OpenDataModelMixin(serializers.ModelSerializer):
def create(self, validated_data):
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '':
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data[
'open_data_slug'].strip() == '':
validated_data['open_data_slug'] = None
return super().create(validated_data)
def update(self, instance, validated_data):
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '':
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data[
'open_data_slug'].strip() == '':
validated_data['open_data_slug'] = None
return super().update(instance, validated_data)
@ -122,7 +125,8 @@ class CustomOnHandField(serializers.Field):
if not self.context["request"].user.is_authenticated:
return []
shared_users = []
if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
if c := caches['default'].get(
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
shared_users = c
else:
try:
@ -332,7 +336,8 @@ class UserSpaceSerializer(WritableNestedModelSerializer):
class Meta:
model = UserSpace
fields = ('id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',)
fields = (
'id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',)
read_only_fields = ('id', 'invite_link', 'created_at', 'updated_at', 'space')
@ -382,13 +387,15 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
class Meta:
model = UserPreference
fields = (
'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page', 'use_fractions', 'use_kj',
'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page',
'use_fractions', 'use_kj',
'plan_share', 'nav_sticky',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping',
'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days',
'csv_delim', 'csv_prefix',
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients', 'food_children_exist'
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients',
'food_children_exist'
)
@ -477,10 +484,13 @@ class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin, OpenDataModelMixin)
if x := validated_data.get('name', None):
validated_data['plural_name'] = x.strip()
if unit := Unit.objects.filter(Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']), space=space).first():
if unit := Unit.objects.filter(
Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']),
space=space).first():
return unit
obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
def update(self, instance, validated_data):
@ -500,7 +510,8 @@ class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerial
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
space = validated_data.pop('space', self.context['request'].space)
obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
def update(self, instance, validated_data):
@ -525,7 +536,8 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer, OpenDataMo
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
space = validated_data.pop('space', self.context['request'].space)
obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
class Meta:
@ -540,7 +552,8 @@ class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer,
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
space = validated_data.pop('space', self.context['request'].space)
obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
class Meta:
@ -665,12 +678,14 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
properties = validated_data.pop('properties', None)
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, properties_food_unit=properties_food_unit,
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space,
properties_food_unit=properties_food_unit,
defaults=validated_data)
if properties and len(properties) > 0:
for p in properties:
obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'], property_amount=p['property_amount'], space=space))
obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'],
property_amount=p['property_amount'], space=space))
return obj
@ -702,7 +717,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
'properties', 'properties_food_amount', 'properties_food_unit', 'fdc_id',
'food_onhand', 'supermarket_category',
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping',
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', 'open_data_slug',
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields',
'open_data_slug',
)
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
@ -726,7 +742,8 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer):
uch = UnitConversionHelper(self.context['request'].space)
conversions = []
for c in uch.get_conversions(obj):
conversions.append({'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper
conversions.append(
{'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper
return conversions
else:
return []
@ -828,7 +845,8 @@ class UnitConversionSerializer(WritableNestedModelSerializer, OpenDataModelMixin
class Meta:
model = UnitConversion
fields = ('id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug')
fields = (
'id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug')
class NutritionInformationSerializer(serializers.ModelSerializer):
@ -898,7 +916,8 @@ class RecipeSerializer(RecipeBaseSerializer):
fields = (
'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url',
'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', 'file_path', 'servings_text', 'rating',
'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings',
'file_path', 'servings_text', 'rating',
'last_cooked',
'private', 'shared',
)
@ -977,7 +996,8 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer):
def create(self, validated_data):
book = validated_data['book']
recipe = validated_data['recipe']
if not book.get_owner() == self.context['request'].user and not self.context['request'].user in book.get_shared():
if not book.get_owner() == self.context['request'].user and not self.context[
'request'].user in book.get_shared():
raise NotFound(detail=None, code=None)
obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe)
return obj
@ -1041,6 +1061,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField('get_name') # should this be done at the front end?
recipe_name = serializers.ReadOnlyField(source='recipe.name')
mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
mealplan_from_date = serializers.ReadOnlyField(source='mealplan.from_date')
mealplan_type = serializers.ReadOnlyField(source='mealplan.meal_type.name')
servings = CustomDecimalField()
def get_name(self, obj):
@ -1064,14 +1086,14 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
class Meta:
model = ShoppingListRecipe
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note')
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note', 'mealplan_from_date',
'mealplan_type')
read_only_fields = ('id',)
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
food = FoodSerializer(allow_null=True)
unit = UnitSerializer(allow_null=True, required=False)
ingredient_note = serializers.ReadOnlyField(source='ingredient.note')
recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True)
amount = CustomDecimalField()
created_by = UserSerializer(read_only=True)
@ -1082,7 +1104,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
# autosync values are only needed for frequent 'checked' value updating
if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))):
for f in list(set(fields) - set(['id', 'checked'])):
for f in list(set(fields) - set(['id', 'checked', 'updated_at', ])):
del fields[f]
return fields
@ -1124,11 +1146,16 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
class Meta:
model = ShoppingListEntry
fields = (
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked',
'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked',
'recipe_mealplan',
'created_by', 'created_at', 'completed_at', 'delay_until'
'created_by', 'created_at', 'updated_at', 'completed_at', 'delay_until'
)
read_only_fields = ('id', 'created_by', 'created_at',)
read_only_fields = ('id', 'created_by', 'created_at','updated_at',)
class ShoppingListEntryBulkSerializer(serializers.Serializer):
ids = serializers.ListField()
checked = serializers.BooleanField()
# TODO deprecate
@ -1283,7 +1310,8 @@ class InviteLinkSerializer(WritableNestedModelSerializer):
class Meta:
model = InviteLink
fields = (
'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by', 'created_at',)
'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by',
'created_at',)
read_only_fields = ('id', 'uuid', 'created_by', 'created_at',)

View File

@ -480,7 +480,7 @@ hr {
margin-top: 1rem;
margin-bottom: 1rem;
border: 0;
border-top: 1px solid rgba(0, 0, 0, .1)
border-top: 1px solid #ced4da;
}
.small, small {

View File

@ -2850,89 +2850,41 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
color: #fff
}
.btn-primary:hover {
background: transparent;
color: #b98766;
border: 1px solid #b98766
}
.btn-secondary {
transition: all .5s ease-in-out;
color: #fff
}
.btn-secondary:hover {
background: transparent;
color: #b55e4f;
border: 1px solid #b55e4f
}
.btn-success {
transition: all .5s ease-in-out;
color: #fff
}
.btn-success:hover {
background: transparent;
color: #82aa8b;
border: 1px solid #82aa8b
}
.btn-info {
transition: all .5s ease-in-out;
color: #fff
}
.btn-info:hover {
background: transparent;
color: #385f84;
border: 1px solid #385f84
}
.btn-warning {
transition: all .5s ease-in-out;
color: #fff
}
.btn-warning:hover {
background: transparent;
color: #eaaa21;
border: 1px solid #eaaa21
}
.btn-danger {
transition: all .5s ease-in-out;
color: #fff
}
.btn-danger:hover {
background: transparent;
color: #a7240e;
border: 1px solid #a7240e
}
.btn-light {
transition: all .5s ease-in-out;
color: #fff
}
.btn-light:hover {
background-color: hsla(0, 0%, 18%, .5);
color: #cfd5cd;
border: 1px solid hsla(0, 0%, 18%, .5)
}
.btn-dark {
transition: all .5s ease-in-out;
color: #fff
}
.btn-dark:hover {
background: transparent;
color: #221e1e;
border: 1px solid #221e1e
}
.btn-opacity-primary {
color: #b98766;
background-color: #0012a7;
@ -6155,7 +6107,7 @@ a.close.disabled {
padding: .5rem .75rem;
margin-bottom: 0;
font-size: 1rem;
background-color: #f7f7f7;
background-color: #242424;
border-bottom: 1px solid #ebebeb;
border-top-left-radius: calc(.3rem - 1px);
border-top-right-radius: calc(.3rem - 1px)

View File

@ -404,7 +404,7 @@
</div>
{% endif %}
<div class="container-fluid mt-2 mt-md-5 mt-xl-5 mt-lg-5{% if request.user.userpreference.left_handed %} left-handed {% endif %}"
<div class="container-fluid mt-2 mt-md-3 mt-xl-3 mt-lg-3{% if request.user.userpreference.left_handed %} left-handed {% endif %}"
id="id_base_container">
<div class="row">
<div class="col-xl-2 d-none d-xl-block">

View File

@ -2,6 +2,7 @@
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% block title %} {{ title }} {% endblock %}
{% block content_fluid %}
@ -10,7 +11,10 @@
<shopping-list-view></shopping-list-view>
</div>
{% endblock %} {% block script %} {% if debug %}
{% endblock %}
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
@ -22,4 +26,6 @@
window.SHOPPING_MIN_AUTOSYNC_INTERVAL = {{ SHOPPING_MIN_AUTOSYNC_INTERVAL }}
</script>
{% render_bundle 'shopping_list_view' %} {% endblock %}
{% render_bundle 'shopping_list_view' %}
{% endblock %}

View File

@ -13,7 +13,9 @@ DETAIL_URL = 'api:shoppinglistentry-detail'
@pytest.fixture()
def obj_1(space_1, u1_s1):
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1)
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1),
food=Food.objects.get_or_create(name='test 1', space=space_1)[0],
space=space_1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
return e
@ -21,7 +23,9 @@ def obj_1(space_1, u1_s1):
@pytest.fixture
def obj_2(space_1, u1_s1):
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 2', space=space_1)[0], space=space_1)
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1),
food=Food.objects.get_or_create(name='test 2', space=space_1)[0],
space=space_1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
return e
@ -79,6 +83,29 @@ def test_update(arg, request, obj_1):
assert response['amount'] == 2
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 403],
['u1_s1', 200],
['a1_s1', 200],
['g1_s2', 403],
['u1_s2', 200],
['a1_s2', 200],
])
def test_bulk_update(arg, request, obj_1, obj_2):
c = request.getfixturevalue(arg[0])
r = c.post(
reverse(LIST_URL, ) + 'bulk/',
{'ids': [obj_1.id, obj_2.id], 'checked': True},
content_type='application/json'
)
assert r.status_code == arg[1]
assert r
if r.status_code == 200:
obj_1.refresh_from_db()
assert obj_1.checked == (arg[0] == 'u1_s1')
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 201],

View File

@ -214,6 +214,9 @@ def test_completed(sle, u1_s1):
def test_recent(sle, u1_s1):
user = auth.get_user(u1_s1)
user.userpreference.shopping_recent_days = 7 # hardcoded API limit 14 days
user.userpreference.save()
today_start = timezone.now().replace(hour=0, minute=0, second=0)
# past_date within recent_days threshold

View File

@ -7,7 +7,7 @@ from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import Food, Ingredient
from cookbook.models import Food, Ingredient, ShoppingListRecipe, ShoppingListEntry
from cookbook.tests.factories import MealPlanFactory, RecipeFactory, StepFactory, UserFactory
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql':
@ -126,7 +126,7 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
all_ing = [x['ingredient'] for x in r]
all_ing = list(ShoppingListEntry.objects.filter(list_recipe__recipe=recipe).all().values_list('ingredient', flat=True))
keep_ing = all_ing[1:-1] # remove first and last element
del keep_ing[int(len(keep_ing) / 2)] # remove a middle element
list_recipe = r[0]['list_recipe']

View File

@ -30,6 +30,7 @@ from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import make_aware
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from icalendar import Calendar, Event
@ -102,7 +103,8 @@ from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer,
SupermarketCategorySerializer, SupermarketSerializer,
SyncLogSerializer, SyncSerializer, UnitConversionSerializer,
UnitSerializer, UserFileSerializer, UserPreferenceSerializer,
UserSerializer, UserSpaceSerializer, ViewLogSerializer)
UserSerializer, UserSpaceSerializer, ViewLogSerializer,
ShoppingListEntryBulkSerializer)
from cookbook.views.import_export import get_integration
from recipes import settings
from recipes.settings import FDC_API_KEY, DRF_THROTTLE_RECIPE_URL_IMPORT
@ -480,6 +482,7 @@ class SyncLogViewSet(viewsets.ReadOnlyModelViewSet):
class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin):
schema = FilterSchema()
queryset = Supermarket.objects
serializer_class = SupermarketSerializer
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
@ -489,7 +492,7 @@ class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin):
return super().get_queryset()
class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin):
class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin, MergeMixin):
queryset = SupermarketCategory.objects
model = SupermarketCategory
serializer_class = SupermarketCategorySerializer
@ -1160,11 +1163,47 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
if pk := self.request.query_params.getlist('id', []):
self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk])
if 'checked' in self.request.query_params or 'recent' in self.request.query_params:
if 'checked' in self.request.query_params:
return shopping_helper(self.queryset, self.request)
elif not self.detail:
today_start = timezone.now().replace(hour=0, minute=0, second=0)
week_ago = today_start - datetime.timedelta(days=min(self.request.user.userpreference.shopping_recent_days, 14))
self.queryset = self.queryset.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
try:
last_autosync = self.request.query_params.get('last_autosync', None)
if last_autosync:
last_autosync = datetime.datetime.fromtimestamp(int(last_autosync) / 1000, datetime.timezone.utc)
self.queryset = self.queryset.filter(updated_at__gte=last_autosync)
except:
traceback.print_exc()
# TODO once old shopping list is removed this needs updated to sharing users in preferences
if self.detail:
return self.queryset
else:
return self.queryset[:1000]
@decorators.action(
detail=False,
methods=['POST'],
serializer_class=ShoppingListEntryBulkSerializer,
permission_classes=[CustomIsUser]
)
def bulk(self, request):
serializer = self.serializer_class(data=request.data)
if serializer.is_valid():
ShoppingListEntry.objects.filter(
Q(created_by=self.request.user)
| Q(shoppinglist__shared=self.request.user)
| Q(created_by__in=list(self.request.user.get_shopping_share()))
).filter(space=request.space, id__in=serializer.validated_data['ids']).update(
checked=serializer.validated_data['checked'],
updated_at=timezone.now(),
)
return Response(serializer.data)
else:
return Response(serializer.errors, 400)
# TODO deprecate
@ -1174,11 +1213,13 @@ class ShoppingListViewSet(viewsets.ModelViewSet):
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
def get_queryset(self):
return self.queryset.filter(
self.queryset = self.queryset.filter(
Q(created_by=self.request.user)
| Q(shared=self.request.user)
| Q(created_by__in=list(self.request.user.get_shopping_share()))
).filter(space=self.request.space).distinct()
).filter(space=self.request.space)
return self.queryset.distinct()
def get_serializer_class(self):
try:
@ -1247,6 +1288,7 @@ class BookmarkletImportViewSet(viewsets.ModelViewSet):
class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin):
schema = FilterSchema()
queryset = UserFile.objects
serializer_class = UserFileSerializer
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]

View File

@ -23,7 +23,6 @@
"babel-loader": "^9.1.0",
"bootstrap-vue": "^2.23.1",
"core-js": "^3.29.1",
"html2pdf.js": "^0.10.1",
"lodash": "^4.17.21",
"mavon-editor": "^2.10.4",
"moment": "^2.29.4",
@ -86,7 +85,8 @@
"parser": "@typescript-eslint/parser"
},
"rules": {
"no-unused-vars": "off"
"no-unused-vars": "off",
"vue/no-unused-components": "warn"
}
},
"browserslist": [

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,45 @@
<div id="app">
<div>
<markdown-editor-component></markdown-editor-component>
<div class="swipe-container">
<div class="swipe-action bg-success">
<i class="swipe-icon fa-fw fas fa-check"></i>
</div>
<b-button-group class="swipe-element">
<div class="card flex-grow-1 btn-block p-2">
<div class="d-flex">
<div class="d-flex flex-column pr-2">
<span>
<span><i class="fas fa-check"></i> <b>100 g </b></span>
<br/>
</span>
<span>
<span><i class="fas fa-check"></i> <b>200 kg </b></span>
<br/>
</span>
</div>
<div class="d-flex flex-column flex-grow-1 align-self-center">
Erdbeeren <br/>
<span><small class="text-muted">vabene111</small></span>
</div>
</div>
</div>
<b-button variant="success">
<i class="d-print-none fa-fw fas fa-check"></i>
</b-button>
</b-button-group>
<div class="swipe-action bg-primary justify-content-end">
<i class="fa-fw fas fa-hourglass-half swipe-icon"></i>
</div>
</div>
</div>
</div>
</template>
@ -30,7 +67,7 @@ Vue.use(BootstrapVue)
export default {
name: "TestView",
mixins: [ApiMixin],
components: {MarkdownEditorComponent},
components: {},
computed: {},
data() {
return {}
@ -49,5 +86,46 @@ export default {
</script>
<style>
.swipe-container {
display: flex;
overflow: auto;
overflow-x: scroll;
scroll-snap-type: x mandatory;
}
/* scrollbar should be hidden */
.swipe-container::-webkit-scrollbar {
display: none;
}
.swipe-container {
scrollbar-width: none; /* For Firefox */
}
/* main element should always snap into view */
.swipe-element {
scroll-snap-align: start;
}
.swipe-icon {
color: white;
position: sticky;
left: 16px;
right: 16px;
}
/* swipe-actions and element should be 100% wide */
.swipe-action,
.swipe-element {
min-width: 100%;
}
.swipe-action {
display: flex;
align-items: center;
}
.right {
justify-content: flex-end;
}
</style>

View File

@ -11,14 +11,14 @@
<div class="flex-column" v-if="show_button_1">
<slot name="button_1">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_search' }" v-bind:href="resolveDjangoUrl('view_search')">
<i class="fas fa-fw fa-book " style="font-size: 1.5em"></i><br/><small>{{ $t('Recipes') }}</small></a> <!-- TODO localize -->
<i class="fas fa-fw fa-book " style="font-size: 1.4em"></i><br/><small>{{ $t('Recipes') }}</small></a> <!-- TODO localize -->
</slot>
</div>
<div class="flex-column" v-if="show_button_2">
<slot name="button_2">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_plan' }" v-bind:href="resolveDjangoUrl('view_plan')">
<i class="fas fa-calendar-alt" style="font-size: 1.5em"></i><br/><small>{{ $t('Meal_Plan') }}</small></a>
<i class="fas fa-calendar-alt" style="font-size: 1.4em"></i><br/><small>{{ $t('Meal_Plan') }}</small></a>
</slot>
</div>
@ -53,14 +53,14 @@
<div class="flex-column" v-if="show_button_3">
<slot name="button_3">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_shopping' }" v-bind:href="resolveDjangoUrl('view_shopping')">
<i class="fas fa-shopping-cart" style="font-size: 1.5em"></i><br/><small>{{ $t('Shopping_list') }}</small></a>
<i class="fas fa-shopping-cart" style="font-size: 1.4em"></i><br/><small>{{ $t('Shopping_list') }}</small></a>
</slot>
</div>
<div class="flex-column">
<slot name="button_4" v-if="show_button_4">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_books' }" v-bind:href="resolveDjangoUrl('view_books')">
<i class="fas fa-book-open" style="font-size: 1.5em"></i><br/><small>{{ $t('Books') }}</small></a> <!-- TODO localize -->
<i class="fas fa-book-open" style="font-size: 1.4em"></i><br/><small>{{ $t('Books') }}</small></a> <!-- TODO localize -->
</slot>
</div>

View File

@ -6,7 +6,6 @@
</template>
<script>
import html2pdf from "html2pdf.js"
export default {
name: "DownloadPDF",
@ -20,12 +19,7 @@ export default {
},
methods: {
downloadFile() {
const doc = document.querySelector(this.dom)
var options = {
margin: 1,
filename: this.name,
}
html2pdf().from(doc).set(options).save()
window.print()
},
},
}

View File

@ -186,8 +186,5 @@ export default {
}
</script>
<style>
.b-form-spinbutton.form-control {
background-color: #e9ecef;
border: 1px solid #ced4da;
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<div>
<b-button-group class="w-100 mt-1">
<b-button @click="updateNumber( 'half')" variant="outline-info"
:disabled="disable"><i class="fas fa-divide"></i> 2
</b-button>
<b-button variant="outline-info" @click="updateNumber( 'sub')"
:disabled="disable"><i class="fas fa-minus"></i>
</b-button>
<b-button variant="outline-info" @click="updateNumber('prompt')"
:disabled="disable">
{{ number }}
</b-button>
<b-button variant="outline-info" @click="updateNumber( 'add')"
:disabled="disable"><i class="fas fa-plus"></i>
</b-button>
<b-button @click="updateNumber('multiply')" variant="outline-info"
:disabled="disable"><i class="fas fa-times"></i> 2
</b-button>
</b-button-group>
</div>
</template>
<script>
import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
Vue.use(BootstrapVue)
export default {
name: "NumberScalerComponent",
props: {
number: {type: Number, default:0},
disable: {type: Boolean, default: false}
},
data() {
return {}
},
methods: {
/**
* perform given operation on linked number
* @param operation update mode
*/
updateNumber: function(operation) {
if (operation === 'half') {
this.$emit('change', this.number / 2)
}
if (operation === 'multiply') {
this.$emit('change', this.number * 2)
}
if (operation === 'add') {
this.$emit('change', this.number + 1)
}
if (operation === 'sub') {
this.$emit('change', this.number - 1)
}
if (operation === 'prompt') {
let input_number = prompt(this.$t('Input'), this.number);
if (input_number !== null && input_number !== "" && !isNaN(input_number) && !isNaN(parseFloat(input_number))) {
this.$emit('change', parseFloat(input_number))
} else {
console.log('Invalid number input in prompt', input_number)
}
}
},
},
}
</script>
<style scoped>
</style>

View File

@ -1,10 +1,10 @@
<template>
<div v-if="user_preferences !== undefined">
<div v-if="useUserPreferenceStore().user_settings !== undefined">
<b-form-group :label="$t('shopping_share')" :description="$t('shopping_share_desc')">
<generic-multiselect
@change="user_preferences.shopping_share = $event.val; updateSettings(false)"
@change="useUserPreferenceStore().user_settings.shopping_share = $event.val; updateSettings(false)"
:model="Models.USER"
:initial_selection="user_preferences.shopping_share"
:initial_selection="useUserPreferenceStore().user_settings.shopping_share"
label="display_name"
:multiple="true"
:placeholder="$t('User')"
@ -12,101 +12,98 @@
</b-form-group>
<b-form-group :label="$t('shopping_auto_sync')" :description="$t('shopping_auto_sync_desc')">
<b-form-input type="range" :min="SHOPPING_MIN_AUTOSYNC_INTERVAL" max="60" step="1" v-model="user_preferences.shopping_auto_sync"
@change="updateSettings(false)"></b-form-input>
<b-form-input type="range" :min="SHOPPING_MIN_AUTOSYNC_INTERVAL" max="60" step="1" v-model="useUserPreferenceStore().user_settings.shopping_auto_sync"
@change="updateSettings(false)" :disabled="useUserPreferenceStore().user_settings.shopping_auto_sync < 1"></b-form-input>
<div class="text-center">
<span v-if="user_preferences.shopping_auto_sync > 0">
{{ Math.round(user_preferences.shopping_auto_sync) }}
<span v-if="user_preferences.shopping_auto_sync === 1">{{ $t('Second') }}</span>
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync > 0">
{{ Math.round(useUserPreferenceStore().user_settings.shopping_auto_sync) }}
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync === 1">{{ $t('Second') }}</span>
<span v-else> {{ $t('Seconds') }}</span>
</span>
<span v-if="user_preferences.shopping_auto_sync < 1">{{ $t('Disable') }}</span>
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync < 1">{{ $t('Disable') }}</span>
</div>
<br/>
<b-button class="btn btn-sm" @click="user_preferences.shopping_auto_sync = 0; updateSettings(false)">{{ $t('Disabled') }}</b-button>
<b-button class="btn btn-sm" @click="useUserPreferenceStore().user_settings.shopping_auto_sync = 0; updateSettings(false)"
v-if="useUserPreferenceStore().user_settings.shopping_auto_sync > 0">{{ $t('Disable') }}</b-button>
<b-button class="btn btn-sm btn-success" @click="useUserPreferenceStore().user_settings.shopping_auto_sync = SHOPPING_MIN_AUTOSYNC_INTERVAL; updateSettings(false)"
v-if="useUserPreferenceStore().user_settings.shopping_auto_sync < 1">{{ $t('Enable') }}</b-button>
</b-form-group>
<b-form-group :description="$t('mealplan_autoadd_shopping_desc')">
<b-form-checkbox v-model="user_preferences.mealplan_autoadd_shopping"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoadd_shopping"
@change="updateSettings(false)">{{ $t('mealplan_autoadd_shopping') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :description="$t('mealplan_autoexclude_onhand_desc')">
<b-form-checkbox v-model="user_preferences.mealplan_autoexclude_onhand"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoexclude_onhand"
@change="updateSettings(false)">{{ $t('mealplan_autoexclude_onhand') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :description="$t('mealplan_autoinclude_related_desc')">
<b-form-checkbox v-model="user_preferences.mealplan_autoinclude_related"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoinclude_related"
@change="updateSettings(false)">{{ $t('mealplan_autoinclude_related') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :description="$t('shopping_add_onhand_desc')">
<b-form-checkbox v-model="user_preferences.shopping_add_onhand"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.shopping_add_onhand"
@change="updateSettings(false)">{{ $t('shopping_add_onhand') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :label="$t('default_delay')" :description="$t('default_delay_desc')">
<b-form-input type="range" min="1" max="72" step="1" v-model="user_preferences.default_delay"
<b-form-input type="range" min="1" max="72" step="1" v-model="useUserPreferenceStore().user_settings.default_delay"
@change="updateSettings(false)"></b-form-input>
<div class="text-center">
<span>{{ Math.round(user_preferences.default_delay) }}
<span v-if="user_preferences.default_delay === 1">{{ $t('Hour') }}</span>
<span>{{ Math.round(useUserPreferenceStore().user_settings.default_delay) }}
<span v-if="useUserPreferenceStore().user_settings.default_delay === 1">{{ $t('Hour') }}</span>
<span v-else> {{ $t('Hours') }}</span>
</span>
</div>
</b-form-group>
<b-form-group :description="$t('filter_to_supermarket_desc')">
<b-form-checkbox v-model="user_preferences.filter_to_supermarket"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.filter_to_supermarket"
@change="updateSettings(false)">{{ $t('filter_to_supermarket') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :label="$t('shopping_recent_days')" :description="$t('shopping_recent_days_desc')">
<b-form-input type="range" min="0" max="14" step="1" v-model="user_preferences.shopping_recent_days"
<b-form-input type="range" min="0" max="14" step="1" v-model="useUserPreferenceStore().user_settings.shopping_recent_days"
@change="updateSettings(false)"></b-form-input>
<div class="text-center">
<span>{{ Math.round(user_preferences.shopping_recent_days) }}
<span v-if="user_preferences.shopping_recent_days === 1">{{ $t('Day') }}</span>
<span>{{ Math.round(useUserPreferenceStore().user_settings.shopping_recent_days) }}
<span v-if="useUserPreferenceStore().user_settings.shopping_recent_days === 1">{{ $t('Day') }}</span>
<span v-else> {{ $t('Days') }}</span>
</span>
</div>
</b-form-group>
<b-form-group :label="$t('csv_delim_label')" :description="$t('csv_delim_help')">
<b-form-input v-model="user_preferences.csv_delim" @change="updateSettings(false)"></b-form-input>
<b-form-input v-model="useUserPreferenceStore().user_settings.csv_delim" @change="updateSettings(false)"></b-form-input>
</b-form-group>
<b-form-group :label="$t('csv_prefix_label')" :description="$t('csv_prefix_help')">
<b-form-input v-model="user_preferences.csv_prefix" @change="updateSettings(false)"></b-form-input>
<b-form-input v-model="useUserPreferenceStore().user_settings.csv_prefix" @change="updateSettings(false)"></b-form-input>
</b-form-group>
</div>
</template>
<script>
import {ApiApiFactory} from "@/utils/openapi/api";
import {ApiMixin, StandardToasts} from "@/utils/utils";
import axios from "axios";
import GenericMultiselect from "@/components/GenericMultiselect";
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
export default {
name: "ShoppingSettingsComponent",
mixins: [ApiMixin],
components: {GenericMultiselect},
props: {
user_id: Number,
},
props: { },
data() {
return {
user_preferences: undefined,
@ -115,30 +112,15 @@ export default {
}
},
mounted() {
this.user_preferences = this.preferences
this.languages = window.AVAILABLE_LANGUAGES
this.loadSettings()
useUserPreferenceStore().loadUserSettings(false)
},
methods: {
loadSettings: function () {
let apiFactory = new ApiApiFactory()
apiFactory.retrieveUserPreference(this.user_id.toString()).then(result => {
this.user_preferences = result.data
}).catch(err => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
},
useUserPreferenceStore,
updateSettings: function (reload) {
let apiFactory = new ApiApiFactory()
this.$emit('updated', this.user_preferences)
apiFactory.partialUpdateUserPreference(this.user_id.toString(), this.user_preferences).then(result => {
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
if (reload) {
location.reload()
}
}).catch(err => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
useUserPreferenceStore().updateUserSettings()
},
}
}

View File

@ -1,176 +1,134 @@
<template>
<div id="shopping_line_item" class="pt-1">
<b-row align-h="start"
ref="shopping_line_item" class="invis-border">
<b-col cols="2" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0"
variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
:class="showDetails ? 'rotated' : ''"></i></div>
<div class="swipe-container" :id="item_container_id" @touchend="handleSwipe()"
v-if="(useUserPreferenceStore().device_settings.shopping_show_checked_entries || !is_checked) && (useUserPreferenceStore().device_settings.shopping_show_delayed_entries || !is_delayed)"
>
<div class="swipe-action" :class="{'bg-success': !is_checked , 'bg-warning': is_checked }">
<i class="swipe-icon fa-fw fas" :class="{'fa-check': !is_checked , 'fa-cart-plus': is_checked }"></i>
</div>
<b-button-group class="swipe-element">
<b-button variant="primary" v-if="is_delayed">
<i class="fa-fw fas fa-hourglass-half"></i>
</b-button>
</b-col>
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
@click.stop="$emit('open-context-menu', $event, entries)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-0 pl-1 pr-md-3 pl-md-3 dropdown-toggle-no-caret">
<i class="fas fa-ellipsis-v"></i>
</button>
<div class="card flex-grow-1 btn-block p-2" @click="detail_modal_visible = true">
<div class="d-flex">
<div class="d-flex flex-column pr-2" v-if="Object.keys(amounts).length> 0">
<span v-for="a in amounts" v-bind:key="a.id">
<span><i class="fas fa-check" v-if="a.checked && !is_checked"></i><i class="fas fa-hourglass-half" v-if="a.delayed && !a.checked"></i> <b>{{ a.amount }} {{
a.unit
}} </b></span>
<br/></span>
</div>
</b-col>
<b-col cols="1" class="px-1 justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
</b-col>
<b-col cols="8">
<b-row class="d-flex h-100">
<b-col cols="6" md="3" class="d-flex align-items-center" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
v-if="Object.entries(formatAmount).length == 1">
<strong class="mr-1">{{ Object.entries(formatAmount)[0][1] }}</strong>
{{ Object.entries(formatAmount)[0][0] }}
</b-col>
<b-col cols="6" md="3" class="d-flex flex-column" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
v-if="Object.entries(formatAmount).length != 1">
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">
{{ x[1] }} &ensp;
{{ x[0] }}
<div class="d-flex flex-column flex-grow-1 align-self-center">
{{ food.name }} <br/>
<span v-if="info_row"><small class="text-muted">{{ info_row }}</small></span>
</div>
</div>
</div>
<b-button variant="success" @click="useShoppingListStore().setEntriesCheckedState(entries, !is_checked, true)"
:class="{'btn-success': !is_checked, 'btn-warning': is_checked}">
<i class="d-print-none fa-fw fas" :class="{'fa-check': !is_checked , 'fa-cart-plus': is_checked }"></i>
</b-button>
</b-button-group>
<div class="swipe-action bg-primary justify-content-end">
<i class="fa-fw fas fa-hourglass-half swipe-icon"></i>
</div>
<b-modal v-model="detail_modal_visible" @hidden="detail_modal_visible = false" body-class="pr-4 pl-4 pt-0">
<template #modal-title>
<h5> {{ food_row }}</h5>
<small class="text-muted">{{ food.description }}</small>
</template>
<template #default>
<h5 class="mt-2">{{ $t('Quick actions') }}</h5>
{{ $t('Category') }}
<b-form-select
class="form-control mb-2"
:options="useShoppingListStore().supermarket_categories"
text-field="name"
value-field="id"
v-model="food.supermarket_category"
@change="detail_modal_visible = false; updateFoodCategory(food)"
></b-form-select>
<b-button variant="info" block
@click="detail_modal_visible = false;useShoppingListStore().delayEntries(entries,!is_delayed, true)">
{{ $t('Postpone') }}
</b-button>
<h6 class="mt-2">{{ $t('Entries') }}</h6>
<b-row v-for="e in entries" v-bind:key="e.id">
<b-col cold="12">
<b-button-group class="w-100">
<div class="card flex-grow-1 btn-block p-2">
<span><i class="fas fa-check" v-if="e.checked"></i><i class="fas fa-hourglass-half" v-if="e.delay_until !== null && !e.checked"></i>
<b><span v-if="e.amount > 0">{{ e.amount }}</span> {{ e.unit?.name }}</b> {{ food.name }}</span>
<span><small class="text-muted">
<span v-if="e.recipe_mealplan && e.recipe_mealplan.recipe_name !== ''">
<a :href="resolveDjangoUrl('view_recipe', e.recipe_mealplan.recipe)"> <b> {{
e.recipe_mealplan.recipe_name
}} </b></a>({{
e.recipe_mealplan.servings
}} {{ $t('Servings') }})<br/>
</span>
<span
v-if="e.recipe_mealplan && e.recipe_mealplan.mealplan_type !== undefined"> {{
e.recipe_mealplan.mealplan_type
}} {{ formatDate(e.recipe_mealplan.mealplan_from_date) }} <br/></span>
{{ e.created_by.display_name }} {{ formatDate(e.created_at) }}<br/>
</small></span>
</div>
<b-button variant="outline-danger"
@click="useShoppingListStore().deleteObject(e)"><i
class="fas fa-trash"></i></b-button>
</b-button-group>
<generic-multiselect
class="mt-1"
v-if="e.recipe_mealplan === null"
:initial_single_selection="e.unit"
:model="Models.UNIT"
:multiple="false"
@change="e.unit = $event.val; useShoppingListStore().updateObject(e)"
>
</generic-multiselect>
<number-scaler-component :number="e.amount"
@change="e.amount = $event; useShoppingListStore().updateObject(e)"
v-if="e.recipe_mealplan === null"></number-scaler-component>
<hr class="m-2"/>
</b-col>
<b-col cols="6" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatFood }}
</b-col>
<b-col cols="3" data-html2canvas-ignore="true" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
class="align-items-center d-none d-md-flex justify-content-end">
<b-button size="sm" @click="showDetails = !showDetails"
class="p-0 mr-0 mr-md-2 p-md-2 text-decoration-none" variant="link">
<div class="text-nowrap">
<i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
<span class="d-none d-md-inline-block"><span class="ml-2">{{
$t("Details")
}}</span></span>
</div>
</b-row>
<b-button variant="success" block @click="useShoppingListStore().createObject({ amount: 0, unit: null, food: food, })"> {{ $t("Add") }}</b-button>
<b-button variant="warning" block @click="detail_modal_visible = false; setFoodIgnoredAndChecked(food)"> {{ $t("Ignore_Shopping") }}</b-button>
<b-button variant="danger" block class="mt-2"
@click="detail_modal_visible = false;useShoppingListStore().deleteEntries(entries)">
{{ $t('Delete_All') }}
</b-button>
</b-col>
</b-row>
</b-col>
<b-col cols="2" class="justify-content-start align-items-center d-flex d-md-none pl-0 pr-0" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
v-if="!settings.left_handed">
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0"
variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
:class="showDetails ? 'rotated' : ''"></i></div>
</b-button>
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
</b-col>
</b-row>
<b-row align-h="center" class="d-none d-md-flex">
<b-col cols="12">
<div class="small text-muted text-truncate">{{ formatHint }}</div>
</b-col>
</b-row>
<!-- detail rows -->
<div class="card no-body mb-1 pt-2 align-content-center shadow-sm" v-if="showDetails">
<div v-for="(e, x) in entries" :key="e.id">
<b-row class="small justify-content-around">
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn btn-link btn-sm m-0 p-0 pl-2"
style="text-overflow: ellipsis"
@click.stop="openRecipeCard($event, e)"
@mouseover="openRecipeCard($event, e)"
>
{{ formatOneRecipe(e) }}
</button>
</b-col>
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap pr-4">
{{ formatOneCreatedBy(e) }}
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
</b-col>
</b-row>
<b-row align-h="start">
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
</b-col>
<b-col cols="2" md="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
@click.stop="$emit('open-context-menu', $event, e)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 pr-md-3 pl-md-3 dropdown-toggle-no-caret"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</b-col>
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
</b-col>
<b-col cols="7" md="9">
<b-row class="d-flex align-items-center h-100">
<b-col cols="5" md="3" class="d-flex align-items-center">
<strong class="mr-1">{{ formatOneAmount(e) }}</strong> {{ formatOneUnit(e) }}
</b-col>
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatOneFood(e) }}
</b-col>
<b-col cols="12" class="d-flex d-md-none">
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)"
:key="i">{{ n }}
</div>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
v-if="!settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
</b-col>
</b-row>
<hr class="w-75 mt-1 mb-1 mt-md-3 mb-md-3" v-if="x !== entries.length - 1"/>
<div class="pb-1 pb-md-4" v-if="x === entries.length - 1"></div>
</div>
</div>
<hr class="m-1" v-if="!showDetails"/>
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300">
<template #menu="{ contextData }" v-if="recipe">
<ContextMenuItem>
<RecipeCard :recipe="contextData" :detail="false"></RecipeCard>
</ContextMenuItem>
<ContextMenuItem @click="$refs.menu.close()">
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
<template #label>
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i>
{{ $t("Servings") }}</a>
</template>
<div @click.prevent.stop>
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
</div>
</b-form-group>
</ContextMenuItem>
<template #modal-footer>
<span></span>
</template>
</ContextMenu>
<i class="fa fa-hourglass fa-lg" style="display: none; position: absolute" aria-hidden="true"
ref="delay_icon"></i>
<i class="fa fa-check fa-lg" style="display: none; position: absolute" aria-hidden="true" ref="check_icon"></i>
</b-modal>
<generic-modal-form :model="Models.FOOD" :show="editing_food !== null"
@hidden="editing_food = null; useShoppingListStore().refreshFromAPI()"></generic-modal-form>
</div>
</template>
@ -178,292 +136,237 @@
import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import ContextMenu from "@/components/ContextMenu/ContextMenu"
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
import {ApiMixin} from "@/utils/utils"
import RecipeCard from "./RecipeCard.vue"
import Vue2TouchEvents from "vue2-touch-events"
import {ApiMixin, FormatMixin, resolveDjangoUrl, StandardToasts} from "@/utils/utils"
import {useMealPlanStore} from "@/stores/MealPlanStore";
import {useShoppingListStore} from "@/stores/ShoppingListStore";
import {ApiApiFactory} from "@/utils/openapi/api";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import NumberScalerComponent from "@/components/NumberScalerComponent.vue";
import GenericModalForm from "@/components/Modals/GenericModalForm.vue";
import GenericMultiselect from "@/components/GenericMultiselect.vue";
Vue.use(BootstrapVue)
Vue.use(Vue2TouchEvents)
export default {
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly
name: "ShoppingLineItem",
mixins: [ApiMixin],
components: {RecipeCard, ContextMenu, ContextMenuItem},
mixins: [ApiMixin, FormatMixin],
components: {GenericMultiselect, GenericModalForm, NumberScalerComponent},
props: {
entries: {
type: Array,
},
settings: Object,
groupby: {type: String},
entries: {type: Object,},
},
data() {
return {
showDetails: false,
recipe: undefined,
servings: 1,
dragStartX: 0,
distance_left: 0
detail_modal_visible: false,
editing_food: null,
}
},
computed: {
formatAmount: function () {
let amount = {}
this.entries.forEach((entry) => {
let unit = entry?.unit?.name ?? "---"
if (entry.amount) {
if (amount[unit]) {
amount[unit] += entry.amount
} else {
amount[unit] = entry.amount
item_container_id: function () {
let id = 'id_sli_'
for (let i in this.entries) {
id += i + '_'
}
return id
},
is_checked: function () {
for (let i in this.entries) {
if (!this.entries[i].checked) {
return false
}
}
})
for (const [k, v] of Object.entries(amount)) {
amount[k] = Math.round(v * 100 + Number.EPSILON) / 100 // javascript hack to force rounding at 2 places
return true
},
is_delayed: function () {
for (let i in this.entries) {
if (Date.parse(this.entries[i].delay_until) > new Date(Date.now())) {
return true
}
return amount
},
formatCategory: function () {
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
},
formatChecked: function () {
return this.entries.map((x) => x.checked).every((x) => x === true)
},
formatHint: function () {
if (this.groupby == "recipe") {
return this.formatCategory
} else {
return this.formatRecipe
}
return false
},
formatFood: function () {
return this.formatOneFood(this.entries[0])
food: function () {
return this.entries[Object.keys(this.entries)[0]]['food']
},
formatUnit: function () {
return this.formatOneUnit(this.entries[0])
},
formatRecipe: function () {
if (this.entries?.length == 1) {
return this.formatOneMealPlan(this.entries[0]) || ""
} else {
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
// return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
amounts: function () {
let unit_amounts = {}
return mealplan_name
.map((x) => {
return this.formatOneMealPlan(x)
})
.join(" - ")
for (let i in this.entries) {
let e = this.entries[i]
if (!e.checked && e.delay_until === null
|| (e.checked && useUserPreferenceStore().device_settings.shopping_show_checked_entries)
|| (e.delay_until !== null && useUserPreferenceStore().device_settings.shopping_show_delayed_entries)) {
let unit = -1
if (e.unit !== undefined && e.unit !== null) {
unit = e.unit.id
}
},
formatNotes: function () {
if (this.entries?.length == 1) {
return this.formatOneNote(this.entries[0]) || ""
if (e.amount > 0) {
if (unit in unit_amounts) {
unit_amounts[unit]['amount'] += e.amount
} else {
if (unit === -1) {
unit_amounts[unit] = {id: -1, unit: "", amount: e.amount, checked: e.checked, delayed: (e.delay_until !== null)}
} else {
unit_amounts[unit] = {id: e.unit.id, unit: e.unit.name, amount: e.amount, checked: e.checked, delayed: (e.delay_until !== null)}
}
return ""
}
}
}
}
return unit_amounts
},
food_row: function () {
return this.food.name
},
info_row: function () {
let info_row = []
let authors = []
let recipes = []
let meal_pans = []
for (let i in this.entries) {
let e = this.entries[i]
if (authors.indexOf(e.created_by.display_name) === -1) {
authors.push(e.created_by.display_name)
}
if (e.recipe_mealplan !== null) {
let recipe_name = e.recipe_mealplan.recipe_name
if (recipes.indexOf(recipe_name) === -1) {
recipes.push(recipe_name.substring(0, 14) + (recipe_name.length > 14 ? '..' : ''))
}
if ('mealplan_from_date' in e.recipe_mealplan) {
let meal_plan_entry = (e?.recipe_mealplan?.mealplan_type || '') + ' (' + this.formatDate(e.recipe_mealplan.mealplan_from_date) + ')'
if (meal_pans.indexOf(meal_plan_entry) === -1) {
meal_pans.push(meal_plan_entry)
}
}
}
}
if (useUserPreferenceStore().device_settings.shopping_item_info_created_by && authors.length > 0) {
info_row.push(authors.join(', '))
}
if (useUserPreferenceStore().device_settings.shopping_item_info_recipe && recipes.length > 0) {
info_row.push(recipes.join(', '))
}
if (useUserPreferenceStore().device_settings.shopping_item_info_mealplan && meal_pans.length > 0) {
info_row.push(meal_pans.join(', '))
}
return info_row.join(' - ')
}
},
watch: {},
mounted() {
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
},
methods: {
// this.genericAPI inherited from ApiMixin
useUserPreferenceStore,
useShoppingListStore,
resolveDjangoUrl,
/**
* update the food after the category was changed
* handle changing category to category ID as a workaround
* @param food
*/
updateFoodCategory: function (food) {
if (typeof food.supermarket_category === "number") { // not the best solution, but as long as generic multiselect does not support caching, I don't want to use a proper model
food.supermarket_category = this.useShoppingListStore().supermarket_categories.filter(sc => sc.id === food.supermarket_category)[0]
}
formatDate: function (datetime) {
if (!datetime) {
return
}
return Intl.DateTimeFormat(window.navigator.language, {
dateStyle: "short",
timeStyle: "short",
}).format(Date.parse(datetime))
},
startHandler: function (event) {
if (event.changedTouches.length > 0) {
this.dragStartX = event.changedTouches[0].clientX
}
},
getOffset(el) {
let rect = el.getBoundingClientRect();
return {
left: rect.left + window.scrollX,
top: rect.top + window.scrollY,
right: rect.right - window.scrollX,
};
},
moveHandler: function (event) {
let item = this.$refs['shopping_line_item'];
this.distance_left = event.changedTouches[0].clientX - this.dragStartX;
item.style.marginLeft = this.distance_left
item.style.marginRight = -this.distance_left
item.style.backgroundColor = '#ddbf86'
item.style.border = "1px solid #000"
let apiClient = new ApiApiFactory()
apiClient.updateFood(food.id, food).then(r => {
let delay_icon = this.$refs['delay_icon']
let check_icon = this.$refs['check_icon']
let color_factor = Math.abs(this.distance_left) / 100
if (this.distance_left > 0) {
item.parentElement.parentElement.style.backgroundColor = 'rgba(130,170,139,0)'.replace(/[^,]+(?=\))/, color_factor)
check_icon.style.display = "block"
check_icon.style.left = this.getOffset(item.parentElement.parentElement).left + 40
check_icon.style.top = this.getOffset(item.parentElement.parentElement).top - 92
check_icon.style.opacity = color_factor - 0.3
} else {
item.parentElement.parentElement.style.backgroundColor = 'rgba(185,135,102,0)'.replace(/[^,]+(?=\))/, color_factor)
delay_icon.style.display = "block"
delay_icon.style.left = this.getOffset(item.parentElement.parentElement).right - 40
delay_icon.style.top = this.getOffset(item.parentElement.parentElement).top - 92
delay_icon.style.opacity = color_factor - 0.3
}
},
endHandler: function (event) {
let item = this.$refs['shopping_line_item'];
item.removeAttribute('style');
item.parentElement.parentElement.removeAttribute('style');
let delay_icon = this.$refs['delay_icon']
let check_icon = this.$refs['check_icon']
delay_icon.style.display = "none"
check_icon.style.display = "none"
if (Math.abs(this.distance_left) > window.screen.width / 6) {
if (this.distance_left > 0) {
let checked = false;
this.entries.forEach((cur) => {
checked = cur.checked
})
let update = {entries: this.entries.map((x) => x.id), checked: !checked}
this.$emit("update-checkbox", update)
} else {
this.$emit("update-delaythis", this.entries)
}
}
},
formatOneAmount: function (item) {
return item?.amount ?? 1
},
formatOneUnit: function (item) {
return item?.unit?.name ?? ""
},
formatOneCategory: function (item) {
return item?.food?.supermarket_category?.name
},
formatOneCompletedAt: function (item) {
if (!item.completed_at) {
return false
}
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
},
formatOneFood: function (item) {
return item.food.name
},
formatOneDelayUntil: function (item) {
if (!item.delay_until || (item.delay_until && item.checked)) {
return false
}
return [this.$t("DelayUntil"), "-", this.formatDate(item.delay_until)].join(" ")
},
formatOneMealPlan: function (item) {
return item?.recipe_mealplan?.name ?? ""
},
formatOneRecipe: function (item) {
return item?.recipe_mealplan?.recipe_name ?? ""
},
formatOneNote: function (item) {
if (!item) {
item = this.entries[0]
}
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
},
formatOneCreatedBy: function (item) {
return [this.$t("Added_by"), item?.created_by.display_name, "@", this.formatDate(item.created_at)].join(" ")
},
openRecipeCard: function (e, item) {
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, {id: item.recipe_mealplan.recipe}).then((result) => {
let recipe = result.data
recipe.steps = undefined
this.recipe = true
this.$refs.recipe_card.open(e, recipe)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
updateChecked: function (e, item) {
let update = undefined
if (!item) {
update = {entries: this.entries.map((x) => x.id), checked: !this.formatChecked}
} else {
update = {entries: [item], checked: !item.checked}
}
console.log(update)
this.$emit("update-checkbox", update)
/**
* set food on_hand status to true and check all associated entries
* @param food
*/
setFoodIgnoredAndChecked: function (food) {
let apiClient = new ApiApiFactory()
food.ignore_shopping = true
apiClient.updateFood(food.id, food).then(r => {
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
useShoppingListStore().setEntriesCheckedState(this.entries, true, false)
},
/**
* function triggered by touchend event of swipe container
* check if min distance is reached and execute desired action
*/
handleSwipe: function () {
const minDistance = 80;
const container = document.querySelector('#' + this.item_container_id);
// get the distance the user swiped
const swipeDistance = container.scrollLeft - container.clientWidth;
if (swipeDistance < minDistance * -1) {
useShoppingListStore().setEntriesCheckedState(this.entries, !this.is_checked, true)
} else if (swipeDistance > minDistance) {
useShoppingListStore().delayEntries(this.entries, !this.is_delayed, true)
}
}
},
}
</script>
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
<style>
/* table { border-collapse:collapse } /* Ensure no space between cells */
/* tr.strikeout td { position:relative } /* Setup a new coordinate system */
/* tr.strikeout td:before { /* Create a new element that */
/* content: " "; /* …has no text content */
/* position: absolute; /* …is absolutely positioned */
/* left: 0; top: 50%; width: 100%; /* …with the top across the middle */
/* border-bottom: 1px solid #000; /* …and with a border on the top */
/* } */
.checkbox-control {
font-size: 0.6rem;
/* scroll snap takes care of restoring scroll position */
.swipe-container {
display: flex;
overflow: auto;
overflow-x: scroll;
scroll-snap-type: x mandatory;
}
.checkbox-control-mobile {
font-size: 1rem;
/* scrollbar should be hidden */
.swipe-container::-webkit-scrollbar {
display: none;
}
.rotate {
-moz-transition: all 0.25s linear;
-webkit-transition: all 0.25s linear;
transition: all 0.25s linear;
.swipe-container {
scrollbar-width: none; /* For Firefox */
}
.rotated {
-moz-transform: rotate(90deg);
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
/* main element should always snap into view */
.swipe-element {
scroll-snap-align: start;
}
.unit-badge-lg {
font-size: 1rem !important;
font-weight: 500 !important;
.swipe-icon {
color: white;
position: sticky;
left: 16px;
right: 16px;
}
@media (max-width: 768px) {
.dropdown-spacing {
padding-left: 0 !important;
padding-right: 0 !important;
}
/* swipe-actions and element should be 100% wide */
.swipe-action,
.swipe-element {
min-width: 100%;
}
.invis-border {
border: 1px solid transparent;
.swipe-action {
display: flex;
align-items: center;
}
@media (min-width: 992px) {
.fa-ellipsis-v {
font-size: 20px;
}
.right {
justify-content: flex-end;
}
@media (min-width: 576px) {
.fa-ellipsis-v {
font-size: 16px;
}
}
</style>

View File

@ -96,6 +96,9 @@
"base_unit": "Base Unit",
"base_amount": "Base Amount",
"Datatype": "Datatype",
"Input": "Input",
"Undo": "Undo",
"NoMoreUndo": "No changes to be undone.",
"Number of Objects": "Number of Objects",
"Add_Step": "Add Step",
"Keywords": "Keywords",
@ -141,6 +144,7 @@
"Edit": "Edit",
"Image": "Image",
"Delete": "Delete",
"Delete_All": "Delete all",
"Open": "Open",
"Ok": "Ok",
"Save": "Save",
@ -239,6 +243,7 @@
"Week": "Week",
"Month": "Month",
"Year": "Year",
"created_by": "Created by",
"Planner": "Planner",
"Planner_Settings": "Planner settings",
"Period": "Period",
@ -295,9 +300,11 @@
"Warning": "Warning",
"NoCategory": "No category selected.",
"InheritWarning": "{food} is set to inherit, changes may not persist.",
"ShowDelayed": "Show Delayed Items",
"ShowDelayed": "Show delayed items",
"ShowRecentlyCompleted": "Show recently completed items",
"Completed": "Completed",
"OfflineAlert": "You are offline, shopping list may not syncronize.",
"ShoppingBackgroundSyncWarning": "Bad network, waiting to sync ...",
"shopping_share": "Share Shopping List",
"shopping_auto_sync": "Autosync",
"one_url_per_line": "One URL per line",
@ -496,6 +503,7 @@
"Reset": "Reset",
"Disabled": "Disabled",
"Disable": "Disable",
"Enable": "Enable",
"Options": "Options",
"Create Food": "Create Food",
"create_food_desc": "Create a food and link it to this recipe.",

View File

@ -0,0 +1,491 @@
import {ApiApiFactory} from "@/utils/openapi/api"
import {StandardToasts} from "@/utils/utils"
import {defineStore} from "pinia"
import Vue from "vue"
import _ from 'lodash';
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import moment from "moment/moment";
const _STORE_ID = "shopping_list_store"
/*
* test store to play around with pinia and see if it can work for my use cases
* don't trust that all shopping list entries are in store as there is no cache validation logic, its just a shared data holder
* */
export const useShoppingListStore = defineStore(_STORE_ID, {
state: () => ({
// shopping data
entries: {},
supermarket_categories: [],
supermarkets: [],
total_unchecked: 0,
total_checked: 0,
total_unchecked_food: 0,
total_checked_food: 0,
// internal
currently_updating: false,
last_autosync: null,
autosync_has_focus: true,
autosync_timeout_id: null,
undo_stack: [],
queue_timeout_id: undefined,
item_check_sync_queue: {},
// constants
GROUP_CATEGORY: 'food.supermarket_category.name',
GROUP_CREATED_BY: 'created_by.display_name',
GROUP_RECIPE: 'recipe_mealplan.recipe_name',
UNDEFINED_CATEGORY: 'shopping_undefined_category'
}),
getters: {
/**
* build a multi-level data structure ready for display from shopping list entries
* group by selected grouping key
* @return {{}}
*/
get_entries_by_group: function () {
let structure = {}
let ordered_structure = []
// build structure
for (let i in this.entries) {
structure = this.updateEntryInStructure(structure, this.entries[i], useUserPreferenceStore().device_settings.shopping_selected_grouping)
}
// statistics for UI conditions and display
let total_unchecked = 0
let total_checked = 0
let total_unchecked_food = 0
let total_checked_food = 0
for (let i in structure) {
let count_unchecked = 0
let count_checked = 0
let count_unchecked_food = 0
let count_checked_food = 0
for (let fi in structure[i]['foods']) {
let food_checked = true
for (let ei in structure[i]['foods'][fi]['entries']) {
if (structure[i]['foods'][fi]['entries'][ei].checked) {
count_checked++
} else {
food_checked = false
count_unchecked++
}
}
if (food_checked) {
count_checked_food++
} else {
count_unchecked_food++
}
}
Vue.set(structure[i], 'count_unchecked', count_unchecked)
Vue.set(structure[i], 'count_checked', count_checked)
Vue.set(structure[i], 'count_unchecked_food', count_unchecked_food)
Vue.set(structure[i], 'count_checked_food', count_checked_food)
total_unchecked += count_unchecked
total_checked += count_checked
total_unchecked_food += count_unchecked_food
total_checked_food += count_checked_food
}
this.total_unchecked = total_unchecked
this.total_checked = total_checked
this.total_unchecked_food = total_unchecked_food
this.total_checked_food = total_checked_food
// ordering
if (this.UNDEFINED_CATEGORY in structure) {
ordered_structure.push(structure[this.UNDEFINED_CATEGORY])
Vue.delete(structure, this.UNDEFINED_CATEGORY)
}
if (useUserPreferenceStore().device_settings.shopping_selected_grouping === this.GROUP_CATEGORY && useUserPreferenceStore().device_settings.shopping_selected_supermarket !== null) {
for (let c of useUserPreferenceStore().device_settings.shopping_selected_supermarket.category_to_supermarket) {
if (c.category.name in structure) {
ordered_structure.push(structure[c.category.name])
Vue.delete(structure, c.category.name)
}
}
if (!useUserPreferenceStore().device_settings.shopping_show_selected_supermarket_only) {
for (let i in structure) {
ordered_structure.push(structure[i])
}
}
} else {
for (let i in structure) {
ordered_structure.push(structure[i])
}
}
return ordered_structure
},
/**
* flattened list of entries used for exporters
* kinda uncool but works for now
* @return {*[]}
*/
get_flat_entries: function () {
let items = []
for (let i in this.get_entries_by_group) {
for (let f in this.get_entries_by_group[i]['foods']) {
for (let e in this.get_entries_by_group[i]['foods'][f]['entries']) {
items.push({
amount: this.get_entries_by_group[i]['foods'][f]['entries'][e].amount,
unit: this.get_entries_by_group[i]['foods'][f]['entries'][e].unit?.name ?? '',
food: this.get_entries_by_group[i]['foods'][f]['entries'][e].food?.name ?? '',
})
}
}
}
return items
},
/**
* list of options available for grouping entry display
* @return {[{id: *, translatable_label: string},{id: *, translatable_label: string},{id: *, translatable_label: string}]}
*/
grouping_options: function () {
return [
{'id': this.GROUP_CATEGORY, 'translatable_label': 'Category'},
{'id': this.GROUP_CREATED_BY, 'translatable_label': 'created_by'},
{'id': this.GROUP_RECIPE, 'translatable_label': 'Recipe'}
]
},
/**
* checks if failed items are contained in the sync queue
*/
has_failed_items: function () {
for (let i in this.item_check_sync_queue) {
if (this.item_check_sync_queue[i]['status'] === 'syncing_failed_before' || this.item_check_sync_queue[i]['status'] === 'waiting_failed_before') {
return true
}
}
return false
}
},
actions: {
/**
* Retrieves all shopping related data (shopping list entries, supermarkets, supermarket categories and shopping list recipes) from API
*/
refreshFromAPI() {
if (!this.currently_updating) {
this.currently_updating = true
this.last_autosync = new Date().getTime();
let apiClient = new ApiApiFactory()
apiClient.listShoppingListEntrys().then((r) => {
this.entries = {}
r.data.forEach((e) => {
Vue.set(this.entries, e.id, e)
})
this.currently_updating = false
}).catch((err) => {
this.currently_updating = false
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
apiClient.listSupermarketCategorys().then(r => {
this.supermarket_categories = r.data
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
apiClient.listSupermarkets().then(r => {
this.supermarkets = r.data
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
}
},
/**
* perform auto sync request to special endpoint returning only entries changed since last auto sync
* only updates local entries that are older than the server version
*/
autosync() {
if (!this.currently_updating && this.autosync_has_focus) {
console.log('running autosync')
this.currently_updating = true
let previous_autosync = this.last_autosync
this.last_autosync = new Date().getTime();
let apiClient = new ApiApiFactory()
apiClient.listShoppingListEntrys(undefined, undefined, undefined, {
'query': {'last_autosync': previous_autosync}
}).then((r) => {
r.data.forEach((e) => {
// dont update stale client data
if (!(Object.keys(this.entries).includes(e.id.toString())) || Date.parse(this.entries[e.id].updated_at) < Date.parse(e.updated_at)) {
console.log('auto sync updating entry ', e)
Vue.set(this.entries, e.id, e)
}
})
this.currently_updating = false
}).catch((err) => {
console.warn('auto sync failed')
this.currently_updating = false
})
}
},
/**
* Create a new shopping list entry
* adds new entry to store
* @param object entry object to create
* @return {Promise<T | void>} promise of creation call to subscribe to
*/
createObject(object) {
let apiClient = new ApiApiFactory()
return apiClient.createShoppingListEntry(object).then((r) => {
Vue.set(this.entries, r.data.id, r.data)
this.registerChange('CREATED', {[r.data.id]: r.data},)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
/**
* update existing entry object and updated_at timestamp
* updates data in store
* IMPORTANT: always use this method to update objects to keep client state consistent
* @param object entry object to update
* @return {Promise<T | void>} promise of updating call to subscribe to
*/
updateObject(object) {
let apiClient = new ApiApiFactory()
// sets the update_at timestamp on the client to prevent auto sync from overriding with older changes
// moment().format() yields locale aware datetime without ms 2024-01-04T13:39:08.607238+01:00
Vue.set(object, 'updated_at', moment().format())
return apiClient.updateShoppingListEntry(object.id, object).then((r) => {
Vue.set(this.entries, r.data.id, r.data)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
/**
* delete shopping list entry object from DB and store
* @param object entry object to delete
* @return {Promise<T | void>} promise of delete call to subscribe to
*/
deleteObject(object) {
let apiClient = new ApiApiFactory()
return apiClient.destroyShoppingListEntry(object.id).then((r) => {
Vue.delete(this.entries, object.id)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE, err)
})
},
/**
* returns a distinct list of recipes associated with unchecked shopping list entries
*/
getAssociatedRecipes: function () {
let recipes = {}
for (let i in this.entries) {
let e = this.entries[i]
if (e.recipe_mealplan !== null) {
Vue.set(recipes, e.recipe_mealplan.recipe, {
'shopping_list_recipe_id': e.list_recipe,
'recipe_id': e.recipe_mealplan.recipe,
'recipe_name': e.recipe_mealplan.recipe_name,
'servings': e.recipe_mealplan.servings,
'mealplan_from_date': e.recipe_mealplan.mealplan_from_date,
'mealplan_type': e.recipe_mealplan.mealplan_type,
})
}
}
return recipes
},
// convenience methods
/**
* function to set entry to its proper place in the data structure to perform grouping
* @param {{}} structure datastructure
* @param {*} entry entry to place
* @param {*} group group to place entry into (must be of ShoppingListStore.GROUP_XXX/dot notation of entry property)
* @returns {{}} datastructure including entry
*/
updateEntryInStructure(structure, entry, group) {
let grouping_key = _.get(entry, group, this.UNDEFINED_CATEGORY)
if (grouping_key === undefined || grouping_key === null) {
grouping_key = this.UNDEFINED_CATEGORY
}
if (!(grouping_key in structure)) {
Vue.set(structure, grouping_key, {'name': grouping_key, 'foods': {}})
}
if (!(entry.food.id in structure[grouping_key]['foods'])) {
Vue.set(structure[grouping_key]['foods'], entry.food.id, {
'id': entry.food.id,
'name': entry.food.name,
'entries': {}
})
}
Vue.set(structure[grouping_key]['foods'][entry.food.id]['entries'], entry.id, entry)
return structure
},
/**
* function to handle user checking or unchecking a set of entries
* @param {{}} entries set of entries
* @param checked boolean to set checked state of entry to
* @param undo if the user should be able to undo the change or not
*/
setEntriesCheckedState(entries, checked, undo) {
if (undo) {
this.registerChange((checked ? 'CHECKED' : 'UNCHECKED'), entries)
}
let entry_id_list = []
for (let i in entries) {
Vue.set(this.entries[i], 'checked', checked)
Vue.set(this.entries[i], 'updated_at', moment().format())
entry_id_list.push(i)
}
this.item_check_sync_queue
Vue.set(this.item_check_sync_queue, Math.random(), {
'ids': entry_id_list,
'checked': checked,
'status': 'waiting'
})
this.runSyncQueue(5)
},
/**
* go through the list of queued requests and try to run them
* add request back to queue if it fails due to offline or timeout
* Do NOT call this method directly, always call using runSyncQueue method to prevent simultaneous runs
* @private
*/
_replaySyncQueue() {
if (navigator.onLine || document.location.href.includes('localhost')) {
let apiClient = new ApiApiFactory()
let promises = []
for (let i in this.item_check_sync_queue) {
let entry = this.item_check_sync_queue[i]
Vue.set(entry, 'status', ((entry['status'] === 'waiting') ? 'syncing' : 'syncing_failed_before'))
Vue.set(this.item_check_sync_queue, i, entry)
let p = apiClient.bulkShoppingListEntry(entry, {timeout: 15000}).then((r) => {
Vue.delete(this.item_check_sync_queue, i)
}).catch((err) => {
if (err.code === "ERR_NETWORK" || err.code === "ECONNABORTED") {
Vue.set(entry, 'status', 'waiting_failed_before')
Vue.set(this.item_check_sync_queue, i, entry)
} else {
Vue.delete(this.item_check_sync_queue, i)
console.error('Failed API call for entry ', entry)
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
}
})
promises.push(p)
}
Promise.allSettled(promises).finally(r => {
this.runSyncQueue(500)
})
} else {
this.runSyncQueue(5000)
}
},
/**
* manages running the replaySyncQueue function after the given timeout
* calling this function might cancel a previously created timeout
* @param timeout time in ms after which to run the replaySyncQueue function
*/
runSyncQueue(timeout) {
clearTimeout(this.queue_timeout_id)
this.queue_timeout_id = setTimeout(() => {
this._replaySyncQueue()
}, timeout)
},
/**
* function to handle user "delaying" and "undelaying" shopping entries
* @param {{}} entries set of entries
* @param delay if entries should be delayed or if delay should be removed
* @param undo if the user should be able to undo the change or not
*/
delayEntries(entries, delay, undo) {
let delay_hours = useUserPreferenceStore().user_settings.default_delay
let delay_date = new Date(Date.now() + delay_hours * (60 * 60 * 1000))
if (undo) {
this.registerChange((delay ? 'DELAY' : 'UNDELAY'), entries)
}
for (let i in entries) {
this.entries[i].delay_until = (delay ? delay_date : null)
this.updateObject(this.entries[i])
}
},
/**
* delete list of entries
* @param {{}} entries set of entries
*/
deleteEntries(entries) {
for (let i in entries) {
this.deleteObject(this.entries[i])
}
},
deleteShoppingListRecipe(shopping_list_recipe_id) {
let api = new ApiApiFactory()
for (let i in this.entries) {
if (this.entries[i].list_recipe === shopping_list_recipe_id) {
Vue.delete(this.entries, i)
}
}
api.destroyShoppingListRecipe(shopping_list_recipe_id).then((x) => {
// no need to update anything, entries were already removed
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE, err)
})
},
/**
* register the change to a set of entries to allow undoing it
* throws an Error if the operation type is not known
* @param type the type of change to register. This determines what undoing the change does. (CREATE->delete object,
* CHECKED->uncheck entry, UNCHECKED->check entry, DELAY->remove delay)
* @param {{}} entries set of entries
*/
registerChange(type, entries) {
if (!['CREATED', 'CHECKED', 'UNCHECKED', 'DELAY', 'UNDELAY'].includes(type)) {
throw Error('Tried to register unknown change type')
}
this.undo_stack.push({'type': type, 'entries': entries})
},
/**
* takes the last item from the undo stack and reverts it
*/
undoChange() {
let last_item = this.undo_stack.pop()
if (last_item !== undefined) {
let type = last_item['type']
let entries = last_item['entries']
if (type === 'CHECKED' || type === 'UNCHECKED') {
this.setEntriesCheckedState(entries, (type === 'UNCHECKED'), false)
} else if (type === 'DELAY' || type === 'UNDELAY') {
this.delayEntries(entries, (type === 'UNDELAY'), false)
} else if (type === 'CREATED') {
for (let i in entries) {
let e = entries[i]
this.deleteObject(e)
}
}
} else {
// can use localization in store
//StandardToasts.makeStandardToast(this, this.$t('NoMoreUndo'))
}
}
},
})

View File

@ -1,20 +1,136 @@
import {defineStore} from 'pinia'
import {ApiApiFactory} from "@/utils/openapi/api";
import {ApiApiFactory, UserPreference} from "@/utils/openapi/api";
import Vue from "vue";
import {StandardToasts} from "@/utils/utils";
const _STALE_TIME_IN_MS = 1000 * 30
const _STORE_ID = 'user_preference_store'
const _LS_DEVICE_SETTINGS = 'TANDOOR_LOCAL_SETTINGS'
const _LS_USER_SETTINGS = 'TANDOOR_USER_SETTINGS'
const _USER_ID = localStorage.getItem('USER_ID')
export const useUserPreferenceStore = defineStore(_STORE_ID, {
state: () => ({
data: null,
updated_at: null,
currently_updating: false,
user_settings_loaded_at: new Date(0),
user_settings: {
image: null,
theme: "TANDOOR",
nav_bg_color: "#ddbf86",
nav_text_color: "DARK",
nav_show_logo: true,
default_unit: "g",
default_page: "SEARCH",
use_fractions: false,
use_kj: false,
plan_share: [],
nav_sticky: true,
ingredient_decimals: 2,
comments: true,
shopping_auto_sync: 5,
mealplan_autoadd_shopping: false,
food_inherit_default: [],
default_delay: "4.0000",
mealplan_autoinclude_related: true,
mealplan_autoexclude_onhand: true,
shopping_share: [],
shopping_recent_days: 7,
csv_delim: ",",
csv_prefix: "",
filter_to_supermarket: false,
shopping_add_onhand: false,
left_handed: false,
show_step_ingredients: true,
food_children_exist: false,
locally_updated_at: new Date(0),
},
device_settings_initialized: false,
device_settings_loaded_at: new Date(0),
device_settings: {
// shopping
shopping_show_checked_entries: false,
shopping_show_delayed_entries: false,
shopping_show_selected_supermarket_only: false,
shopping_selected_grouping: 'food.supermarket_category.name',
shopping_selected_supermarket: null,
shopping_item_info_created_by: false,
shopping_item_info_mealplan: false,
shopping_item_info_recipe: true,
},
}),
getters: {
getters: {},
actions: {
// Device settings (on device settings stored in local storage)
/**
* Load device settings from local storage and update state device_settings
*/
loadDeviceSettings() {
let s = localStorage.getItem(_LS_DEVICE_SETTINGS)
if (!(s === null || s === {})) {
let settings = JSON.parse(s)
for (s in settings) {
Vue.set(this.device_settings, s, settings[s])
}
}
this.device_settings_initialized = true
},
/**
* persist changes to device settings into local storage
*/
updateDeviceSettings: function () {
localStorage.setItem(_LS_DEVICE_SETTINGS, JSON.stringify(this.device_settings))
},
// ---------------- new methods for user settings
loadUserSettings: function (allow_cached_results) {
let s = localStorage.getItem(_LS_USER_SETTINGS)
if (!(s === null || s === {})) {
let settings = JSON.parse(s)
for (s in settings) {
Vue.set(this.user_settings, s, settings[s])
}
console.log(`loaded local user settings age ${((new Date().getTime()) - this.user_settings.locally_updated_at) / 1000} `)
}
if (((new Date().getTime()) - this.user_settings.locally_updated_at) > _STALE_TIME_IN_MS || !allow_cached_results) {
console.log('refreshing user settings from API')
let apiClient = new ApiApiFactory()
apiClient.retrieveUserPreference(localStorage.getItem('USER_ID')).then(r => {
for (s in r.data) {
if (!(s in this.user_settings) && s !== 'user') {
// dont load new keys if no default exists (to prevent forgetting to add defaults)
console.error(`API returned UserPreference key "${s}" which has no default in UserPreferenceStore.user_settings.`)
} else {
Vue.set(this.user_settings, s, r.data[s])
}
}
Vue.set(this.user_settings, 'locally_updated_at', new Date().getTime())
localStorage.setItem(_LS_USER_SETTINGS, JSON.stringify(this.user_settings))
}).catch(err => {
this.currently_updating = false
})
}
},
actions: {
updateUserSettings: function () {
let apiClient = new ApiApiFactory()
apiClient.partialUpdateUserPreference(_USER_ID, this.user_settings).then(r => {
this.user_settings = r.data
Vue.set(this.user_settings, 'locally_updated_at', new Date().getTime())
localStorage.setItem(_LS_USER_SETTINGS, JSON.stringify(this.user_settings))
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
}).catch(err => {
this.currently_updating = false
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
// ----------------
// User Preferences (database settings stored in user preference model)
/**
* gets data from the store either directly or refreshes from API if data is considered stale
* @returns {UserPreference|*|Promise<axios.AxiosResponse<UserPreference>>}
@ -75,6 +191,10 @@ export const useUserPreferenceStore = defineStore(_STORE_ID, {
this.data = r.data
this.updated_at = new Date()
this.currently_updating = false
this.user_settings = r.data
this.user_settings_loaded_at = new Date()
return this.data
}).catch(err => {
this.currently_updating = false

View File

@ -408,6 +408,7 @@ export class Models {
static SHOPPING_CATEGORY = {
name: "Shopping_Category",
apiName: "SupermarketCategory",
merge: true,
create: {
params: [["name", "description"]],
form: {

View File

@ -4013,18 +4013,6 @@ export interface ShoppingListEntries {
* @memberof ShoppingListEntries
*/
unit?: FoodPropertiesFoodUnit | null;
/**
*
* @type {number}
* @memberof ShoppingListEntries
*/
ingredient?: number | null;
/**
*
* @type {string}
* @memberof ShoppingListEntries
*/
ingredient_note?: string;
/**
*
* @type {string}
@ -4061,6 +4049,12 @@ export interface ShoppingListEntries {
* @memberof ShoppingListEntries
*/
created_at?: string;
/**
*
* @type {string}
* @memberof ShoppingListEntries
*/
updated_at?: string;
/**
*
* @type {string}
@ -4104,18 +4098,6 @@ export interface ShoppingListEntry {
* @memberof ShoppingListEntry
*/
unit?: FoodPropertiesFoodUnit | null;
/**
*
* @type {number}
* @memberof ShoppingListEntry
*/
ingredient?: number | null;
/**
*
* @type {string}
* @memberof ShoppingListEntry
*/
ingredient_note?: string;
/**
*
* @type {string}
@ -4152,6 +4134,12 @@ export interface ShoppingListEntry {
* @memberof ShoppingListEntry
*/
created_at?: string;
/**
*
* @type {string}
* @memberof ShoppingListEntry
*/
updated_at?: string;
/**
*
* @type {string}
@ -4165,6 +4153,25 @@ export interface ShoppingListEntry {
*/
delay_until?: string | null;
}
/**
*
* @export
* @interface ShoppingListEntryBulk
*/
export interface ShoppingListEntryBulk {
/**
*
* @type {Array<any>}
* @memberof ShoppingListEntryBulk
*/
ids: Array<any>;
/**
*
* @type {boolean}
* @memberof ShoppingListEntryBulk
*/
checked: boolean;
}
/**
*
* @export
@ -4213,6 +4220,18 @@ export interface ShoppingListRecipe {
* @memberof ShoppingListRecipe
*/
mealplan_note?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipe
*/
mealplan_from_date?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipe
*/
mealplan_type?: string;
}
/**
*
@ -4262,6 +4281,18 @@ export interface ShoppingListRecipeMealplan {
* @memberof ShoppingListRecipeMealplan
*/
mealplan_note?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipeMealplan
*/
mealplan_from_date?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipeMealplan
*/
mealplan_type?: string;
}
/**
*
@ -4311,6 +4342,18 @@ export interface ShoppingListRecipes {
* @memberof ShoppingListRecipes
*/
mealplan_note?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipes
*/
mealplan_from_date?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipes
*/
mealplan_type?: string;
}
/**
*
@ -4507,19 +4550,97 @@ export interface Space {
* @memberof Space
*/
nav_logo?: RecipeFile | null;
/**
*
* @type {string}
* @memberof Space
*/
space_theme?: SpaceSpaceThemeEnum;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
space_theme?: RecipeFile | null;
custom_space_theme?: RecipeFile | null;
/**
*
* @type {boolean}
* @type {string}
* @memberof Space
*/
use_plural?: boolean;
nav_bg_color?: string;
/**
*
* @type {string}
* @memberof Space
*/
nav_text_color?: SpaceNavTextColorEnum;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_32?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_128?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_144?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_180?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_192?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_512?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_svg?: RecipeFile | null;
}
/**
* @export
* @enum {string}
*/
export enum SpaceSpaceThemeEnum {
Blank = 'BLANK',
Tandoor = 'TANDOOR',
Bootstrap = 'BOOTSTRAP',
Darkly = 'DARKLY',
Flatly = 'FLATLY',
Superhero = 'SUPERHERO',
TandoorDark = 'TANDOOR_DARK'
}
/**
* @export
* @enum {string}
*/
export enum SpaceNavTextColorEnum {
Blank = 'BLANK',
Light = 'LIGHT',
Dark = 'DARK'
}
/**
*
* @export
@ -5382,6 +5503,39 @@ export interface ViewLog {
*/
export const ApiApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
bulkShoppingListEntry: async (shoppingListEntryBulk?: ShoppingListEntryBulk, options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/shopping-list-entry/bulk/`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(shoppingListEntryBulk, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {AccessToken} [accessToken]
@ -9486,10 +9640,11 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
},
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listSupermarkets: async (options: any = {}): Promise<RequestArgs> => {
listSupermarkets: async (query?: string, options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/supermarket/`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -9502,6 +9657,10 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
if (query !== undefined) {
localVarQueryParameter['query'] = query;
}
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
@ -9661,10 +9820,11 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
},
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listUserFiles: async (options: any = {}): Promise<RequestArgs> => {
listUserFiles: async (query?: string, options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/user-file/`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -9677,6 +9837,10 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
if (query !== undefined) {
localVarQueryParameter['query'] = query;
}
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
@ -9935,6 +10099,47 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
mergeSupermarketCategory: async (id: string, target: string, supermarketCategory?: SupermarketCategory, options: any = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('mergeSupermarketCategory', 'id', id)
// verify required parameter 'target' is not null or undefined
assertParamExists('mergeSupermarketCategory', 'target', target)
const localVarPath = `/api/supermarket-category/{id}/merge/{target}/`
.replace(`{${"id"}}`, encodeURIComponent(String(id)))
.replace(`{${"target"}}`, encodeURIComponent(String(target)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(supermarketCategory, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id A unique integer value identifying this unit.
@ -14820,6 +15025,16 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
export const ApiApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ApiApiAxiosParamCreator(configuration)
return {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async bulkShoppingListEntry(shoppingListEntryBulk?: ShoppingListEntryBulk, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ShoppingListEntryBulk>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.bulkShoppingListEntry(shoppingListEntryBulk, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {AccessToken} [accessToken]
@ -16034,11 +16249,12 @@ export const ApiApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listSupermarkets(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<Supermarket>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listSupermarkets(options);
async listSupermarkets(query?: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<Supermarket>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listSupermarkets(query, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -16085,11 +16301,12 @@ export const ApiApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listUserFiles(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<UserFile>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listUserFiles(options);
async listUserFiles(query?: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<UserFile>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listUserFiles(query, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -16165,6 +16382,18 @@ export const ApiApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergeKeyword(id, target, keyword, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async mergeSupermarketCategory(id: string, target: string, supermarketCategory?: SupermarketCategory, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SupermarketCategory>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergeSupermarketCategory(id, target, supermarketCategory, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id A unique integer value identifying this unit.
@ -17623,6 +17852,15 @@ export const ApiApiFp = function(configuration?: Configuration) {
export const ApiApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ApiApiFp(configuration)
return {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
bulkShoppingListEntry(shoppingListEntryBulk?: ShoppingListEntryBulk, options?: any): AxiosPromise<ShoppingListEntryBulk> {
return localVarFp.bulkShoppingListEntry(shoppingListEntryBulk, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AccessToken} [accessToken]
@ -18719,11 +18957,12 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
},
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listSupermarkets(options?: any): AxiosPromise<Array<Supermarket>> {
return localVarFp.listSupermarkets(options).then((request) => request(axios, basePath));
listSupermarkets(query?: string, options?: any): AxiosPromise<Array<Supermarket>> {
return localVarFp.listSupermarkets(query, options).then((request) => request(axios, basePath));
},
/**
*
@ -18765,11 +19004,12 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
},
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listUserFiles(options?: any): AxiosPromise<Array<UserFile>> {
return localVarFp.listUserFiles(options).then((request) => request(axios, basePath));
listUserFiles(query?: string, options?: any): AxiosPromise<Array<UserFile>> {
return localVarFp.listUserFiles(query, options).then((request) => request(axios, basePath));
},
/**
*
@ -18837,6 +19077,17 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
mergeKeyword(id: string, target: string, keyword?: Keyword, options?: any): AxiosPromise<Keyword> {
return localVarFp.mergeKeyword(id, target, keyword, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
mergeSupermarketCategory(id: string, target: string, supermarketCategory?: SupermarketCategory, options?: any): AxiosPromise<SupermarketCategory> {
return localVarFp.mergeSupermarketCategory(id, target, supermarketCategory, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id A unique integer value identifying this unit.
@ -20160,6 +20411,17 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
* @extends {BaseAPI}
*/
export class ApiApi extends BaseAPI {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public bulkShoppingListEntry(shoppingListEntryBulk?: ShoppingListEntryBulk, options?: any) {
return ApiApiFp(this.configuration).bulkShoppingListEntry(shoppingListEntryBulk, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AccessToken} [accessToken]
@ -21492,12 +21754,13 @@ export class ApiApi extends BaseAPI {
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public listSupermarkets(options?: any) {
return ApiApiFp(this.configuration).listSupermarkets(options).then((request) => request(this.axios, this.basePath));
public listSupermarkets(query?: string, options?: any) {
return ApiApiFp(this.configuration).listSupermarkets(query, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -21548,12 +21811,13 @@ export class ApiApi extends BaseAPI {
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public listUserFiles(options?: any) {
return ApiApiFp(this.configuration).listUserFiles(options).then((request) => request(this.axios, this.basePath));
public listUserFiles(query?: string, options?: any) {
return ApiApiFp(this.configuration).listUserFiles(query, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -21636,6 +21900,19 @@ export class ApiApi extends BaseAPI {
return ApiApiFp(this.configuration).mergeKeyword(id, target, keyword, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public mergeSupermarketCategory(id: string, target: string, supermarketCategory?: SupermarketCategory, options?: any) {
return ApiApiFp(this.configuration).mergeSupermarketCategory(id, target, supermarketCategory, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id A unique integer value identifying this unit.

View File

@ -131,7 +131,7 @@ export class StandardToasts {
}
let DEBUG = localStorage.getItem("DEBUG") === "True" || always_show_errors
let DEBUG = (localStorage.getItem("DEBUG") === "True" || always_show_errors) && variant !== 'success'
if (DEBUG){
console.log('ERROR ', err, JSON.stringify(err?.response?.data))
console.trace();
@ -365,6 +365,23 @@ export function energyHeading() {
}
}
export const FormatMixin = {
name: "FormatMixin",
methods: {
/**
* format short date from datetime
* @param datetime any string that can be parsed by Date.parse()
* @return {string}
*/
formatDate: function (datetime) {
return Intl.DateTimeFormat(window.navigator.language, {
dateStyle: "short",
}).format(Date.parse(datetime))
},
},
}
axios.defaults.xsrfCookieName = "csrftoken"
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
@ -376,7 +393,7 @@ export const ApiMixin = {
}
},
methods: {
// if passing parameters that are not part of the offical schema of the endpoint use parameter: options: {query: {simple: 1}}
// if passing parameters that are not part of the official schema of the endpoint use parameter: options: {query: {simple: 1}}
genericAPI: function (model, action, options) {
let setup = getConfig(model, action)
if (setup?.config?.function) {