Compare commits

...

26 Commits
2.2.6 ... 2.3.1

Author SHA1 Message Date
vabene1111
856f417d1b Merge branch 'develop' 2025-10-08 07:57:59 +02:00
vabene1111
85821bcc94 nginx config update 2025-10-08 07:42:09 +02:00
vabene1111
2345af8fd6 updated django 2025-10-05 13:11:34 +02:00
vabene1111
51107c64ee fixed unit merge with duplicate conversion 2025-10-05 13:10:38 +02:00
vabene1111
81983c5ae2 fixed default unit for first ingredient 2025-10-05 13:06:57 +02:00
vabene1111
f7713a43a7 fxied recipe properties editor and added recipe AI properties 2025-10-05 12:55:44 +02:00
vabene1111
ffd951a7f4 Merge branch 'develop' of https://github.com/TandoorRecipes/recipes into develop 2025-10-05 09:39:58 +02:00
vabene1111
319ac8e191 fixed recipe properties editor 2025-10-05 09:39:54 +02:00
vabene1111
aea247b4a3 Merge pull request #4091 from c0mputerguru/ical-test-fix
Requests using date should be in local timezone, not UTC as tandoor is timezone aware
2025-09-30 21:45:11 +02:00
vabene1111
e2843bb02f if at least one reciep 2025-09-30 21:41:20 +02:00
vabene1111
e3aa3e1137 always show at least one random recipe slider 2025-09-30 21:41:04 +02:00
vabene1111
da1187b03a fixed date editor missing from cook log editor 2025-09-30 21:39:35 +02:00
vabene1111
f9ed79978c improved mealie 1 importer 2025-09-30 21:36:37 +02:00
vabene1111
920a3ed4a3 fixed times cooked filter 2025-09-30 21:07:01 +02:00
vabene1111
2077eae142 fixed step sorter for import page 2025-09-30 20:55:12 +02:00
vabene1111
b1ef35e415 added default ordering for most models 2025-09-30 20:47:44 +02:00
vabene1111
0a687d840c fixed ingredients missing in sub recipe steps 2025-09-30 20:25:09 +02:00
vabene1111
6a3034b966 fixed merging in ingredient editor 2025-09-30 20:17:43 +02:00
Anand Patel
3d7afbfe4f Requests using date should be in local timezone, not UTC as tandoor is timezone aware. 2025-09-30 16:44:05 +00:00
vabene1111
02e43730bd fixed unit conversion division by 0 2025-09-29 22:14:19 +02:00
vabene1111
6adf077ee5 removed outside guincorn binding 2025-09-29 21:37:41 +02:00
vabene1111
d73ffa46ff added auto demo login link to docs index page 2025-09-29 21:21:13 +02:00
vabene1111
8572f338ad fixed ingredient insert focus error 2025-09-25 21:03:35 +02:00
vabene1111
920ec8e74b fixed missing pg extensions 2025-09-25 20:56:53 +02:00
vabene1111
2328bf2342 fixed mealie edgecases 2025-09-25 20:48:20 +02:00
vabene1111
85620a1431 Merge branch 'master' into develop 2025-09-25 12:33:43 +02:00
61 changed files with 653 additions and 114 deletions

16
boot.sh
View File

@@ -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 --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi

View File

@@ -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:

View File

@@ -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

View File

@@ -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']]

View File

@@ -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',

View 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(),
]

View File

@@ -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',)},
),
]

View File

@@ -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',)

View File

@@ -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
],
])

View File

@@ -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

View File

@@ -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):

View File

@@ -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>

View File

@@ -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.

View File

