mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-25 11:19:39 -05:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
602f0a8bf0 | ||
|
|
673d12d233 | ||
|
|
6359245925 | ||
|
|
a7c4822322 | ||
|
|
e94419f320 | ||
|
|
01f46483ff | ||
|
|
d6da5688af | ||
|
|
680ae39201 | ||
|
|
2472ee9c26 | ||
|
|
4428b06d4a | ||
|
|
e9c38d7d5e | ||
|
|
6f28d58807 | ||
|
|
88db611f0a | ||
|
|
f3302b4014 | ||
|
|
d4bb161275 | ||
|
|
32f1538938 | ||
|
|
029baea4c7 | ||
|
|
38d1b7cef5 | ||
|
|
856f417d1b | ||
|
|
85821bcc94 | ||
|
|
2345af8fd6 | ||
|
|
51107c64ee | ||
|
|
81983c5ae2 | ||
|
|
f7713a43a7 | ||
|
|
ffd951a7f4 | ||
|
|
319ac8e191 | ||
|
|
aea247b4a3 | ||
|
|
e2843bb02f | ||
|
|
e3aa3e1137 | ||
|
|
da1187b03a | ||
|
|
f9ed79978c | ||
|
|
920a3ed4a3 | ||
|
|
2077eae142 | ||
|
|
b1ef35e415 | ||
|
|
0a687d840c | ||
|
|
6a3034b966 | ||
|
|
3d7afbfe4f | ||
|
|
02e43730bd | ||
|
|
6adf077ee5 | ||
|
|
d73ffa46ff | ||
|
|
8572f338ad | ||
|
|
920ec8e74b | ||
|
|
2328bf2342 | ||
|
|
85620a1431 | ||
|
|
0037858885 |
16
boot.sh
16
boot.sh
@@ -2,7 +2,7 @@
|
||||
source venv/bin/activate
|
||||
|
||||
# these are envsubst in the nginx config, make sure they default to something sensible when unset
|
||||
export TANDOOR_PORT="${TANDOOR_PORT:-8080}"
|
||||
export TANDOOR_PORT="${TANDOOR_PORT:-80}"
|
||||
export MEDIA_ROOT=${MEDIA_ROOT:-/opt/recipes/mediafiles};
|
||||
export STATIC_ROOT=${STATIC_ROOT:-/opt/recipes/staticfiles};
|
||||
|
||||
@@ -12,11 +12,6 @@ GUNICORN_LOG_LEVEL="${GUNICORN_LOG_LEVEL:-'info'}"
|
||||
|
||||
PLUGINS_BUILD="${PLUGINS_BUILD:-0}"
|
||||
|
||||
if [ "${TANDOOR_PORT}" -eq 80 ]; then
|
||||
echo "TANDOOR_PORT set to 8080 because 80 is now taken by the integrated nginx"
|
||||
TANDOOR_PORT=8080
|
||||
fi
|
||||
|
||||
display_warning() {
|
||||
echo "[WARNING]"
|
||||
echo -e "$1"
|
||||
@@ -29,7 +24,6 @@ envsubst '$MEDIA_ROOT $STATIC_ROOT $TANDOOR_PORT' < /opt/recipes/http.d/Recipes.
|
||||
echo "Starting nginx"
|
||||
nginx
|
||||
|
||||
|
||||
echo "Checking configuration..."
|
||||
|
||||
# SECRET_KEY (or a valid file at SECRET_KEY_FILE) must be set in .env file
|
||||
@@ -110,9 +104,5 @@ chmod -R 755 ${MEDIA_ROOT:-/opt/recipes/mediafiles}
|
||||
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
|
||||
|
||||
echo "Starting gunicorn"
|
||||
# Check if IPv6 is enabled, only then run gunicorn with ipv6 support
|
||||
if [ "$ipv6_disable" -eq 0 ]; then
|
||||
exec gunicorn -b "[::]:$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
else
|
||||
exec gunicorn -b ":$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
fi
|
||||
exec gunicorn --bind unix:/run/tandoor.sock --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --timeout ${GUNICORN_TIMEOUT:-30} --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ class FoodPropertyHelper:
|
||||
found_property = False
|
||||
# if food has a value for the given property type (no matter if conversion is possible)
|
||||
has_property_value = False
|
||||
if i.food.properties_food_amount == 0 or i.food.properties_food_unit is None and not (i.amount == 0 or i.no_amount): # if food is configured incorrectly
|
||||
if (i.food.properties_food_amount == 0 or i.food.properties_food_unit is None) and not (i.amount == 0 or i.no_amount): # if food is configured incorrectly
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': None}
|
||||
computed_properties[pt.id]['missing_value'] = True
|
||||
else:
|
||||
@@ -63,8 +63,9 @@ class FoodPropertyHelper:
|
||||
computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food)
|
||||
if not found_property:
|
||||
# if no amount and food does not exist yet add it but don't count as missing
|
||||
if i.amount == 0 or i.no_amount and i.food.id not in computed_properties[pt.id]['food_values']:
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': 0}
|
||||
if i.amount == 0 or i.no_amount:
|
||||
if i.food.id not in computed_properties[pt.id]['food_values']:
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': 0}
|
||||
# if amount is present but unit is missing indicate it in the result
|
||||
elif i.unit is None:
|
||||
if i.food.id not in computed_properties[pt.id]['food_values']:
|
||||
@@ -72,7 +73,8 @@ class FoodPropertyHelper:
|
||||
computed_properties[pt.id]['food_values'][i.food.id]['missing_unit'] = True
|
||||
else:
|
||||
computed_properties[pt.id]['missing_value'] = True
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': None}
|
||||
if i.food.id not in computed_properties[pt.id]['food_values']:
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': None}
|
||||
if has_property_value and i.unit is not None:
|
||||
computed_properties[pt.id]['food_values'][i.food.id]['missing_conversion'] = {'base_unit': {'id': i.unit.id, 'name': i.unit.name}, 'converted_unit': {'id': i.food.properties_food_unit.id, 'name': i.food.properties_food_unit.name}}
|
||||
|
||||
@@ -82,8 +84,12 @@ class FoodPropertyHelper:
|
||||
# TODO move to central helper ? --> use defaultdict
|
||||
@staticmethod
|
||||
def add_or_create(d, key, value, food):
|
||||
if key in d and d[key]['value']:
|
||||
d[key]['value'] += value
|
||||
if key in d:
|
||||
# value can be None if a previous instance of the same food was missing a conversion
|
||||
if d[key]['value']:
|
||||
d[key]['value'] += value
|
||||
else:
|
||||
d[key]['value'] = value
|
||||
else:
|
||||
d[key] = {'id': food.id, 'food': {'id': food.id, 'name': food.name}, 'value': value}
|
||||
return d
|
||||
|
||||
@@ -326,7 +326,7 @@ class RecipeSearch():
|
||||
def _favorite_recipes(self):
|
||||
if self._sort_includes('favorite') or self._timescooked or self._timescooked_gte or self._timescooked_lte:
|
||||
less_than = self._timescooked_lte and not self._sort_includes('-favorite')
|
||||
if less_than:
|
||||
if less_than or self._timescooked == 0:
|
||||
default = 1000
|
||||
else:
|
||||
default = 0
|
||||
@@ -339,7 +339,7 @@ class RecipeSearch():
|
||||
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default))
|
||||
|
||||
if self._timescooked:
|
||||
self._queryset = self._queryset.filter(favorite=0)
|
||||
self._queryset = self._queryset.filter(favorite=self._timescooked)
|
||||
elif self._timescooked_lte:
|
||||
self._queryset = self._queryset.filter(favorite__lte=int(self._timescooked_lte)).exclude(favorite=0)
|
||||
elif self._timescooked_gte:
|
||||
|
||||
@@ -72,7 +72,8 @@ def get_from_scraper(scrape, request):
|
||||
# assign servings attributes
|
||||
try:
|
||||
# dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly
|
||||
servings = scrape.schema.data.get('recipeYield') or 1
|
||||
# max(x,1) to prevent 0 servings which breaks scaling
|
||||
servings = max(scrape.schema.data.get('recipeYield') or 1, 1)
|
||||
except Exception:
|
||||
servings = 1
|
||||
|
||||
|
||||
@@ -135,8 +135,9 @@ class UnitConversionHelper:
|
||||
:param food: base food
|
||||
:return: converted ingredient object from base amount/unit/food
|
||||
"""
|
||||
if uc.food is None or uc.food == food:
|
||||
if (uc.food is None or uc.food == food) and uc.converted_amount > 0 and uc.base_amount > 0:
|
||||
if unit == uc.base_unit:
|
||||
return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food, space=self.space)
|
||||
else:
|
||||
return Ingredient(amount=amount * (uc.base_amount / uc.converted_amount), unit=uc.base_unit, food=food, space=self.space)
|
||||
return None
|
||||
|
||||
@@ -128,6 +128,7 @@ class Mealie1(Integration):
|
||||
|
||||
steps_relation = []
|
||||
first_step_of_recipe_dict = {}
|
||||
step_id_dict = {}
|
||||
for s in mealie_database['recipe_instructions']:
|
||||
if s['recipe_id'] in recipes_dict:
|
||||
step = Step.objects.create(instruction=(s['text'] if s['text'] else "") + (f" \n {s['summary']}" if s['summary'] else ""),
|
||||
@@ -135,9 +136,20 @@ class Mealie1(Integration):
|
||||
name=s['title'],
|
||||
space=self.request.space)
|
||||
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[s['recipe_id']], step_id=step.pk))
|
||||
step_id_dict[s["id"]] = step.pk
|
||||
if s['recipe_id'] not in first_step_of_recipe_dict:
|
||||
first_step_of_recipe_dict[s['recipe_id']] = step.pk
|
||||
|
||||
# it is possible for a recipe to not have steps but have ingredients, in that case create an empty step to add them to later
|
||||
for r in recipes_dict.keys():
|
||||
if r not in first_step_of_recipe_dict:
|
||||
step = Step.objects.create(instruction='',
|
||||
order=0,
|
||||
name='',
|
||||
space=self.request.space)
|
||||
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[r], step_id=step.pk))
|
||||
first_step_of_recipe_dict[r] = step.pk
|
||||
|
||||
for n in mealie_database['notes']:
|
||||
if n['recipe_id'] in recipes_dict:
|
||||
step = Step.objects.create(instruction=n['text'],
|
||||
@@ -153,6 +165,11 @@ class Mealie1(Integration):
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipes_ingredients"])} ingredients...\n"
|
||||
self.import_log.save()
|
||||
|
||||
# mealie stores the reference to a step (instruction) from an ingredient (reference) in the recipe_ingredient_ref_link table
|
||||
recipe_ingredient_ref_link_dict = {}
|
||||
for ref in mealie_database['recipe_ingredient_ref_link']:
|
||||
recipe_ingredient_ref_link_dict[ref["reference_id"]] = ref["instruction_id"]
|
||||
|
||||
ingredients_relation = []
|
||||
for i in mealie_database['recipes_ingredients']:
|
||||
if i['recipe_id'] in recipes_dict:
|
||||
@@ -162,18 +179,18 @@ class Mealie1(Integration):
|
||||
is_header=True,
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=title_ingredient.pk))
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=title_ingredient.pk))
|
||||
if i['food_id']:
|
||||
ingredient = Ingredient.objects.create(
|
||||
food_id=foods_dict[i['food_id']] if i['food_id'] in foods_dict else None,
|
||||
unit_id=units_dict[i['unit_id']] if i['unit_id'] in units_dict else None,
|
||||
original_text=i['original_text'],
|
||||
order=i['position'],
|
||||
amount=i['quantity'],
|
||||
amount=i['quantity'] if i['quantity'] else 0,
|
||||
note=i['note'],
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=ingredient.pk))
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=ingredient.pk))
|
||||
elif i['note'].strip():
|
||||
amount, unit, food, note = ingredient_parser.parse(i['note'].strip())
|
||||
f = ingredient_parser.get_food(food)
|
||||
@@ -186,7 +203,7 @@ class Mealie1(Integration):
|
||||
original_text=i['original_text'],
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=ingredient.pk))
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=ingredient.pk))
|
||||
Step.ingredients.through.objects.bulk_create(ingredients_relation)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipes_to_categories"]) + len(mealie_database["recipes_to_tags"])} category and keyword relations...\n"
|
||||
@@ -340,3 +357,10 @@ class Mealie1(Integration):
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
|
||||
def get_step_id(i, first_step_of_recipe_dict, step_id_dict, recipe_ingredient_ref_link_dict):
|
||||
try:
|
||||
return step_id_dict[recipe_ingredient_ref_link_dict[i['reference_id']]]
|
||||
except KeyError:
|
||||
return first_step_of_recipe_dict[i['recipe_id']]
|
||||
@@ -16,7 +16,7 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from cookbook.models import SearchFields
|
||||
|
||||
from django.contrib.postgres.operations import TrigramExtension, UnaccentExtension
|
||||
|
||||
def allSearchFields():
|
||||
return list(SearchFields.objects.values_list('id', flat=True))
|
||||
@@ -141,6 +141,8 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
TrigramExtension(),
|
||||
UnaccentExtension(),
|
||||
migrations.RunPython(create_default_groups),
|
||||
migrations.CreateModel(
|
||||
name='AiProvider',
|
||||
|
||||
15
cookbook/migrations/0230_auto_20250925_2056.py
Normal file
15
cookbook/migrations/0230_auto_20250925_2056.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-25 18:56
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.postgres.operations import TrigramExtension, UnaccentExtension
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0229_alter_ailog_options_alter_aiprovider_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
TrigramExtension(),
|
||||
UnaccentExtension(),
|
||||
]
|
||||
@@ -0,0 +1,141 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-30 18:47
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0230_auto_20250925_2056'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='aiprovider',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='automation',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='bookmarkletimport',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='comment',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='connectorconfig',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='cooklog',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='customfilter',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='exportlog',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='food',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='importlog',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='invitelink',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='keyword',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='mealplan',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='mealtype',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='recipe',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='recipebook',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='recipeimport',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='sharelink',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='shoppinglistentry',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='shoppinglistrecipe',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='space',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='storage',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='supermarket',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='supermarketcategory',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='sync',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='synclog',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='telegrambot',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='unit',
|
||||
options={'ordering': ('name',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='unitconversion',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='userfile',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='userspace',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='viewlog',
|
||||
options={'ordering': ('pk',)},
|
||||
),
|
||||
]
|
||||
@@ -402,6 +402,9 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class AiProvider(models.Model):
|
||||
name = models.CharField(max_length=128)
|
||||
@@ -421,13 +424,14 @@ class AiProvider(models.Model):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ('id',)
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class AiLog(models.Model, PermissionModelMixin):
|
||||
F_FILE_IMPORT = 'FILE_IMPORT'
|
||||
F_STEP_SORT = 'STEP_SORT'
|
||||
F_FOOD_PROPERTIES = 'FOOD_PROPERTIES'
|
||||
F_RECIPE_PROPERTIES = 'RECIPE_PROPERTIES'
|
||||
|
||||
ai_provider = models.ForeignKey(AiProvider, on_delete=models.SET_NULL, null=True)
|
||||
function = models.CharField(max_length=64)
|
||||
@@ -476,6 +480,9 @@ class ConnectorConfig(models.Model, PermissionModelMixin):
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class UserPreference(models.Model, PermissionModelMixin):
|
||||
# Themes
|
||||
@@ -579,6 +586,9 @@ class UserSpace(models.Model, PermissionModelMixin):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class Storage(models.Model, PermissionModelMixin):
|
||||
DROPBOX = 'DB'
|
||||
@@ -603,6 +613,9 @@ class Storage(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class Sync(models.Model, PermissionModelMixin):
|
||||
storage = models.ForeignKey(Storage, on_delete=models.PROTECT)
|
||||
@@ -618,6 +631,9 @@ class Sync(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return self.path
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class SupermarketCategory(models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
@@ -643,6 +659,7 @@ class SupermarketCategory(models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='smc_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_category_unique_open_data_slug_per_space')
|
||||
]
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class Supermarket(models.Model, PermissionModelMixin):
|
||||
@@ -662,6 +679,7 @@ class Supermarket(models.Model, PermissionModelMixin):
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='sm_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_unique_open_data_slug_per_space')
|
||||
]
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class SupermarketCategoryRelation(models.Model, PermissionModelMixin):
|
||||
@@ -693,6 +711,9 @@ class SyncLog(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return f"{self.created_at}:{self.sync} - {self.status}"
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelMixin):
|
||||
if SORT_TREE_BY_NAME:
|
||||
@@ -710,6 +731,7 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='kw_unique_name_per_space')
|
||||
]
|
||||
indexes = (Index(fields=['id', 'name']),)
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
@@ -741,6 +763,7 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='u_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_unique_open_data_slug_per_space')
|
||||
]
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
@@ -874,6 +897,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
Index(fields=['id']),
|
||||
Index(fields=['name']),
|
||||
)
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class UnitConversion(ExportModelOperationsMixin('unit_conversion'), models.Model, PermissionModelMixin):
|
||||
@@ -900,6 +924,7 @@ class UnitConversion(ExportModelOperationsMixin('unit_conversion'), models.Model
|
||||
models.UniqueConstraint(fields=['space', 'base_unit', 'converted_unit', 'food'], name='f_unique_conversion_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_conversion_unique_open_data_slug_per_space')
|
||||
]
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin):
|
||||
@@ -1104,13 +1129,14 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
|
||||
sub_food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe__in=related_recipes).exclude(recipe=None).values_list('recipe'))
|
||||
return Recipe.objects.filter(Q(id__in=related_recipes.values_list('id')) | sub_step_recipes | sub_food_recipes)
|
||||
|
||||
class Meta():
|
||||
class Meta:
|
||||
indexes = (
|
||||
GinIndex(fields=["name_search_vector"]),
|
||||
GinIndex(fields=["desc_search_vector"]),
|
||||
Index(fields=['id']),
|
||||
Index(fields=['name']),
|
||||
)
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionModelMixin):
|
||||
@@ -1131,6 +1157,9 @@ class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionMod
|
||||
|
||||
def __str__(self):
|
||||
return self.text
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class RecipeImport(models.Model, PermissionModelMixin):
|
||||
@@ -1159,6 +1188,9 @@ class RecipeImport(models.Model, PermissionModelMixin):
|
||||
self.delete()
|
||||
return recipe
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
@@ -1176,6 +1208,7 @@ class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionMod
|
||||
|
||||
class Meta():
|
||||
indexes = (Index(fields=['name']),)
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, PermissionModelMixin):
|
||||
@@ -1221,6 +1254,7 @@ class MealType(models.Model, PermissionModelMixin):
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name', 'created_by'], name='mt_unique_name_per_space'),
|
||||
]
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, PermissionModelMixin):
|
||||
@@ -1248,6 +1282,9 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
|
||||
def __str__(self):
|
||||
return f'{self.get_label()} - {self.from_date} - {self.meal_type.name}'
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=32, blank=True, default='')
|
||||
@@ -1263,6 +1300,9 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
|
||||
def __str__(self):
|
||||
return f'Shopping list recipe {self.id} - {self.recipe}'
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
|
||||
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries')
|
||||
@@ -1294,6 +1334,9 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
@@ -1309,6 +1352,9 @@ class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, Permissi
|
||||
def __str__(self):
|
||||
return f'{self.recipe} - {self.uuid}'
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
def default_valid_until():
|
||||
return date.today() + timedelta(days=14)
|
||||
@@ -1332,6 +1378,9 @@ class InviteLink(ExportModelOperationsMixin('invite_link'), models.Model, Permis
|
||||
def __str__(self):
|
||||
return f'{self.uuid}'
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class TelegramBot(models.Model, PermissionModelMixin):
|
||||
token = models.CharField(max_length=256)
|
||||
@@ -1346,6 +1395,9 @@ class TelegramBot(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
@@ -1363,7 +1415,7 @@ class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionMo
|
||||
def __str__(self):
|
||||
return self.recipe.name
|
||||
|
||||
class Meta():
|
||||
class Meta:
|
||||
indexes = (
|
||||
Index(fields=['id']),
|
||||
Index(fields=['recipe']),
|
||||
@@ -1372,6 +1424,7 @@ class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionMo
|
||||
Index(fields=['created_by']),
|
||||
Index(fields=['created_by', 'rating']),
|
||||
)
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionModelMixin):
|
||||
@@ -1385,13 +1438,14 @@ class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionMo
|
||||
def __str__(self):
|
||||
return self.recipe.name
|
||||
|
||||
class Meta():
|
||||
class Meta:
|
||||
indexes = (
|
||||
Index(fields=['recipe']),
|
||||
Index(fields=['-created_at']),
|
||||
Index(fields=['created_by']),
|
||||
Index(fields=['recipe', '-created_at', 'created_by']),
|
||||
)
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ImportLog(models.Model, PermissionModelMixin):
|
||||
@@ -1412,6 +1466,9 @@ class ImportLog(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return f"{self.created_at}:{self.type}"
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class ExportLog(models.Model, PermissionModelMixin):
|
||||
type = models.CharField(max_length=32)
|
||||
@@ -1432,6 +1489,9 @@ class ExportLog(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return f"{self.created_at}:{self.type}"
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models.Model, PermissionModelMixin):
|
||||
html = models.TextField()
|
||||
@@ -1442,6 +1502,9 @@ class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models
|
||||
objects = ScopedManager(space='space')
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
# field names used to configure search behavior - all data populated during data migration
|
||||
# other option is to use a MultiSelectField from https://github.com/goinnn/django-multiselectfield
|
||||
@@ -1509,6 +1572,9 @@ class UserFile(ExportModelOperationsMixin('user_files'), models.Model, Permissio
|
||||
def __str__(self):
|
||||
return f'{self.name} (#{self.id})'
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class Automation(ExportModelOperationsMixin('automations'), models.Model, PermissionModelMixin):
|
||||
FOOD_ALIAS = 'FOOD_ALIAS'
|
||||
@@ -1555,6 +1621,9 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis
|
||||
objects = ScopedManager(space='space')
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
|
||||
|
||||
class CustomFilter(models.Model, PermissionModelMixin):
|
||||
RECIPE = 'RECIPE'
|
||||
@@ -1585,3 +1654,4 @@ class CustomFilter(models.Model, PermissionModelMixin):
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='cf_unique_name_per_space')
|
||||
]
|
||||
ordering = ('pk',)
|
||||
|
||||
@@ -99,19 +99,19 @@ def test_list_filter(obj_1, u1_s1):
|
||||
|
||||
response = json.loads(
|
||||
u1_s1.get(
|
||||
f'{reverse(LIST_URL)}?from_date={(timezone.now() + timedelta(days=2)).strftime("%Y-%m-%d")}'
|
||||
f'{reverse(LIST_URL)}?from_date={(timezone.localtime(timezone.now()) + timedelta(days=1)).strftime("%Y-%m-%d")}'
|
||||
).content)['results']
|
||||
assert len(response) == 0
|
||||
|
||||
response = json.loads(
|
||||
u1_s1.get(
|
||||
f'{reverse(LIST_URL)}?to_date={(timezone.now() - timedelta(days=2)).strftime("%Y-%m-%d")}'
|
||||
f'{reverse(LIST_URL)}?to_date={(timezone.localtime(timezone.now()) - timedelta(days=1)).strftime("%Y-%m-%d")}'
|
||||
).content)['results']
|
||||
assert len(response) == 0
|
||||
|
||||
response = json.loads(
|
||||
u1_s1.get(
|
||||
f'{reverse(LIST_URL)}?from_date={(timezone.now() - timedelta(days=2)).strftime("%Y-%m-%d")}&to_date={(timezone.now() + timedelta(days=2)).strftime("%Y-%m-%d")}'
|
||||
f'{reverse(LIST_URL)}?from_date={(timezone.localtime(timezone.now()) - timedelta(days=1)).strftime("%Y-%m-%d")}&to_date={(timezone.localtime(timezone.now()) + timedelta(days=1)).strftime("%Y-%m-%d")}'
|
||||
).content)['results']
|
||||
assert len(response) == 1
|
||||
|
||||
@@ -153,8 +153,8 @@ def test_add(arg, request, u1_s2, recipe_1_s1, meal_type):
|
||||
'id': meal_type.id,
|
||||
'name': meal_type.name
|
||||
},
|
||||
'from_date': (timezone.now()).strftime("%Y-%m-%d"),
|
||||
'to_date': (timezone.now()).strftime("%Y-%m-%d"),
|
||||
'from_date': (timezone.localtime(timezone.now())).strftime("%Y-%m-%d"),
|
||||
'to_date': (timezone.localtime(timezone.now())).strftime("%Y-%m-%d"),
|
||||
'servings': 1,
|
||||
'title': 'test',
|
||||
'shared': []
|
||||
@@ -196,8 +196,8 @@ def test_add_with_shopping(u1_s1, meal_type):
|
||||
'id': meal_type.id,
|
||||
'name': meal_type.name
|
||||
},
|
||||
'from_date': (timezone.now()).strftime("%Y-%m-%d"),
|
||||
'to_date': (timezone.now()).strftime("%Y-%m-%d"),
|
||||
'from_date': (timezone.localtime(timezone.now())).strftime("%Y-%m-%d"),
|
||||
'to_date': (timezone.localtime(timezone.now())).strftime("%Y-%m-%d"),
|
||||
'servings': 1,
|
||||
'title': 'test',
|
||||
'shared': [],
|
||||
@@ -212,13 +212,13 @@ def test_add_with_shopping(u1_s1, meal_type):
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['', 2],
|
||||
[f'?from_date={timezone.now().strftime("%Y-%m-%d")}', 1],
|
||||
[f'?from_date={timezone.localtime(timezone.now()).strftime("%Y-%m-%d")}', 1],
|
||||
[
|
||||
f'?to_date={(timezone.now() - timedelta(days=1)).strftime("%Y-%m-%d")}',
|
||||
f'?to_date={(timezone.localtime(timezone.now()) - timedelta(days=1)).strftime("%Y-%m-%d")}',
|
||||
1
|
||||
],
|
||||
[
|
||||
f'?from_date={(timezone.now() + timedelta(days=2)).strftime("%Y-%m-%d")}&to_date={(timezone.now() + timedelta(days=2)).strftime("%Y-%m-%d")}',
|
||||
f'?from_date={(timezone.localtime(timezone.now()) + timedelta(days=1)).strftime("%Y-%m-%d")}&to_date={(timezone.localtime(timezone.now()) + timedelta(days=1)).strftime("%Y-%m-%d")}',
|
||||
0
|
||||
],
|
||||
])
|
||||
|
||||
@@ -185,3 +185,32 @@ def test_unit_conversions(space_1, space_2, u1_s1):
|
||||
assert next(x for x in conversions if x.unit == unit_kg_space_2) is not None
|
||||
assert abs(next(x for x in conversions if x.unit == unit_kg_space_2).amount - Decimal(0.1)) < 0.0001
|
||||
print(conversions)
|
||||
|
||||
def test_conversion_with_zero(space_1, space_2, u1_s1):
|
||||
with scopes_disabled():
|
||||
uch = UnitConversionHelper(space_1)
|
||||
|
||||
unit_gram = Unit.objects.create(name='gram', base_unit='g', space=space_1)
|
||||
unit_fantasy = Unit.objects.create(name='Fantasy Unit', base_unit=None, space=space_1)
|
||||
|
||||
food_1 = Food.objects.create(name='Test Food 1', space=space_1)
|
||||
|
||||
ingredient_food_1_gram = Ingredient.objects.create(
|
||||
food=food_1,
|
||||
unit=unit_gram,
|
||||
amount=100,
|
||||
space=space_1,
|
||||
)
|
||||
|
||||
print('\n----------- TEST BASE CUSTOM CONVERSION - TO CUSTOM CONVERSION ---------------')
|
||||
UnitConversion.objects.create(
|
||||
base_amount=0,
|
||||
base_unit=unit_gram,
|
||||
converted_amount=0,
|
||||
converted_unit=unit_fantasy,
|
||||
space=space_1,
|
||||
created_by=auth.get_user(u1_s1),
|
||||
)
|
||||
conversions = uch.get_conversions(ingredient_food_1_gram)
|
||||
|
||||
assert len(conversions) == 1 # conversion always includes the ingredient, if count is 1 no other conversion was found
|
||||
|
||||
@@ -372,11 +372,16 @@ class MergeMixin(ViewSetMixin):
|
||||
isTree = False
|
||||
|
||||
try:
|
||||
# TODO these checks could be improved to merge existing properties and conversion in a smart way. For now it will just loose them to prevent duplicates
|
||||
if isinstance(source, Food):
|
||||
source.properties.all().delete()
|
||||
source.properties.clear()
|
||||
UnitConversion.objects.filter(food=source).delete()
|
||||
|
||||
if isinstance(source, Unit):
|
||||
UnitConversion.objects.filter(base_unit=source).delete()
|
||||
UnitConversion.objects.filter(converted_unit=source).delete()
|
||||
|
||||
for link in [field for field in source._meta.get_fields() if issubclass(type(field), ForeignObjectRel)]:
|
||||
linkManager = getattr(source, link.get_accessor_name())
|
||||
related = linkManager.all()
|
||||
@@ -1125,7 +1130,7 @@ class FoodViewSet(LoggingMixin, TreeMixin, DeleteRelationMixing):
|
||||
"type": "text",
|
||||
"text": "Given the following food and the following different types of properties please update the food so that the properties attribute contains a list with all property types in the following format [{property_amount: <the property value>, property_type: {id: <the ID of the property type>, name: <the name of the property type>}}]."
|
||||
"The property values should be in the unit given in the property type and for the amount specified in the properties_food_amount attribute of the food, which is given in the properties_food_unit."
|
||||
"property_amount is a decimal number. Please try to keep a percision of two decimal places if given in your source data."
|
||||
"property_amount is a decimal number. Please try to keep a precision of two decimal places if given in your source data."
|
||||
"Do not make up any data. If there is no data available for the given property type that is ok, just return null as a property_amount for that property type. Do not change anything else!"
|
||||
"Most property types are likely going to be nutritional values. Please do not make up any values, only return values you can find in the sources available to you."
|
||||
"Only return values if you are sure they are meant for the food given. Under no circumstance are you allowed to change any other value of the given food or change the structure in any way or form."
|
||||
@@ -1805,6 +1810,82 @@ class RecipeViewSet(LoggingMixin, viewsets.ModelViewSet, DeleteRelationMixing):
|
||||
|
||||
return Response(serializer.errors, 400)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(name='provider', description='ID of the AI provider that should be used for this AI request', type=int),
|
||||
]
|
||||
)
|
||||
@decorators.action(detail=True, methods=['POST'], )
|
||||
def aiproperties(self, request, pk):
|
||||
serializer = RecipeSerializer(data=request.data, partial=True, context={'request': request})
|
||||
if serializer.is_valid():
|
||||
|
||||
if not request.query_params.get('provider', None) or not re.match(r'^(\d)+$', request.query_params.get('provider', None)):
|
||||
response = {
|
||||
'error': True,
|
||||
'msg': _('You must select an AI provider to perform your request.'),
|
||||
}
|
||||
return Response(response, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not can_perform_ai_request(request.space):
|
||||
response = {
|
||||
'error': True,
|
||||
'msg': _("You don't have any credits remaining to use AI or AI features are not enabled for your space."),
|
||||
}
|
||||
return Response(response, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
ai_provider = AiProvider.objects.filter(pk=request.query_params.get('provider')).filter(Q(space=request.space) | Q(space__isnull=True)).first()
|
||||
|
||||
litellm.callbacks = [AiCallbackHandler(request.space, request.user, ai_provider, AiLog.F_RECIPE_PROPERTIES)]
|
||||
|
||||
property_type_list = list(PropertyType.objects.filter(space=request.space).values('id', 'name', 'description', 'unit', 'category', 'fdc_id'))
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Given the following recipe and the following different types of properties please update the recipe so that the properties attribute contains a list with all property types in the following format [{property_amount: <the property value>, property_type: {id: <the ID of the property type>, name: <the name of the property type>}}]."
|
||||
"The property values should be in the unit given in the property type and calculated based on the total quantity of the foods used for the recipe."
|
||||
"property_amount is a decimal number. Please try to keep a precision of two decimal places if given in your source data."
|
||||
"Do not make up any data. If there is no data available for the given property type that is ok, just return null as a property_amount for that property type. Do not change anything else!"
|
||||
"Most property types are likely going to be nutritional values. Please do not make up any values, only return values you can find in the sources available to you."
|
||||
"Under no circumstance are you allowed to change any other value of the given food or change the structure in any way or form."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": json.dumps(request.data)
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": json.dumps(property_type_list)
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
try:
|
||||
ai_request = {
|
||||
'api_key': ai_provider.api_key,
|
||||
'model': ai_provider.model_name,
|
||||
'response_format': {"type": "json_object"},
|
||||
'messages': messages,
|
||||
}
|
||||
if ai_provider.url:
|
||||
ai_request['api_base'] = ai_provider.url
|
||||
ai_response = completion(**ai_request)
|
||||
|
||||
response_text = ai_response.choices[0].message.content
|
||||
|
||||
return Response(json.loads(response_text), status=status.HTTP_200_OK)
|
||||
except BadRequestError as err:
|
||||
pass
|
||||
response = {
|
||||
'error': True,
|
||||
'msg': 'The AI could not process your request. \n\n' + err.message,
|
||||
}
|
||||
return Response(response, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(responses=RecipeSerializer(many=False))
|
||||
@decorators.action(detail=True, pagination_class=None, methods=['PATCH'], serializer_class=RecipeSerializer)
|
||||
def delete_external(self, request, pk):
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>
|
||||
<a href="https://hub.docker.com/r/vabene1111/recipes" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/docker/pulls/vabene1111/recipes" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/releases/latest" rel="noopener noreferrer"><img src="https://img.shields.io/github/v/release/vabene1111/recipes" ></a>
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
|
||||
<a href="https://app.tandoor.dev/e/demo-auto-login/" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://tandoor.dev" target="_blank" rel="noopener noreferrer">Website</a> •
|
||||
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a> •
|
||||
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Docs</a> •
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" target="_blank" rel="noopener noreferrer">Demo</a> •
|
||||
<a href="https://app.tandoor.dev/e/demo-auto-login/" target="_blank" rel="noopener noreferrer">Demo</a> •
|
||||
<a href="https://community.tandoor.dev" target="_blank" rel="noopener noreferrer">Community</a> •
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer">Discord</a>
|
||||
</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
These instructions are inspired from a standard django/gunicorn/postgresql instructions ([for example](https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04))
|
||||
|
||||
!!! warning
|
||||
Make sure to use at least Python 3.10 (although 3.12 is preferred) or higher, and ensure that `pip` is associated with Python 3. Depending on your system configuration, using `python` or `pip` might default to Python 2. Make sure your machine has at least 2048 MB of memory; otherwise, the `yarn build` process may fail with the error: `FATAL ERROR: Reached heap limit - Allocation failed: JavaScript heap out of memory`.
|
||||
Make sure to use at least Python 3.12 or higher, and ensure that `pip` is associated with Python 3. Depending on your system configuration, using `python` or `pip` might default to Python 2. Make sure your machine has at least 2048 MB of memory; otherwise, the `yarn build` process may fail with the error: `FATAL ERROR: Reached heap limit - Allocation failed: JavaScript heap out of memory`.
|
||||
|
||||
!!! warning
|
||||
These instructions are **not** regularly reviewed and might be outdated.
|
||||
|
||||
@@ -96,12 +96,15 @@ Configuration options for serving related services.
|
||||
|
||||
#### Port
|
||||
|
||||
> default `8080` - options: `1-65535`
|
||||
> default `80` - options: `1-65535`
|
||||
|
||||
Port for gunicorn to bind to. Should not be changed if using docker stack with reverse proxy.
|
||||
!!! warning
|
||||
Changed in version 2.3 to no longer configure the port of gunicorn but the port of the internal nginx
|
||||
|
||||
Port where Tandoor exposes its internal web server.
|
||||
|
||||
```
|
||||
TANDOOR_PORT=8080
|
||||
TANDOOR_PORT=80
|
||||
```
|
||||
|
||||
|
||||
@@ -186,6 +189,19 @@ See [Gunicorn docs](https://docs.gunicorn.org/en/stable/design.html#how-many-wor
|
||||
GUNICORN_THREADS=2
|
||||
```
|
||||
|
||||
|
||||
#### Gunicorn Timeout
|
||||
|
||||
> default `30` - options `1-X`
|
||||
|
||||
Set the timeout in seconds of gunicorn when starting using `boot.sh` (all container installations).
|
||||
The default is likely appropriate for most installations. However, if you are using a LLM which high response times gunicornmight time out during the wait until the LLM finished, in such cases you might want to increase the timeout.
|
||||
See [Gunicorn docs]([https://docs.gunicorn.org/en/stable/design.html#how-many-workers](https://docs.gunicorn.org/en/stable/settings.html#timeout)) for default settings.
|
||||
|
||||
```
|
||||
GUNICORN_TIMEOUT=30
|
||||
```
|
||||
|
||||
#### Gunicorn Media
|
||||
|
||||
> default `0` - options `0`, `1`
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80 ipv6only=on;
|
||||
listen ${TANDOOR_PORT};
|
||||
listen [::]:${TANDOOR_PORT} ipv6only=on;
|
||||
server_name localhost;
|
||||
|
||||
client_max_body_size 128M;
|
||||
client_max_body_size 512M;
|
||||
|
||||
# serve media files
|
||||
location /media {
|
||||
@@ -19,7 +19,10 @@ server {
|
||||
# pass requests for dynamic content to gunicorn
|
||||
location / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_pass http://localhost:${TANDOOR_PORT};
|
||||
proxy_pass http://unix:/run/tandoor.sock;
|
||||
|
||||
# param needed by django allauth sessions to log IP
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# disabled for now because it redirects to the error page and not back, also not showing html
|
||||
#error_page 502 /errors/http502.html;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Django==5.2.6
|
||||
Django==5.2.7
|
||||
cryptography===45.0.5
|
||||
django-annoying==0.10.6
|
||||
django-cleanup==9.0.0
|
||||
@@ -37,7 +37,7 @@ django-storages==1.14.6
|
||||
boto3==1.28.75
|
||||
django-prometheus==2.4.1
|
||||
django-hCaptcha==0.2.0
|
||||
python-ldap==3.4.4
|
||||
python-ldap==3.4.5
|
||||
django-auth-ldap==4.6.0
|
||||
pyppeteer==2.0.0
|
||||
pytubefix==9.2.2
|
||||
|
||||
@@ -53,13 +53,13 @@
|
||||
{{ fv.food.name }}
|
||||
</span>
|
||||
<template #append>
|
||||
<v-chip v-if="fv.value != undefined">{{ $n(fv.value) }} {{ dialogProperty.unit }}</v-chip>
|
||||
<v-chip color="create" v-else-if="fv.missing_conversion" class="cursor-pointer" prepend-icon="$create">
|
||||
<v-chip color="create" v-if="fv.missing_conversion" class="cursor-pointer" prepend-icon="$create">
|
||||
{{ $t('Conversion') }}: {{ fv.missing_conversion.base_unit.name }} <i class="fa-solid fa-arrow-right me-1 ms-1"></i>
|
||||
{{ fv.missing_conversion.converted_unit.name }}
|
||||
<model-edit-dialog model="UnitConversion" @create="refreshRecipe()"
|
||||
:item-defaults="{baseAmount: 1, baseUnit: fv.missing_conversion.base_unit, convertedUnit: fv.missing_conversion.converted_unit, food: fv.food}"></model-edit-dialog>
|
||||
</v-chip>
|
||||
<v-chip v-else-if="fv.value != undefined">{{ $n(fv.value) }} {{ dialogProperty.unit }}</v-chip>
|
||||
<v-chip color="warning" prepend-icon="$edit" class="cursor-pointer" :to="{name: 'ModelEditPage', params: {model: 'Recipe', id: recipe.id}}" v-else-if="fv.missing_unit">
|
||||
{{ $t('NoUnit') }}
|
||||
</v-chip>
|
||||
|
||||
@@ -229,7 +229,7 @@ const selectedAiProvider = ref<undefined | AiProvider>(useUserPreferenceStore().
|
||||
* factor for multiplying ingredient amounts based on recipe base servings and user selected servings
|
||||
*/
|
||||
const ingredientFactor = computed(() => {
|
||||
return servings.value / ((recipe.value.servings != undefined) ? recipe.value.servings : 1)
|
||||
return servings.value / ((recipe.value.servings != undefined) ? Math.max(recipe.value.servings, 1) : 1)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<v-row>
|
||||
<v-col>
|
||||
<span v-if="step.name">{{ step.name }}</span>
|
||||
<span v-else-if="step.stepRecipe"><v-icon icon="$recipes"></v-icon> {{ step.stepRecipeData.name }}</span>
|
||||
<span v-else>{{ $t('Step') }} {{ props.stepNumber }}</span>
|
||||
</v-col>
|
||||
<v-col class="text-right">
|
||||
@@ -23,11 +22,12 @@
|
||||
<timer :seconds="step.time != undefined ? step.time*60 : 0" @stop="timerRunning = false" v-if="timerRunning"></timer>
|
||||
<v-card-text v-if="step.ingredients.length > 0 || step.instruction != ''">
|
||||
<v-row>
|
||||
<v-col cols="12" md="6" v-if="step.ingredients.length > 0 && step.showIngredientsTable">
|
||||
<v-col cols="12" md="6" v-if="step.ingredients.length > 0 && (step.showIngredientsTable || step.show_ingredients_table)">
|
||||
<ingredients-table v-model="step.ingredients" :ingredient-factor="ingredientFactor"></ingredients-table>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="markdown-body">
|
||||
<instructions :instructions_html="step.instructionsMarkdown" :ingredient_factor="ingredientFactor" v-if="step.instructionsMarkdown != undefined"></instructions>
|
||||
<instructions :instructions_html="step.instructionsMarkdown" :ingredient_factor="ingredientFactor"
|
||||
v-if="step.instructionsMarkdown != undefined"></instructions>
|
||||
<!-- sub recipes dont have a correct schema, thus they use different variable naming -->
|
||||
<instructions :instructions_html="step.instructions_markdown" :ingredient_factor="ingredientFactor" v-else></instructions>
|
||||
</v-col>
|
||||
@@ -35,7 +35,12 @@
|
||||
</v-card-text>
|
||||
|
||||
<template v-if="step.stepRecipe">
|
||||
<v-card class="ma-2 border-md" prepend-icon="$recipes" :title="step.stepRecipeData.name">
|
||||
<v-card class="ma-2 border-md">
|
||||
<v-card-title>
|
||||
<v-icon icon="$recipes"></v-icon>
|
||||
{{ step.stepRecipeData.name }}
|
||||
<v-btn icon="fa-solid fa-up-right-from-square" size="x-small" :to="{name: 'RecipeViewPage', params: {id: step.stepRecipeData.id}}" target="_blank" variant="plain"></v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text class="mt-1" v-for="(subRecipeStep, subRecipeStepIndex) in step.stepRecipeData.steps" :key="subRecipeStep.id">
|
||||
<step-view v-model="step.stepRecipeData.steps[subRecipeStepIndex]" :step-number="subRecipeStepIndex+1" :ingredientFactor="ingredientFactor"></step-view>
|
||||
</v-card-text>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<v-btn-group density="compact">
|
||||
<v-btn color="create" @click="food.properties.push({} as Property)" prepend-icon="$create">{{ $t('Add') }}</v-btn>
|
||||
<v-btn color="create" @click="editingObj.properties.push({} as Property); addPropertiesFoodUnit()" prepend-icon="$create">{{ $t('Add') }}</v-btn>
|
||||
<v-btn color="secondary" @click="addAllProperties" prepend-icon="fa-solid fa-list">{{ $t('AddAll') }}</v-btn>
|
||||
<ai-action-button color="info" @selected="propertiesFromAi" :loading="aiLoading" prepend-icon="$ai">{{ $t('AI') }}</ai-action-button>
|
||||
</v-btn-group>
|
||||
|
||||
<v-row class="d-none d-md-flex mt-2" v-for="p in food.properties" dense>
|
||||
<v-row class="d-none d-md-flex mt-2" v-for="p in editingObj.properties" dense>
|
||||
<v-col cols="0" md="6">
|
||||
<v-number-input :step="10" v-model="p.propertyAmount" control-variant="stacked" :precision="2">
|
||||
<template #append-inner v-if="p.propertyType">
|
||||
@@ -25,7 +25,7 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-list class="d-md-none">
|
||||
<v-list-item v-for="p in food.properties" border>
|
||||
<v-list-item v-for="p in editingObj.properties" border>
|
||||
<span v-if="p.propertyType">{{ p.propertyAmount }} {{ p.propertyType.unit }} {{ p.propertyType.name }} / {{ props.amountFor }}
|
||||
</span>
|
||||
<span v-else><i><{{ $t('New') }}></i></span>
|
||||
@@ -41,18 +41,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {ApiApi, Food, Property} from "@/openapi";
|
||||
import {ApiApi, Food, Property, Recipe, Unit} from "@/openapi";
|
||||
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
import {PropType, ref} from "vue";
|
||||
import {computed, nextTick, onMounted, ref} from "vue";
|
||||
import AiActionButton from "@/components/buttons/AiActionButton.vue";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const props = defineProps({
|
||||
amountFor: {type: String, required: true},
|
||||
})
|
||||
|
||||
const food = defineModel<Food>({required: true})
|
||||
const isFood = computed(() => {
|
||||
return !('steps' in editingObj.value)
|
||||
})
|
||||
|
||||
const editingObj = defineModel<Food | Recipe>({required: true})
|
||||
|
||||
const aiLoading = ref(false)
|
||||
|
||||
@@ -61,8 +66,8 @@ const aiLoading = ref(false)
|
||||
* @param property property to delete
|
||||
*/
|
||||
function deleteProperty(property: Property) {
|
||||
if (food.value.properties) {
|
||||
food.value.properties = food.value.properties.filter(p => p !== property)
|
||||
if (editingObj.value.properties) {
|
||||
editingObj.value.properties = editingObj.value.properties.filter(p => p !== property)
|
||||
// TODO delete from DB, needs endpoint for property relation to either recipe or food
|
||||
}
|
||||
}
|
||||
@@ -74,14 +79,16 @@ function deleteProperty(property: Property) {
|
||||
function addAllProperties() {
|
||||
const api = new ApiApi()
|
||||
|
||||
if (food.value.properties) {
|
||||
food.value.properties = []
|
||||
}
|
||||
// if (editingObj.value.properties) {
|
||||
// editingObj.value.properties = []
|
||||
// }
|
||||
|
||||
addPropertiesFoodUnit()
|
||||
|
||||
api.apiPropertyTypeList().then(r => {
|
||||
r.results.forEach(pt => {
|
||||
if (food.value.properties.findIndex(x => x.propertyType.name == pt.name) == -1) {
|
||||
food.value.properties.push({propertyAmount: 0, propertyType: pt} as Property)
|
||||
if (editingObj.value.properties.findIndex(x => x.propertyType.name == pt.name) == -1) {
|
||||
editingObj.value.properties.push({propertyAmount: 0, propertyType: pt} as Property)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -90,13 +97,39 @@ function addAllProperties() {
|
||||
function propertiesFromAi(providerId: number) {
|
||||
const api = new ApiApi()
|
||||
aiLoading.value = true
|
||||
api.apiFoodAipropertiesCreate({id: food.value.id!, food: food.value, provider: providerId}).then(r => {
|
||||
food.value = r
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
}).finally(() => {
|
||||
aiLoading.value = false
|
||||
})
|
||||
|
||||
if (isFood.value) {
|
||||
api.apiFoodAipropertiesCreate({id: editingObj.value.id!, food: editingObj.value, provider: providerId}).then(r => {
|
||||
editingObj.value = r
|
||||
nextTick(() => {
|
||||
addPropertiesFoodUnit()
|
||||
})
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
}).finally(() => {
|
||||
aiLoading.value = false
|
||||
})
|
||||
} else {
|
||||
api.apiRecipeAipropertiesCreate({id: editingObj.value.id!, recipe: editingObj.value, provider: providerId}).then(r => {
|
||||
editingObj.value = r
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
}).finally(() => {
|
||||
aiLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* if its empty add the properties food unit
|
||||
*/
|
||||
function addPropertiesFoodUnit(){
|
||||
console.log('ADDING UNIT', !editingObj.value.propertiesFoodUnit)
|
||||
if (isFood.value && !editingObj.value.propertiesFoodUnit) {
|
||||
console.log('ADDING UNIT ACTUALLY')
|
||||
editingObj.value.propertiesFoodUnit = (useUserPreferenceStore().defaultUnitObj != null) ? useUserPreferenceStore().defaultUnitObj! : {name: 'g'} as Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -60,56 +60,58 @@
|
||||
<v-label>{{ $t('Ingredients') }}</v-label>
|
||||
<div v-if="!mobile">
|
||||
<vue-draggable v-model="step.ingredients" handle=".drag-handle" :on-sort="sortIngredients" :empty-insert-threshold="25" group="ingredients">
|
||||
<v-row v-for="(ingredient, index) in step.ingredients" :key="ingredient.id" class="d-flex flex-nowrap" dense>
|
||||
<v-col cols="12" class="pa-0 ma-0 text-center text-disabled" v-if="ingredient.originalText">
|
||||
<div v-for="(ingredient, index) in step.ingredients" :key="ingredient.id" dense>
|
||||
<div class="pa-0 ma-0 text-center text-disabled" v-if="ingredient.originalText">
|
||||
<v-icon icon="$import" size="x-small"></v-icon>
|
||||
{{ ingredient.originalText }}
|
||||
</v-col>
|
||||
<div class="flex-col flex-grow-0 ma-1" style="min-width: 15%" v-if="!ingredient.isHeader">
|
||||
<v-text-field :id="`id_input_amount_${step.id}_${index}`" :label="$t('Amount')" type="number" v-model="ingredient.amount" density="compact"
|
||||
hide-details :disabled="ingredient.noAmount">
|
||||
</div>
|
||||
<div class="d-flex flex-nowrap">
|
||||
<div class="flex-col flex-grow-0 ma-1" style="min-width: 15%" v-if="!ingredient.isHeader">
|
||||
<v-number-input :id="`id_input_amount_${props.stepIndex}_${index}`" :label="$t('Amount')" type="number" v-model="ingredient.amount" density="compact"
|
||||
hide-details control-variant="hidden" :disabled="ingredient.noAmount">
|
||||
|
||||
<template #prepend>
|
||||
<v-icon icon="$dragHandle" class="drag-handle cursor-grab"></v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
<div class="flex-col flex-grow-0 ma-1" style="min-width: 15%" v-if="!ingredient.isHeader ">
|
||||
<model-select model="Unit" v-model="ingredient.unit" density="compact" allow-create hide-details :disabled="ingredient.noAmount"></model-select>
|
||||
</div>
|
||||
<div class="flex-col flex-grow-1 ma-1" style="min-width: 15%" v-if="!ingredient.isHeader">
|
||||
<model-select model="Food" v-model="ingredient.food" density="compact" allow-create hide-details></model-select>
|
||||
</div>
|
||||
<div class="flex-col ma-1" style="min-width: 15%" :class="{'flex-grow-1': ingredient.isHeader, 'flex-grow-0': !ingredient.isHeader}" @keydown.tab="event => handleIngredientNoteTab(event, index)">
|
||||
<v-text-field :label="(ingredient.isHeader) ? $t('Headline') : $t('Note')" v-model="ingredient.note" density="compact" hide-details>
|
||||
<template #prepend v-if="ingredient.isHeader">
|
||||
<v-icon icon="$dragHandle" class="drag-handle cursor-grab"></v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
<div class="flex-col flex-grow-0 d-flex ma-1">
|
||||
<div class="d-flex align-center justify-center">
|
||||
<v-btn variant="plain" class="" density="compact" tabindex="-1" icon>
|
||||
<v-icon icon="$menu"></v-icon>
|
||||
<v-menu activator="parent">
|
||||
<v-list>
|
||||
<v-list-item link>
|
||||
<v-switch v-model="step.ingredients[index].isHeader" :label="$t('Headline')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item link>
|
||||
<v-switch v-model="step.ingredients[index].noAmount" :label="$t('Disable_Amount')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item @click="editingIngredientIndex = index; dialogIngredientSorter = true" prepend-icon="fa-solid fa-sort">
|
||||
{{ $t('Move') }}
|
||||
</v-list-item>
|
||||
<v-list-item @click="step.ingredients.splice(index, 1)" prepend-icon="$delete">{{ $t('Delete') }}</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
<template #prepend>
|
||||
<v-icon icon="$dragHandle" class="drag-handle cursor-grab"></v-icon>
|
||||
</template>
|
||||
</v-number-input>
|
||||
</div>
|
||||
<div class="flex-col flex-grow-0 ma-1" style="min-width: 15%" v-if="!ingredient.isHeader ">
|
||||
<model-select model="Unit" v-model="ingredient.unit" density="compact" allow-create hide-details :disabled="ingredient.noAmount"></model-select>
|
||||
</div>
|
||||
<div class="flex-col flex-grow-1 ma-1" style="min-width: 15%" v-if="!ingredient.isHeader">
|
||||
<model-select model="Food" v-model="ingredient.food" density="compact" allow-create hide-details></model-select>
|
||||
</div>
|
||||
<div class="flex-col ma-1" style="min-width: 15%" :class="{'flex-grow-1': ingredient.isHeader, 'flex-grow-0': !ingredient.isHeader}"
|
||||
@keydown.tab="event => handleIngredientNoteTab(event, index)">
|
||||
<v-text-field :label="(ingredient.isHeader) ? $t('Headline') : $t('Note')" v-model="ingredient.note" density="compact" hide-details>
|
||||
<template #prepend v-if="ingredient.isHeader">
|
||||
<v-icon icon="$dragHandle" class="drag-handle cursor-grab"></v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
<div class="flex-col flex-grow-0 d-flex ma-1">
|
||||
<div class="d-flex align-center justify-center">
|
||||
<v-btn variant="plain" class="" density="compact" tabindex="-1" icon>
|
||||
<v-icon icon="$menu"></v-icon>
|
||||
<v-menu activator="parent">
|
||||
<v-list>
|
||||
<v-list-item link>
|
||||
<v-switch v-model="step.ingredients[index].isHeader" :label="$t('Headline')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item link>
|
||||
<v-switch v-model="step.ingredients[index].noAmount" :label="$t('Disable_Amount')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item @click="editingIngredientIndex = index; dialogIngredientSorter = true" prepend-icon="fa-solid fa-sort">
|
||||
{{ $t('Move') }}
|
||||
</v-list-item>
|
||||
<v-list-item @click="step.ingredients.splice(index, 1)" prepend-icon="$delete">{{ $t('Delete') }}</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</v-row>
|
||||
</div>
|
||||
</vue-draggable>
|
||||
</div>
|
||||
|
||||
@@ -259,24 +261,6 @@ const dialogIngredientSorter = ref(false)
|
||||
const editingIngredientIndex = ref(0)
|
||||
const ingredientTextInput = ref("")
|
||||
|
||||
const defaultUnit = ref<null | Unit>(null)
|
||||
|
||||
onMounted(() => {
|
||||
let api = new ApiApi()
|
||||
|
||||
if (useUserPreferenceStore().userSettings.defaultUnit) {
|
||||
api.apiUnitList({query: useUserPreferenceStore().userSettings.defaultUnit}).then(r => {
|
||||
r.results.forEach(u => {
|
||||
if (u.name == useUserPreferenceStore().userSettings.defaultUnit) {
|
||||
defaultUnit.value = u
|
||||
}
|
||||
})
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* sort function called by draggable when ingredient table is sorted
|
||||
*/
|
||||
@@ -332,14 +316,10 @@ function handleIngredientNoteTab(event: KeyboardEvent, index: number) {
|
||||
function insertAndFocusIngredient() {
|
||||
let ingredient = {
|
||||
amount: 0,
|
||||
unit: null,
|
||||
unit: useUserPreferenceStore().defaultUnitObj,
|
||||
food: null,
|
||||
} as Ingredient
|
||||
|
||||
if (defaultUnit.value != null) {
|
||||
ingredient.unit = defaultUnit.value
|
||||
}
|
||||
|
||||
step.value.ingredients.push(ingredient)
|
||||
nextTick(() => {
|
||||
sortIngredients()
|
||||
@@ -347,7 +327,7 @@ function insertAndFocusIngredient() {
|
||||
editingIngredientIndex.value = step.value.ingredients.length - 1
|
||||
dialogIngredientEditor.value = true
|
||||
} else {
|
||||
document.getElementById(`id_input_amount_${step.value.id}_${step.value.ingredients.length - 1}`).select()
|
||||
document.getElementById(`id_input_amount_${props.stepIndex}_${step.value.ingredients.length - 1}`).select()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
<v-list density="compact">
|
||||
<v-list-subheader>{{$t('Ingredients')}}</v-list-subheader>
|
||||
<v-list-item
|
||||
v-for="template in templates"
|
||||
@click="insertTextAtPosition(template.template + ' ')"
|
||||
v-for="t in templates"
|
||||
@click="insertTextAtPosition(t.template + ' ')"
|
||||
>
|
||||
<ingredient-string :ingredient="template.ingredient"></ingredient-string>
|
||||
<ingredient-string :ingredient="t.ingredient"></ingredient-string>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
@@ -65,7 +65,7 @@ const templates = computed(() => {
|
||||
function insertTextAtPosition(text: string){
|
||||
let textarea = markdownEditor.value.getTextareaDom()
|
||||
let position = textarea.selectionStart
|
||||
if (step.value.instruction){
|
||||
if (step.value.instruction != undefined){
|
||||
step.value.instruction = step.value.instruction.slice(0, position) + text + step.value.instruction.slice(position)
|
||||
|
||||
nextTick(() => {
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
<v-rating v-model="editingObj.rating" clearable hover density="compact"></v-rating>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
|
||||
<v-number-input :label="$t('Servings')" v-model="editingObj.servings" :precision="2"></v-number-input>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
@@ -42,7 +41,7 @@ import {onMounted, PropType, watch} from "vue";
|
||||
import {CookLog} from "@/openapi";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
|
||||
import {VDateInput} from 'vuetify/labs/VDateInput' //TODO remove once component is out of labs
|
||||
|
||||
const props = defineProps({
|
||||
item: {type: {} as PropType<CookLog>, required: false, default: null},
|
||||
|
||||
@@ -226,7 +226,7 @@ function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {
|
||||
newItemFunction: () => {
|
||||
editingObj.value.propertiesFoodAmount = 100
|
||||
editingObj.value.propertiesFoodUnit = {name: (useUserPreferenceStore().userSettings.defaultUnit != undefined ? useUserPreferenceStore().userSettings.defaultUnit : 'g')} as Unit
|
||||
editingObj.value.propertiesFoodUnit = (useUserPreferenceStore().defaultUnitObj != null) ? useUserPreferenceStore().defaultUnitObj! : {name: 'g'} as Unit
|
||||
},
|
||||
itemDefaults: props.itemDefaults,
|
||||
})
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<v-tabs v-model="tab" :disabled="loading || fileApiLoading" grow>
|
||||
<v-tab value="recipe">{{ $t('Recipe') }}</v-tab>
|
||||
<v-tab value="steps">{{ $t('Steps') }}</v-tab>
|
||||
<v-tab value="properties">{{ $t('Properties') }}</v-tab>
|
||||
<v-tab value="settings">{{ $t('Miscellaneous') }}</v-tab>
|
||||
<v-tab value="properties" :disabled="!isUpdate()">{{ $t('Properties') }}</v-tab>
|
||||
<v-tab value="settings" :disabled="!isUpdate()">{{ $t('Miscellaneous') }}</v-tab>
|
||||
</v-tabs>
|
||||
</v-card-text>
|
||||
<v-card-text v-if="!isSpaceAtRecipeLimit(useUserPreferenceStore().activeSpace)">
|
||||
@@ -87,6 +87,12 @@
|
||||
</v-row>
|
||||
|
||||
<v-form :disabled="loading || fileApiLoading">
|
||||
<v-row v-if="editingObj.steps.length == 0">
|
||||
<v-col class="text-center">
|
||||
<v-btn icon="$create" variant="outlined" size="x-small" @click="addStep(i+1)"></v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-for="(s,i ) in editingObj.steps" :key="s.id" dense>
|
||||
<v-col>
|
||||
<step-editor v-model="editingObj.steps[i]" v-model:recipe="editingObj" :step-index="i" @delete="deleteStepAtIndex(i)" @move="dialogStepManager = true"></step-editor>
|
||||
@@ -106,7 +112,10 @@
|
||||
<v-tabs-window-item value="properties">
|
||||
<v-form :disabled="loading || fileApiLoading">
|
||||
<closable-help-alert :text="$t('PropertiesFoodHelp')"></closable-help-alert>
|
||||
<properties-editor v-model="editingObj.properties" :amount-for="$t('Serving')"></properties-editor>
|
||||
<properties-editor v-model="editingObj" :amount-for="$t('Serving')"></properties-editor>
|
||||
|
||||
<!-- TODO remove once append to body for model select is working properly -->
|
||||
<v-spacer style="margin-top: 100px;"></v-spacer>
|
||||
</v-form>
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="settings">
|
||||
@@ -226,7 +235,7 @@ function initializeEditor() {
|
||||
addStep()
|
||||
editingObj.value.steps[0].ingredients.push({
|
||||
food: null,
|
||||
unit: null,
|
||||
unit: useUserPreferenceStore().defaultUnitObj,
|
||||
amount: 0,
|
||||
} as Ingredient)
|
||||
editingObj.value.internal = true //TODO make database default after v2
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col md="6">
|
||||
<v-number-input :label="$t('Amount')" :step="10" v-model="editingObj.baseAmount" control-variant="stacked" :precision="3"></v-number-input>
|
||||
<v-number-input :label="$t('Amount')" :step="10" v-model="editingObj.baseAmount" control-variant="stacked" :precision="3" :min="0.001"></v-number-input>
|
||||
</v-col>
|
||||
<v-col md="6">
|
||||
<!-- TODO fix card overflow invisible, overflow-visible class is not working -->
|
||||
<model-select :label="$t('Unit')" v-model="editingObj.baseUnit" model="Unit"></model-select>
|
||||
<model-select v-model="editingObj.baseUnit" model="Unit"></model-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="mt-0">
|
||||
@@ -33,11 +33,11 @@
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col md="6">
|
||||
<v-number-input :label="$t('Amount')" :step="10" v-model="editingObj.convertedAmount" control-variant="stacked" :precision="3"></v-number-input>
|
||||
<v-number-input :label="$t('Amount')" :step="10" v-model="editingObj.convertedAmount" control-variant="stacked" :precision="3" :min="0.001"></v-number-input>
|
||||
</v-col>
|
||||
<v-col md="6">
|
||||
<!-- TODO fix card overflow invisible, overflow-visible class is not working -->
|
||||
<model-select :label="$t('Unit')" v-model="editingObj.convertedUnit" model="Unit"></model-select>
|
||||
<model-select v-model="editingObj.convertedUnit" model="Unit"></model-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
Authentication works by proving the word <code>Bearer</code> followed by an API Token as a request Authorization
|
||||
header as shown below. <br/>
|
||||
<code>Authorization: Bearer TOKEN</code> -or-<br/>
|
||||
<code>curl -X GET http://your.domain.com/api/recipes/ -H 'Authorization:
|
||||
<code>curl -X GET http://your.domain.com/api/recipe/ -H 'Authorization:
|
||||
Bearer TOKEN'</code>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -124,6 +124,8 @@ export function useFileApi() {
|
||||
* @returns Promise resolving to the import ID of the app import
|
||||
*/
|
||||
function doAppImport(files: File[], app: string, includeDuplicates: boolean, mealPlans: boolean = true, shoppingLists: boolean = true, nutritionPerServing: boolean = false,) {
|
||||
fileApiLoading.value = true
|
||||
|
||||
let formData = new FormData()
|
||||
formData.append('type', app);
|
||||
formData.append('duplicates', includeDuplicates ? 'true' : 'false')
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"Fats": "",
|
||||
"File": "",
|
||||
"Files": "",
|
||||
"Finish": "",
|
||||
"Food": "",
|
||||
"FoodInherit": "",
|
||||
"FoodNotOnHand": "",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"Fats": "Мазнини",
|
||||
"File": "Файл",
|
||||
"Files": "Файлове",
|
||||
"Finish": "",
|
||||
"Food": "Храна",
|
||||
"FoodInherit": "Хранителни наследствени полета",
|
||||
"FoodNotOnHand": "Нямате {храна} под ръка.",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "Greixos",
|
||||
"File": "Arxiu",
|
||||
"Files": "Arxius",
|
||||
"Finish": "",
|
||||
"First_name": "Nom",
|
||||
"Food": "Aliment",
|
||||
"FoodInherit": "Camps Heretats",
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"Fats": "Tuky",
|
||||
"File": "Soubor",
|
||||
"Files": "Soubory",
|
||||
"Finish": "",
|
||||
"First_name": "Jméno",
|
||||
"Food": "Potravina",
|
||||
"FoodInherit": "Propisovatelná pole potraviny",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "Fedtstoffer",
|
||||
"File": "Fil",
|
||||
"Files": "Filer",
|
||||
"Finish": "",
|
||||
"First_name": "Fornavn",
|
||||
"Food": "Mad",
|
||||
"FoodInherit": "Nedarvelige mad felter",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"BaseUnit": "Basiseinheit",
|
||||
"BaseUnitHelp": "Optionale Standardeinheit zur automatischen Umrechnung",
|
||||
"Basics": "Grundlagen",
|
||||
"BatchDeleteConfirm": "Möchtest du alle angezeigten Objekte löschen? Dies kann nicht rückgängig gemacht werden!",
|
||||
"BatchDeleteConfirm": "Möchtest du alle angezeigten Objekte löschen? Dies kann nicht rückgängig gemacht werden! ACHTUNG: Es ist möglich das Objekte gelöscht werden die an anderen Stellen verwendet werden!",
|
||||
"BatchDeleteHelp": "Wenn ein Objekt nicht gelöscht werden kann, wird es noch irgendwo verwendet. ",
|
||||
"BatchEdit": "Massenbearbeitung",
|
||||
"BatchEditUpdatingItemsCount": "Bearbeite {count} {type}",
|
||||
@@ -212,6 +212,7 @@
|
||||
"Fats": "Fette",
|
||||
"File": "Datei",
|
||||
"Files": "Dateien",
|
||||
"Finish": "Fertigstellen",
|
||||
"FinishedAt": "Fertig um",
|
||||
"First": "Erstes",
|
||||
"First_name": "Vorname",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "Λιπαρά",
|
||||
"File": "Αρχείο",
|
||||
"Files": "Αρχεία",
|
||||
"Finish": "",
|
||||
"First_name": "Όνομα",
|
||||
"Food": "Φαγητό",
|
||||
"FoodInherit": "Πεδία φαγητών που κληρονομούνται",
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"BaseUnit": "Base Unit",
|
||||
"BaseUnitHelp": "Standard unit for automatic unit conversion",
|
||||
"Basics": "Basics",
|
||||
"BatchDeleteConfirm": "Do you want to delete all shown items? This cannot be undone!",
|
||||
"BatchDeleteConfirm": "Do you want to delete all shown items? This cannot be undone! WARNING: It is possible that this deletes objects that are used elsewhere. ",
|
||||
"BatchDeleteHelp": "If an item cannot be deleted it is used somewhere. ",
|
||||
"BatchEdit": "Batch Edit",
|
||||
"BatchEditUpdatingItemsCount": "Editing {count} {type}",
|
||||
@@ -210,6 +210,7 @@
|
||||
"Fats": "Fats",
|
||||
"File": "File",
|
||||
"Files": "Files",
|
||||
"Finish": "Finish",
|
||||
"FinishedAt": "Finished at",
|
||||
"First": "First",
|
||||
"First_name": "First Name",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"Fats": "Grasas",
|
||||
"File": "Archivo",
|
||||
"Files": "Archivos",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Finaliza a las",
|
||||
"First": "Primero",
|
||||
"First_name": "Nombre",
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
"Fats": "Rasvat",
|
||||
"File": "Tiedosto",
|
||||
"Files": "Tiedostot",
|
||||
"Finish": "",
|
||||
"First_name": "Etunimi",
|
||||
"Food": "Ruoka",
|
||||
"FoodInherit": "Ruoan perinnölliset kentät",
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"Fats": "Matières grasses",
|
||||
"File": "Fichier",
|
||||
"Files": "Fichiers",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Terminé à",
|
||||
"First": "Premier",
|
||||
"First_name": "Prénom",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "שומנים",
|
||||
"File": "קובץ",
|
||||
"Files": "קבצים",
|
||||
"Finish": "",
|
||||
"First_name": "שם פרטי",
|
||||
"Food": "אוכל",
|
||||
"FoodInherit": "ערכי מזון",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "Masti",
|
||||
"File": "Datoteka",
|
||||
"Files": "Datoteke",
|
||||
"Finish": "",
|
||||
"First_name": "Ime",
|
||||
"Food": "Namirnica",
|
||||
"FoodInherit": "Nasljedna polja namirnice",
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
"Fats": "Zsírok",
|
||||
"File": "Fájl",
|
||||
"Files": "Fájlok",
|
||||
"Finish": "",
|
||||
"First_name": "Keresztnév",
|
||||
"Food": "Alapanyag",
|
||||
"FoodInherit": "",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"Fats": "",
|
||||
"File": "",
|
||||
"Files": "",
|
||||
"Finish": "",
|
||||
"Food": "Սննդամթերք",
|
||||
"FromBalance": "",
|
||||
"Fulltext": "",
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"Fats": "Lemak",
|
||||
"File": "Berkas",
|
||||
"Files": "File",
|
||||
"Finish": "",
|
||||
"First_name": "",
|
||||
"Food": "",
|
||||
"FoodInherit": "",
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"Fats": "",
|
||||
"File": "",
|
||||
"Files": "",
|
||||
"Finish": "",
|
||||
"First_name": "",
|
||||
"Food": "",
|
||||
"FoodInherit": "",
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"BaseUnit": "Unità di base",
|
||||
"BaseUnitHelp": "Unità standard per la conversione automatica di unità",
|
||||
"Basics": "Informazioni di base",
|
||||
"BatchDeleteConfirm": "Vuoi eliminare tutti gli elementi mostrati? Questo non può essere annullato!",
|
||||
"BatchDeleteConfirm": "",
|
||||
"BatchDeleteHelp": "Se un elemento non può essere eliminato, è utilizzato altrove. ",
|
||||
"BatchEdit": "Modifica massiva",
|
||||
"BatchEditUpdatingItemsCount": "Modifica di {count} {type}",
|
||||
@@ -211,6 +211,7 @@
|
||||
"Fats": "Grassi",
|
||||
"File": "File",
|
||||
"Files": "File",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Finito alle",
|
||||
"First": "Primo",
|
||||
"First_name": "Nome",
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"Fats": "",
|
||||
"File": "",
|
||||
"Files": "",
|
||||
"Finish": "",
|
||||
"First_name": "",
|
||||
"Food": "",
|
||||
"FoodInherit": "",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "",
|
||||
"File": "",
|
||||
"Files": "",
|
||||
"Finish": "",
|
||||
"First_name": "",
|
||||
"Food": "",
|
||||
"FoodInherit": "",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"Fats": "Fett",
|
||||
"File": "Fil",
|
||||
"Files": "Filer",
|
||||
"Finish": "",
|
||||
"First_name": "Fornavn",
|
||||
"Food": "Matretter",
|
||||
"FoodInherit": "Arvbare felt for matvarer",
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
"Fats": "Vetten",
|
||||
"File": "Bestand",
|
||||
"Files": "Bestanden",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Afgerond op",
|
||||
"First": "Eerste",
|
||||
"First_name": "Voornaam",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"Fats": "Tłuszcze",
|
||||
"File": "Plik",
|
||||
"Files": "Pliki",
|
||||
"Finish": "",
|
||||
"First_name": "Imię",
|
||||
"Food": "Żywność",
|
||||
"FoodInherit": "Pola dziedziczone w żywności",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"Auto_Sort_Help": "Mover todos os ingredientes para o passo mais indicado.",
|
||||
"Automate": "Automatizar",
|
||||
"Automation": "Automação",
|
||||
"BatchDeleteConfirm": "",
|
||||
"Books": "Livros",
|
||||
"Calculator": "Calculadora",
|
||||
"Calories": "Calorias",
|
||||
@@ -103,6 +104,7 @@
|
||||
"Fats": "Gorduras",
|
||||
"File": "Ficheiro",
|
||||
"Files": "Ficheiros",
|
||||
"Finish": "",
|
||||
"Food": "Comida",
|
||||
"FoodInherit": "Campos herdados por comida",
|
||||
"FoodNotOnHand": "Não têm {food} disponível.",
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
"Fats": "Gorduras",
|
||||
"File": "Arquivo",
|
||||
"Files": "Arquivos",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Finalizado em",
|
||||
"First": "Primeiro",
|
||||
"First_name": "Primeiro Nome",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"Fats": "Grăsimi",
|
||||
"File": "Fișier",
|
||||
"Files": "Fișiere",
|
||||
"Finish": "",
|
||||
"First_name": "Prenume",
|
||||
"Food": "Mâncare",
|
||||
"FoodInherit": "Câmpuri moștenite de alimente",
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"Fats": "Жиры",
|
||||
"File": "Файл",
|
||||
"Files": "Файлы",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Завершено в",
|
||||
"First": "Первый",
|
||||
"First_name": "Имя",
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"Fats": "Maščobe",
|
||||
"File": "Datoteka",
|
||||
"Files": "Datoteke",
|
||||
"Finish": "",
|
||||
"FinishedAt": "Končano ob",
|
||||
"First": "Prvi",
|
||||
"First_name": "Ime",
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
"Fats": "Fett",
|
||||
"File": "Fil",
|
||||
"Files": "Filer",
|
||||
"Finish": "",
|
||||
"First_name": "Förnamn",
|
||||
"Food": "Livsmedel",
|
||||
"FoodInherit": "Ärftliga livsmedels fält",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "Yağlar",
|
||||
"File": "Dosya",
|
||||
"Files": "Dosyalar",
|
||||
"Finish": "",
|
||||
"First_name": "İsim",
|
||||
"Food": "Yiyecek",
|
||||
"FoodInherit": "Yiyeceğin Devralınabileceği Alanlar",
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
"Fats": "Жири",
|
||||
"File": "Файл",
|
||||
"Files": "Файли",
|
||||
"Finish": "",
|
||||
"Food": "Їжа",
|
||||
"FoodInherit": "Пола Успадкованої Їжі",
|
||||
"FoodNotOnHand": "У вас немає {food} на руках.",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"Fats": "脂肪",
|
||||
"File": "文件",
|
||||
"Files": "文件",
|
||||
"Finish": "",
|
||||
"First_name": "名",
|
||||
"Food": "食物",
|
||||
"FoodInherit": "食物可继承的字段",
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
"Fats": "脂肪",
|
||||
"File": "檔案",
|
||||
"Files": "檔案",
|
||||
"Finish": "",
|
||||
"FinishedAt": "完成於",
|
||||
"First": "第一個",
|
||||
"First_name": "名字",
|
||||
|
||||
@@ -877,6 +877,12 @@ export interface ApiEnterpriseSocialKeywordUpdateRequest {
|
||||
keyword: Omit<Keyword, 'label'|'parent'|'numchild'|'createdAt'|'updatedAt'|'fullName'>;
|
||||
}
|
||||
|
||||
export interface ApiEnterpriseSocialRecipeAipropertiesCreateRequest {
|
||||
id: number;
|
||||
recipe: Omit<Recipe, 'image'|'createdBy'|'createdAt'|'updatedAt'|'foodProperties'|'rating'|'lastCooked'>;
|
||||
provider?: number;
|
||||
}
|
||||
|
||||
export interface ApiEnterpriseSocialRecipeBatchUpdateUpdateRequest {
|
||||
recipeBatchUpdate: RecipeBatchUpdate;
|
||||
}
|
||||
@@ -1689,6 +1695,12 @@ export interface ApiPropertyUpdateRequest {
|
||||
property: Property;
|
||||
}
|
||||
|
||||
export interface ApiRecipeAipropertiesCreateRequest {
|
||||
id: number;
|
||||
recipe: Omit<Recipe, 'image'|'createdBy'|'createdAt'|'updatedAt'|'foodProperties'|'rating'|'lastCooked'>;
|
||||
provider?: number;
|
||||
}
|
||||
|
||||
export interface ApiRecipeBatchUpdateUpdateRequest {
|
||||
recipeBatchUpdate: RecipeBatchUpdate;
|
||||
}
|
||||
@@ -5574,6 +5586,57 @@ export class ApiApi extends runtime.BaseAPI {
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
async apiEnterpriseSocialRecipeAipropertiesCreateRaw(requestParameters: ApiEnterpriseSocialRecipeAipropertiesCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Recipe>> {
|
||||
if (requestParameters['id'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'id',
|
||||
'Required parameter "id" was null or undefined when calling apiEnterpriseSocialRecipeAipropertiesCreate().'
|
||||
);
|
||||
}
|
||||
|
||||
if (requestParameters['recipe'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'recipe',
|
||||
'Required parameter "recipe" was null or undefined when calling apiEnterpriseSocialRecipeAipropertiesCreate().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
if (requestParameters['provider'] != null) {
|
||||
queryParameters['provider'] = requestParameters['provider'];
|
||||
}
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
headerParameters['Content-Type'] = 'application/json';
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
|
||||
}
|
||||
|
||||
const response = await this.request({
|
||||
path: `/api/enterprise-social-recipe/{id}/aiproperties/`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
|
||||
method: 'POST',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
body: RecipeToJSON(requestParameters['recipe']),
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response, (jsonValue) => RecipeFromJSON(jsonValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
async apiEnterpriseSocialRecipeAipropertiesCreate(requestParameters: ApiEnterpriseSocialRecipeAipropertiesCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Recipe> {
|
||||
const response = await this.apiEnterpriseSocialRecipeAipropertiesCreateRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
@@ -12351,6 +12414,57 @@ export class ApiApi extends runtime.BaseAPI {
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
async apiRecipeAipropertiesCreateRaw(requestParameters: ApiRecipeAipropertiesCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Recipe>> {
|
||||
if (requestParameters['id'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'id',
|
||||
'Required parameter "id" was null or undefined when calling apiRecipeAipropertiesCreate().'
|
||||
);
|
||||
}
|
||||
|
||||
if (requestParameters['recipe'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'recipe',
|
||||
'Required parameter "recipe" was null or undefined when calling apiRecipeAipropertiesCreate().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
if (requestParameters['provider'] != null) {
|
||||
queryParameters['provider'] = requestParameters['provider'];
|
||||
}
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
headerParameters['Content-Type'] = 'application/json';
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
|
||||
}
|
||||
|
||||
const response = await this.request({
|
||||
path: `/api/recipe/{id}/aiproperties/`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
|
||||
method: 'POST',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
body: RecipeToJSON(requestParameters['recipe']),
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response, (jsonValue) => RecipeFromJSON(jsonValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
async apiRecipeAipropertiesCreate(requestParameters: ApiRecipeAipropertiesCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Recipe> {
|
||||
const response = await this.apiRecipeAipropertiesCreateRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</v-list-item>
|
||||
<v-list-item link prepend-icon="fa-solid fa-arrows-to-dot" :disabled="!selectedFood">
|
||||
{{ $t('Merge') }}
|
||||
<model-merge-dialog :source="selectedFood" model="Food"
|
||||
<model-merge-dialog :source="[selectedFood]" model="Food"
|
||||
@change="(obj: Food) => {selectedFood = obj;refreshPage()} "></model-merge-dialog>
|
||||
</v-list-item>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</v-list-item>
|
||||
<v-list-item link prepend-icon="fa-solid fa-arrows-to-dot" :disabled="!selectedUnit">
|
||||
{{ $t('Merge') }}
|
||||
<model-merge-dialog :source="selectedUnit" model="Unit"
|
||||
<model-merge-dialog :source="[selectedUnit]" model="Unit"
|
||||
@change="(obj: Food) => {selectedUnit = obj;refreshPage()} "></model-merge-dialog>
|
||||
</v-list-item>
|
||||
<v-list-item link prepend-icon="$automation" :disabled="!selectedUnit">
|
||||
@@ -117,12 +117,12 @@
|
||||
@update:modelValue="item.changed = true" :precision="2"></v-number-input>
|
||||
</template>
|
||||
<template v-slot:item.unit="{ item }">
|
||||
<model-select model="Unit" v-model="item.unit" :label="$t('Unit')" density="compact" hide-details allow-create append-to-body
|
||||
<model-select model="Unit" v-model="item.unit" density="compact" hide-details allow-create append-to-body
|
||||
@update:modelValue="item.changed = true">
|
||||
</model-select>
|
||||
</template>
|
||||
<template v-slot:item.food="{ item }">
|
||||
<model-select model="Food" v-model="item.food" :label="$t('Food')" density="compact" hide-details allow-create append-to-body
|
||||
<model-select model="Food" v-model="item.food" density="compact" hide-details allow-create append-to-body
|
||||
@update:modelValue="item.changed = true"></model-select>
|
||||
</template>
|
||||
<template v-slot:item.note="{ item }">
|
||||
|
||||
@@ -49,10 +49,10 @@
|
||||
<td>
|
||||
{{ ingredient.food.name }}
|
||||
<!-- TODO weird mixture of using ingredients but not in the correct relation to the recipe not good, properly sort out and add easy unitconversion/food edit features -->
|
||||
<!-- <v-btn variant="outlined" block>-->
|
||||
<!-- {{ ingredient.food.name }}-->
|
||||
<!-- <model-edit-dialog model="Food" :item="ingredient.food!" @save="args => ingredient.food = args"></model-edit-dialog>-->
|
||||
<!-- </v-btn>-->
|
||||
<!-- <v-btn variant="outlined" block>-->
|
||||
<!-- {{ ingredient.food.name }}-->
|
||||
<!-- <model-edit-dialog model="Food" :item="ingredient.food!" @save="args => ingredient.food = args"></model-edit-dialog>-->
|
||||
<!-- </v-btn>-->
|
||||
<!-- <v-chip v-if="ingredient.unit && ingredient.food.propertiesFoodUnit && ingredient.unit.id == ingredient.food.propertiesFoodUnit.id" color="success"-->
|
||||
<!-- size="small">{{ ingredient.unit.name }}-->
|
||||
<!-- </v-chip>-->
|
||||
@@ -73,7 +73,8 @@
|
||||
@click="fdcSelectedIngredient = ingredient; fdcDialog = true"></v-btn>
|
||||
<v-btn @click="updateFoodFdcData(ingredient)" icon="fa-solid fa-arrows-rotate" size="small" density="compact" variant="plain"
|
||||
v-if="ingredient.food.fdcId"></v-btn>
|
||||
<v-btn @click="openFdcPage(ingredient.food.fdcId)" :href="`https://fdc.nal.usda.gov/food-details/${ingredient.food.fdcId}/nutrients`" target="_blank"
|
||||
<v-btn @click="openFdcPage(ingredient.food.fdcId)" :href="`https://fdc.nal.usda.gov/food-details/${ingredient.food.fdcId}/nutrients`"
|
||||
target="_blank"
|
||||
icon="fa-solid fa-arrow-up-right-from-square"
|
||||
size="small" variant="plain" v-if="ingredient.food.fdcId"></v-btn>
|
||||
</template>
|
||||
@@ -81,7 +82,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<v-number-input v-model="ingredient.food.propertiesFoodAmount" density="compact" hide-details @change="updateFood(ingredient)"
|
||||
:loading="ingredient.loading" style="min-width: 100px" control-variant="hidden" :precision="2">
|
||||
:loading="ingredient.loading" style="min-width: 100px" control-variant="hidden" :precision="2">
|
||||
|
||||
</v-number-input>
|
||||
</td>
|
||||
@@ -90,8 +91,10 @@
|
||||
:loading="ingredient.loading"></model-select>
|
||||
</td>
|
||||
<td v-for="p in ingredient.food.properties" v-bind:key="`${ingredient.food.id}_${p.propertyType.id}`">
|
||||
<v-number-input v-model="p.propertyAmount" density="compact" hide-details v-if="p.propertyAmount != null" @change="updateFood(ingredient)" :precision="2"
|
||||
:loading="ingredient.loading" @click:clear="deleteFoodProperty(p, ingredient)" style="min-width: 120px" control-variant="hidden" clearable>
|
||||
<v-number-input v-model="p.propertyAmount" density="compact" hide-details v-if="p.propertyAmount != null" @change="updateFood(ingredient)"
|
||||
:precision="2"
|
||||
:loading="ingredient.loading" @click:clear="deleteFoodProperty(p, ingredient)" style="min-width: 120px" control-variant="hidden"
|
||||
clearable>
|
||||
|
||||
</v-number-input>
|
||||
|
||||
@@ -104,11 +107,10 @@
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<!-- TODO remove once append to body for model select is working properly -->
|
||||
<v-spacer style="margin-top: 120px;"></v-spacer>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col>
|
||||
|
||||
<v-card prepend-icon="fa-solid fa-calculator" :title="$t('Calculator')">
|
||||
<v-card-text>
|
||||
<v-row dense>
|
||||
|
||||
@@ -837,7 +837,7 @@ function deleteStep(step: SourceImportStep) {
|
||||
|
||||
function handleMergeAllSteps(): void {
|
||||
if (importResponse.value.recipe && importResponse.value.recipe.steps) {
|
||||
mergeAllSteps(importResponse.value.recipe.steps)
|
||||
importResponse.value.recipe.steps = mergeAllSteps(importResponse.value.recipe.steps)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,7 +931,10 @@ function setAllKeywordsImportStatus(status: boolean) {
|
||||
* add a new (empty) step at the end of the step list
|
||||
*/
|
||||
function addStep() {
|
||||
importResponse.value.recipe?.steps.push({} as SourceImportStep)
|
||||
importResponse.value.recipe?.steps.push({
|
||||
ingredients: [],
|
||||
instruction: ''
|
||||
} as SourceImportStep)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -788,7 +788,7 @@ const filters = ref({
|
||||
enabled: false,
|
||||
default: undefined,
|
||||
is: VNumberInput,
|
||||
modelValue: useRouteQuery('timescookedGte', undefined, {transform: Number}),
|
||||
modelValue: useRouteQuery('timescooked', undefined, {transform: Number}),
|
||||
},
|
||||
timescookedGte: {
|
||||
id: 'timescookedGte',
|
||||
|
||||
@@ -34,13 +34,13 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<template v-if="totalRecipes > 0">
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="recent" v-if="totalRecipes > 5"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="new"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="recent" v-if="totalRecipes > 0"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="new" v-if="totalRecipes > 10"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="keyword" v-if="totalRecipes > 10"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="random" v-if="totalRecipes > 10"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="created_by" v-if="totalRecipes > 5"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="2" mode="rating" v-if="totalRecipes > 5"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="keyword" v-if="totalRecipes > 5"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="random" v-if="totalRecipes >= 5"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="created_by" v-if="totalRecipes > 10"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="2" mode="rating" v-if="totalRecipes > 10"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="keyword" v-if="totalRecipes > 25"></horizontal-recipe-scroller>
|
||||
<horizontal-recipe-scroller :skeletons="4" mode="random" v-if="totalRecipes > 25"></horizontal-recipe-scroller>
|
||||
|
||||
<v-row>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {acceptHMRUpdate, defineStore} from 'pinia'
|
||||
import {useStorage} from "@vueuse/core";
|
||||
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
|
||||
import {ApiApi, ServerSettings, Space, UserPreference, UserSpace} from "@/openapi";
|
||||
import {ApiApi, ServerSettings, Space, Unit, UserPreference, UserSpace} from "@/openapi";
|
||||
import {ShoppingGroupingOptions} from "@/types/Shopping";
|
||||
import {computed, ComputedRef, ref} from "vue";
|
||||
import {DeviceSettings} from "@/types/settings";
|
||||
@@ -50,6 +50,11 @@ export const useUserPreferenceStore = defineStore('user_preference_store', () =>
|
||||
*/
|
||||
const initCompleted = ref(false)
|
||||
|
||||
/**
|
||||
* load the default unit to the store for easy use in editors and more
|
||||
*/
|
||||
const defaultUnitObj = ref<Unit | null>(null)
|
||||
|
||||
const theme = useTheme()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -77,6 +82,7 @@ export const useUserPreferenceStore = defineStore('user_preference_store', () =>
|
||||
userSettings.value = r[0]
|
||||
isAuthenticated.value = true
|
||||
updateTheme()
|
||||
loadDefaultUnit()
|
||||
} else {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, r)
|
||||
}
|
||||
@@ -87,6 +93,28 @@ export const useUserPreferenceStore = defineStore('user_preference_store', () =>
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* load the default unit from the backend
|
||||
* TODO migrate to nested serializer but requires actually creating the unit as currently its possible the default unit does not exist yet
|
||||
*/
|
||||
function loadDefaultUnit() {
|
||||
let api = new ApiApi()
|
||||
|
||||
if (userSettings.value.defaultUnit) {
|
||||
api.apiUnitList({query: userSettings.value.defaultUnit}).then(r => {
|
||||
r.results.forEach(u => {
|
||||
if (u.name == userSettings.value.defaultUnit) {
|
||||
defaultUnitObj.value = u
|
||||
}
|
||||
})
|
||||
}).catch(err => {
|
||||
if (err.response.status != 403) {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* persist changes to user settings to DB
|
||||
*/
|
||||
@@ -254,6 +282,7 @@ export const useUserPreferenceStore = defineStore('user_preference_store', () =>
|
||||
activeUserSpace,
|
||||
isAuthenticated,
|
||||
initCompleted,
|
||||
defaultUnitObj,
|
||||
loadUserSettings,
|
||||
loadServerSettings,
|
||||
updateUserSettings,
|
||||
|
||||
@@ -23,7 +23,7 @@ export default createVuetify({
|
||||
},
|
||||
// always localize the date display of DateInputs
|
||||
VDateInput: {
|
||||
displayFormat : (date: Date) => DateTime.fromJSDate(date).toLocaleString()
|
||||
displayFormat: (date: Date) => DateTime.fromJSDate(date).toLocaleString()
|
||||
},
|
||||
// always use color for switches to properly see if enabled or not
|
||||
VSwitch: {
|
||||
@@ -34,6 +34,10 @@ export default createVuetify({
|
||||
decimalSeparator: 0.1.toLocaleString().replace(/\d/g, '')
|
||||
}
|
||||
},
|
||||
// locale: {
|
||||
// locale: 'de',
|
||||
// fallback: 'en',
|
||||
// },
|
||||
theme: {
|
||||
defaultTheme: 'light',
|
||||
themes: {
|
||||
|
||||
Reference in New Issue
Block a user