@@ -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
```

View File

@@ -1,6 +1,6 @@
server {
listen 80;
listen [::]:80 ipv6only=on;
listen ${TANDOOR_PORT};
listen [::]:${TANDOOR_PORT} ipv6only=on;
server_name localhost;
client_max_body_size 128M;
@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -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)" 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,10 +41,10 @@
<script setup lang="ts">
import {ApiApi, Food, Property} from "@/openapi";
import {ApiApi, Food, Property, Recipe} from "@/openapi";
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
import ModelSelect from "@/components/inputs/ModelSelect.vue";
import {PropType, ref} from "vue";
import {computed, onMounted, ref} from "vue";
import AiActionButton from "@/components/buttons/AiActionButton.vue";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
@@ -52,7 +52,11 @@ 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 +65,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 +78,14 @@ function deleteProperty(property: Property) {
function addAllProperties() {
const api = new ApiApi()
if (food.value.properties) {
food.value.properties = []
if (editingObj.value.properties) {
editingObj.value.properties = []
}
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 +94,25 @@ 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
}).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
})
}
}

View File

@@ -67,7 +67,7 @@
</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-text-field :id="`id_input_amount_${step.id}_${index}`" :label="$t('Amount')" type="number" v-model="ingredient.amount" density="compact"
<v-text-field :id="`id_input_amount_${props.stepIndex}_${index}`" :label="$t('Amount')" type="number" v-model="ingredient.amount" density="compact"
hide-details :disabled="ingredient.noAmount">
<template #prepend>
@@ -261,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
*/
@@ -334,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()
@@ -349,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()
}
})
}

View File

@@ -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},

View File

@@ -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)">
@@ -106,7 +106,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 +229,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

View File

@@ -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>

View File

@@ -115,6 +115,7 @@
"Fats": "",
"File": "",
"Files": "",
"Finish": "",
"Food": "",
"FoodInherit": "",
"FoodNotOnHand": "",

View File

@@ -112,6 +112,7 @@
"Fats": "Мазнини",
"File": "Файл",
"Files": "Файлове",
"Finish": "",
"Food": "Храна",
"FoodInherit": "Хранителни наследствени полета",
"FoodNotOnHand": "Нямате {храна} под ръка.",

View File

@@ -154,6 +154,7 @@
"Fats": "Greixos",
"File": "Arxiu",
"Files": "Arxius",
"Finish": "",
"First_name": "Nom",
"Food": "Aliment",
"FoodInherit": "Camps Heretats",

View File

@@ -153,6 +153,7 @@
"Fats": "Tuky",
"File": "Soubor",
"Files": "Soubory",
"Finish": "",
"First_name": "Jméno",
"Food": "Potravina",
"FoodInherit": "Propisovatelná pole potraviny",

View File

@@ -154,6 +154,7 @@
"Fats": "Fedtstoffer",
"File": "Fil",
"Files": "Filer",
"Finish": "",
"First_name": "Fornavn",
"Food": "Mad",
"FoodInherit": "Nedarvelige mad felter",

View File

@@ -212,6 +212,7 @@
"Fats": "Fette",
"File": "Datei",
"Files": "Dateien",
"Finish": "Fertigstellen",
"FinishedAt": "Fertig um",
"First": "Erstes",
"First_name": "Vorname",

View File

@@ -154,6 +154,7 @@
"Fats": "Λιπαρά",
"File": "Αρχείο",
"Files": "Αρχεία",
"Finish": "",
"First_name": "Όνομα",
"Food": "Φαγητό",
"FoodInherit": "Πεδία φαγητών που κληρονομούνται",

View File

@@ -210,6 +210,7 @@
"Fats": "Fats",
"File": "File",
"Files": "Files",
"Finish": "Finish",
"FinishedAt": "Finished at",
"First": "First",
"First_name": "First Name",

View File

@@ -207,6 +207,7 @@
"Fats": "Grasas",
"File": "Archivo",
"Files": "Archivos",
"Finish": "",
"FinishedAt": "Finaliza a las",
"First": "Primero",
"First_name": "Nombre",

View File

@@ -151,6 +151,7 @@
"Fats": "Rasvat",
"File": "Tiedosto",
"Files": "Tiedostot",
"Finish": "",
"First_name": "Etunimi",
"Food": "Ruoka",
"FoodInherit": "Ruoan perinnölliset kentät",

View File

@@ -210,6 +210,7 @@
"Fats": "Matières grasses",
"File": "Fichier",
"Files": "Fichiers",
"Finish": "",
"FinishedAt": "Terminé à",
"First": "Premier",
"First_name": "Prénom",

View File

@@ -154,6 +154,7 @@
"Fats": "שומנים",
"File": "קובץ",
"Files": "קבצים",
"Finish": "",
"First_name": "שם פרטי",
"Food": "אוכל",
"FoodInherit": "ערכי מזון",

View File

@@ -154,6 +154,7 @@
"Fats": "Masti",
"File": "Datoteka",
"Files": "Datoteke",
"Finish": "",
"First_name": "Ime",
"Food": "Namirnica",
"FoodInherit": "Nasljedna polja namirnice",

View File

@@ -137,6 +137,7 @@
"Fats": "Zsírok",
"File": "Fájl",
"Files": "Fájlok",
"Finish": "",
"First_name": "Keresztnév",
"Food": "Alapanyag",
"FoodInherit": "",

View File

@@ -69,6 +69,7 @@
"Fats": "",
"File": "",
"Files": "",
"Finish": "",
"Food": "Սննդամթերք",
"FromBalance": "",
"Fulltext": "",

View File

@@ -126,6 +126,7 @@
"Fats": "Lemak",
"File": "Berkas",
"Files": "File",
"Finish": "",
"First_name": "",
"Food": "",
"FoodInherit": "",

View File

@@ -153,6 +153,7 @@
"Fats": "",
"File": "",
"Files": "",
"Finish": "",
"First_name": "",
"Food": "",
"FoodInherit": "",

View File

@@ -211,6 +211,7 @@
"Fats": "Grassi",
"File": "File",
"Files": "File",
"Finish": "",
"FinishedAt": "Finito alle",
"First": "Primo",
"First_name": "Nome",

View File

@@ -139,6 +139,7 @@
"Fats": "",
"File": "",
"Files": "",
"Finish": "",
"First_name": "",
"Food": "",
"FoodInherit": "",

View File

@@ -154,6 +154,7 @@
"Fats": "",
"File": "",
"Files": "",
"Finish": "",
"First_name": "",
"Food": "",
"FoodInherit": "",

View File

@@ -146,6 +146,7 @@
"Fats": "Fett",
"File": "Fil",
"Files": "Filer",
"Finish": "",
"First_name": "Fornavn",
"Food": "Matretter",
"FoodInherit": "Arvbare felt for matvarer",

View File

@@ -211,6 +211,7 @@
"Fats": "Vetten",
"File": "Bestand",
"Files": "Bestanden",
"Finish": "",
"FinishedAt": "Afgerond op",
"First": "Eerste",
"First_name": "Voornaam",

View File

@@ -180,6 +180,7 @@
"Fats": "Tłuszcze",
"File": "Plik",
"Files": "Pliki",
"Finish": "",
"First_name": "Imię",
"Food": "Żywność",
"FoodInherit": "Pola dziedziczone w żywności",

View File

@@ -103,6 +103,7 @@
"Fats": "Gorduras",
"File": "Ficheiro",
"Files": "Ficheiros",
"Finish": "",
"Food": "Comida",
"FoodInherit": "Campos herdados por comida",
"FoodNotOnHand": "Não têm {food} disponível.",

View File

@@ -209,6 +209,7 @@
"Fats": "Gorduras",
"File": "Arquivo",
"Files": "Arquivos",
"Finish": "",
"FinishedAt": "Finalizado em",
"First": "Primeiro",
"First_name": "Primeiro Nome",

View File

@@ -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",

View File

@@ -210,6 +210,7 @@
"Fats": "Жиры",
"File": "Файл",
"Files": "Файлы",
"Finish": "",
"FinishedAt": "Завершено в",
"First": "Первый",
"First_name": "Имя",

View File

@@ -210,6 +210,7 @@
"Fats": "Maščobe",
"File": "Datoteka",
"Files": "Datoteke",
"Finish": "",
"FinishedAt": "Končano ob",
"First": "Prvi",
"First_name": "Ime",

View File

@@ -191,6 +191,7 @@
"Fats": "Fett",
"File": "Fil",
"Files": "Filer",
"Finish": "",
"First_name": "Förnamn",
"Food": "Livsmedel",
"FoodInherit": "Ärftliga livsmedels fält",

View File

@@ -154,6 +154,7 @@
"Fats": "Yağlar",
"File": "Dosya",
"Files": "Dosyalar",
"Finish": "",
"First_name": "İsim",
"Food": "Yiyecek",
"FoodInherit": "Yiyeceğin Devralınabileceği Alanlar",

View File

@@ -137,6 +137,7 @@
"Fats": "Жири",
"File": "Файл",
"Files": "Файли",
"Finish": "",
"Food": "Їжа",
"FoodInherit": "Пола Успадкованої Їжі",
"FoodNotOnHand": "У вас немає {food} на руках.",

View File

@@ -154,6 +154,7 @@
"Fats": "脂肪",
"File": "文件",
"Files": "文件",
"Finish": "",
"First_name": "名",
"Food": "食物",
"FoodInherit": "食物可继承的字段",

View File

@@ -209,6 +209,7 @@
"Fats": "脂肪",
"File": "檔案",
"Files": "檔案",
"Finish": "",
"FinishedAt": "完成於",
"First": "第一個",
"First_name": "名字",

View File

@@ -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/
*/

View File

@@ -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 }">

View File

@@ -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)
}

View File

@@ -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',

View File

@@ -35,9 +35,9 @@
</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="new" v-if="totalRecipes > 1"></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="random" v-if="totalRecipes > 1"></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>

View File

@@ -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,