Merge branch 'feature/export-progress' into develop

This commit is contained in:
vabene1111 2022-01-28 15:41:49 +01:00 committed by GitHub
commit a0892470e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
141 changed files with 14841 additions and 8379 deletions

View File

@ -45,7 +45,8 @@ SHOPPING_MIN_AUTOSYNC_INTERVAL=5
# Default for user setting sticky navbar
# STICKY_NAV_PREF_DEFAULT=1
# If base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
# If base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
# Be sure to not have a trailing slash: e.g. '/recipes' instead of '/recipes/'
# SCRIPT_NAME=/recipes
# If staticfiles are stored at a different location uncomment and change accordingly, MUST END IN /
@ -150,5 +151,6 @@ REVERSE_PROXY_AUTH=0
# Disabled by default, uncomment to enable
# ENABLE_PDF_EXPORT=1
# Duration to keep the cached export file
EXPORT_FILE_CACHE_DURATION=600
# Duration to keep the cached export file (in seconds)
EXPORT_FILE_CACHE_DURATION=300

View File

@ -1,15 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
### Version
Please provide your current version (can be found on the system page since v0.8.4)
Version:
### Bug description
A clear and concise description of what the bug is.

View File

@ -0,0 +1,81 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
## Version
<!-- Please provide your current version (can be found on the system page since v0.8.4). -->
**Tandoor-Version:**
## Setup configuration
<!--Please tick all boxes which apply to your configuration. Feel free to provide additional information below.
To tick boxes here, simply put an X inside the brackets below -->
### Setup
- [ ] Docker / Docker-Compose
- [ ] Unraid
- [ ] Synology
- [ ] Kubernetes
- [ ] Manual setup
- [ ] Others (please state below)
### Reverse Proxy
- [ ] No reverse proxy
- [ ] jwilder's nginx proxy
- [ ] Nginx proxy manager (NPM)
- [ ] SWAG
- [ ] Caddy
- [ ] Traefik
- [ ] Others (please state below)
<!-- Please provide additional information if possible -->
**Additional information:**
## Bug description
A clear and concise description of what the bug is.
## Logs
<!-- *(Remove this section entirely if no logs are available or necessary for your issue)*
To get the most information about your issue, set DEBUG=1 (e.g. in your `.env` file if using docker-compose) and try to reproduce the issue afterwards.
Please put your logs into the expandable section below and use code quotation for all logs! Usage: Put three backticks in front and after the log, like this:
` ``` <Many lines of log messages ``` `
Feel free to remove parts if you don't fill them out.
-->
<details>
<summary>Web-Container-Logs</summary>
<!-- *Put your logs inside here (leave the code quotations in takt):* -->
```
Replace me with logs
```
</details>
<details>
<summary>DB-Container-Logs</summary>
<!-- *Put your logs inside here (leave the code quotations in takt):* -->
```
Replace me with logs
```
</details>
<details>
<summary>Nginx-Container-Logs <!-- if you use one --></summary>
<!-- *Put your logs inside here (leave the code quotations in takt):* -->
```
Replace me with logs
```
</details>

64
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@ -0,0 +1,64 @@
name: Bug Report
description: "Create a report to help us improve"
#title: ""
#labels: ["Bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
id: version
attributes:
label: Tandoor Version
description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)"
validations:
required: true
- type: dropdown
id: setup
attributes:
label: Setup
description: "How is your Tandoor instance set up?"
options:
- Docker / Docker-Compose
- Unraid
- Synology
- Kubernetes
- Manual Setup
- Others (please state below)
validations:
required: true
- type: dropdown
id: reverse-proxy
attributes:
label: "Reverse Proxy"
description: "What reverse proxy do you use with Tandoor?"
options:
- No reverse proxy
- jwilder's nginx proxy
- Nginx Proxy Manager (NPM)
- SWAG
- Caddy
- Traefik
- Apache2
- Others (please state below)
validations:
required: true
- type: input
id: other
attributes:
label: Other
description: "In case you chose 'Others' above, please provide more info here."
- type: textarea
id: bug-descr
attributes:
label: Bug description
description: "Please accurately describe the bug you encountered."
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant logs
description: Please copy and paste any relevant logs. This will be automatically formatted into code, so no need for backticks.
render: shell

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: FAQs
url: https://docs.tandoor.dev/faq/
about: Please take a look at the FAQs before creating a bug ticket.

40
.github/ISSUE_TEMPLATE/doc_issue.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: Documentation Issue
description: "Create a report to help us improve"
#title: ""
labels: ["documentation"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this documentation issue report!
- type: input
id: docs-link
attributes:
label: Documentation link
description: "Please provide a link to the corresponding documentation site on docs.tandoor.dev"
- type: dropdown
id: section
attributes:
label: Affected section
description: "What part of the documentation is the issue about?"
options:
- Installation
- Features
- System
- FAQ
- Does not exist yet
- Other (please state below)
validations:
required: true
- type: input
id: other
attributes:
label: Other
description: "In case you chose 'Other' above, please provide more info here."
- type: textarea
id: descr
attributes:
label: Issue description
description: "Please accurately describe the documentation issue you are seeing."
validations:
required: true

View File

@ -0,0 +1,39 @@
name: Feature Request
description: "Suggest an idea for this project"
#title: ""
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this feature request!
- type: textarea
id: problem
attributes:
label: "Is your feature request related to a problem? Please describe."
description: "A clear and concise description of what the problem is. Ex. I'm always frustrated when..."
- type: textarea
id: solution
attributes:
label: "Describe the solution you'd like"
description: "A clear and concise description of what you want to happen."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "Describe alternatives you've considered"
description: "A clear and concise description of any alternative solutions or features you've considered."
- type: textarea
id: additional
attributes:
label: "Additional context"
description: "Add any other context or screenshots about the feature request here."
- type: checkboxes
attributes:
label: "Contribute"
description: "Are you willing and able to help develop this feature?"
options:
- label: "Yes"
- label: "Partly"
- label: "No"

82
.github/ISSUE_TEMPLATE/help_request.yml vendored Normal file
View File

@ -0,0 +1,82 @@
name: Help request
description: "If there is anything wrong with your setup"
#title: ""
labels: ["setup issue"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this help request!
- type: textarea
id: issue
attributes:
label: Issue
description: "Please describe your problem here."
validations:
required: true
- type: input
id: version
attributes:
label: Tandoor Version
description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)"
validations:
required: true
- type: input
id: os
attributes:
label: OS Version
description: "E.g. Ubuntu 20.02"
validations:
required: true
- type: dropdown
id: setup
attributes:
label: Setup
description: "How is your Tandoor instance set up?"
options:
- Docker / Docker-Compose
- Unraid
- Synology
- Kubernetes
- Manual Setup
- Others (please state below)
validations:
required: true
- type: dropdown
id: reverse-proxy
attributes:
label: "Reverse Proxy"
description: "What reverse proxy do you use with Tandoor?"
options:
- No reverse proxy
- jwilder's nginx proxy
- Nginx Proxy Manager (NPM)
- SWAG
- Caddy
- Traefik
- Others (please state below)
validations:
required: true
- type: input
id: other
attributes:
label: Other
description: "In case you chose 'Others' above or have more info, please provide additional details here."
- type: textarea
id: env
attributes:
label: Environment file
description: "Please include your `.env` config file (**make sure to remove/replace all secrets**)"
render: shell
- type: textarea
id: docker-compose
attributes:
label: Docker-Compose file
description: "When running with docker compose please provide your `docker-compose.yml`"
render: shell
- type: textarea
id: logs
attributes:
label: Relevant logs
description: "If you feel like there is anything interesting please post the output of `docker-compose logs` at container startup and when the issue happens."
render: shell

View File

@ -0,0 +1,36 @@
name: Website Import
description: "Anything related to website imports"
#title: ""
#labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this website import form!
- type: input
id: version
attributes:
label: Tandoor Version
description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)"
validations:
required: true
- type: input
id: url
attributes:
label: Import URL
description: "Exact URL you are trying to import from."
validations:
required: true
- type: textarea
id: bug-descr
attributes:
label: "When did the issue happen?"
description: "When pressing the search button with the url / when importing after the page has loaded / ..."
validations:
required: true
- type: textarea
id: logs
attributes:
label: Response / message shown
description: Please copy and paste any relevant logs or responses / messages which are shown in Tandoor. This will be automatically formatted into code, so no need for backticks.
render: shell

View File

@ -1,4 +1,4 @@
name: Continous Integration
name: Continuous Integration
on: [push]

View File

@ -9,7 +9,7 @@
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
<p align="center">
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=master" ></a>
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continuous%20Integration/badge.svg?branch=master" ></a>
<a href="https://github.com/vabene1111/recipes/stargazers" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/stars/vabene1111/recipes" ></a>
<a href="https://github.com/vabene1111/recipes/network/members" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/forks/vabene1111/recipes" ></a>
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>

View File

@ -7,4 +7,4 @@ Since this software is still considered beta/WIP support is always only given fo
## Reporting a Vulnerability
Please open a normal public issue if you have any security related concerns. If you feel like the issue should not be discussed in
public just open a generic issue and we will discuss further communitcation there (since GitHub does not allow everyone to create a security advisory :/).
public just open a generic issue and we will discuss further communication there (since GitHub does not allow everyone to create a security advisory :/).

View File

@ -15,7 +15,7 @@ from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
TelegramBot, Unit, UserFile, UserPreference, ViewLog)
TelegramBot, Unit, UserFile, UserPreference, ViewLog, Automation)
class CustomUserAdmin(UserAdmin):
@ -29,11 +29,52 @@ admin.site.register(User, CustomUserAdmin)
admin.site.unregister(Group)
@admin.action(description='Delete all data from a space')
def delete_space_action(modeladmin, request, queryset):
for space in queryset:
CookLog.objects.filter(space=space).delete()
ViewLog.objects.filter(space=space).delete()
ImportLog.objects.filter(space=space).delete()
BookmarkletImport.objects.filter(space=space).delete()
Comment.objects.filter(recipe__space=space).delete()
Keyword.objects.filter(space=space).delete()
Ingredient.objects.filter(space=space).delete()
Food.objects.filter(space=space).delete()
Unit.objects.filter(space=space).delete()
Step.objects.filter(space=space).delete()
NutritionInformation.objects.filter(space=space).delete()
RecipeBookEntry.objects.filter(book__space=space).delete()
RecipeBook.objects.filter(space=space).delete()
MealType.objects.filter(space=space).delete()
MealPlan.objects.filter(space=space).delete()
ShareLink.objects.filter(space=space).delete()
Recipe.objects.filter(space=space).delete()
RecipeImport.objects.filter(space=space).delete()
SyncLog.objects.filter(sync__space=space).delete()
Sync.objects.filter(space=space).delete()
Storage.objects.filter(space=space).delete()
ShoppingListEntry.objects.filter(shoppinglist__space=space).delete()
ShoppingListRecipe.objects.filter(shoppinglist__space=space).delete()
ShoppingList.objects.filter(space=space).delete()
SupermarketCategoryRelation.objects.filter(supermarket__space=space).delete()
SupermarketCategory.objects.filter(space=space).delete()
Supermarket.objects.filter(space=space).delete()
InviteLink.objects.filter(space=space).delete()
UserFile.objects.filter(space=space).delete()
Automation.objects.filter(space=space).delete()
class SpaceAdmin(admin.ModelAdmin):
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
search_fields = ('name', 'created_by__username')
list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
date_hierarchy = 'created_at'
actions = [delete_space_action]
admin.site.register(Space, SpaceAdmin)
@ -128,7 +169,7 @@ def sort_tree(modeladmin, request, queryset):
class KeywordAdmin(TreeAdmin):
form = movenodeform_factory(Keyword)
ordering = ('space', 'path',)
search_fields = ('name', )
search_fields = ('name',)
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
@ -136,8 +177,8 @@ admin.site.register(Keyword, KeywordAdmin)
class StepAdmin(admin.ModelAdmin):
list_display = ('name', 'type', 'order')
search_fields = ('name', 'type')
list_display = ('name', 'order',)
search_fields = ('name',)
admin.site.register(Step, StepAdmin)
@ -171,13 +212,15 @@ class RecipeAdmin(admin.ModelAdmin):
admin.site.register(Recipe, RecipeAdmin)
admin.site.register(Unit)
# admin.site.register(FoodInheritField)
class FoodAdmin(TreeAdmin):
form = movenodeform_factory(Keyword)
ordering = ('space', 'path',)
search_fields = ('name', )
search_fields = ('name',)
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]

View File

@ -358,8 +358,8 @@ class InviteLinkForm(forms.ModelForm):
def clean(self):
space = self.cleaned_data['space']
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + InviteLink.objects.filter(
space=space).filter(valid_until__gte=datetime.today()).count()) >= space.max_users:
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() +
InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users:
raise ValidationError(_('Maximum number of users for this space reached.'))
def clean_email(self):
@ -442,7 +442,7 @@ class SearchPreferenceForm(forms.ModelForm):
help_texts = {
'search': _(
'Select type method of search. Click <a href="/docs/search/">here</a> for full desciption of choices.'),
'Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'),
'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'),
'unaccent': _(
'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
@ -461,7 +461,7 @@ class SearchPreferenceForm(forms.ModelForm):
'lookup': _('Fuzzy Lookups'),
'unaccent': _('Ignore Accent'),
'icontains': _("Partial Match"),
'istartswith': _("Starts Wtih"),
'istartswith': _("Starts With"),
'trigram': _("Fuzzy Search"),
'fulltext': _("Full Text")
}
@ -535,10 +535,11 @@ class SpacePreferenceForm(forms.ModelForm):
class Meta:
model = Space
fields = ('food_inherit', 'reset_food_inherit',)
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count')
help_texts = {
'food_inherit': _('Fields on food that should be inherited by default.'), }
'food_inherit': _('Fields on food that should be inherited by default.'),
'show_facet_count': _('Show recipe counts on search filters'), }
widgets = {
'food_inherit': MultiSelectWidget

View File

@ -7,7 +7,7 @@ class Round(Func):
def str2bool(v):
if type(v) == bool:
if type(v) == bool or v is None:
return v
else:
return v.lower() in ("yes", "true", "1")

View File

@ -34,7 +34,7 @@ def has_group_permission(user, groups):
"""
Tests if a given user is member of a certain group (or any higher group)
Superusers always bypass permission checks.
Unauthenticated users cant be member of any group thus always return false.
Unauthenticated users can't be member of any group thus always return false.
:param user: django auth user object
:param groups: list or tuple of groups the user should be checked for
:return: True if user is in allowed groups, false otherwise
@ -206,7 +206,7 @@ class CustomIsShared(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
# temporary hack to make old shopping list work with new shopping list
if obj.__class__.__name__ == 'ShoppingList':
if obj.__class__.__name__ in ['ShoppingList', 'ShoppingListEntry']:
return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
return is_object_shared(request.user, obj)

File diff suppressed because it is too large Load Diff

View File

@ -38,6 +38,7 @@ def shopping_helper(qs, request):
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
# TODO refactor as class
def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
"""
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
@ -78,7 +79,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
elif ingredients:
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
else:
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space)
if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
@ -100,9 +101,9 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
if ingredients.filter(food__recipe=x).exists():
for ing in ingredients.filter(food__recipe=x):
if exclude_onhand:
x_ing = Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
else:
x_ing = Ingredient.objects.filter(step__recipe=x, space=space)
x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True)
for i in [x for x in x_ing]:
ShoppingListEntry.objects.create(
list_recipe=list_recipe,
@ -125,7 +126,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
# if servings have changed, update the ShoppingListRecipe and existing Entrys
# if servings have changed, update the ShoppingListRecipe and existing Entries
if servings <= 0:
servings = 1
@ -137,7 +138,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
sle.save()
# add any missing Entrys
# add any missing Entries
for i in [x for x in add_ingredients if x.food]:
ShoppingListEntry.objects.create(

View File

@ -2,7 +2,6 @@ import json
from io import BytesIO, StringIO
from re import match
from zipfile import ZipFile
from django.utils.text import get_valid_filename
from rest_framework.renderers import JSONRenderer
@ -60,13 +59,12 @@ class Default(Integration):
recipe_zip_obj.close()
export_zip_obj.writestr(get_valid_filename(r.name) + '.zip', recipe_zip_stream.getvalue())
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(r)
el.save()
export_zip_obj.close()
return [[ self.get_export_file_name(), export_zip_stream.getvalue() ]]

View File

@ -46,7 +46,7 @@ class Integration:
try:
last_kw = Keyword.objects.filter(name__regex=r'^(Import [0-9]+)', space=request.space).latest('created_at')
name = f'Import {int(last_kw.name.replace("Import ", "")) + 1}'
except ObjectDoesNotExist:
except (ObjectDoesNotExist, ValueError):
name = 'Import 1'
parent, created = Keyword.objects.get_or_create(name='Import', space=request.space)
@ -57,7 +57,7 @@ class Integration:
icon=icon,
space=request.space
)
except IntegrityError: # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
except (IntegrityError, ValueError): # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
self.keyword = parent.add_child(
name=f'{name} {str(uuid.uuid4())[0:8]}',
description=description,
@ -98,6 +98,10 @@ class Integration:
el.running = False
el.save()
response = HttpResponse(export_file, content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
return response
def import_file_name_filter(self, zip_info_object):
"""
@ -269,7 +273,6 @@ class Integration:
"""
raise NotImplementedError('Method not implemented in integration')
def get_files_from_recipes(self, recipes, el, cookie):
"""
Takes a list of recipe object and converts it to a array containing each file.

View File

@ -27,10 +27,10 @@ class RecetteTek(Integration):
def get_recipe_from_file(self, file):
# Create initial recipe with just a title and a decription
# Create initial recipe with just a title and a description
recipe = Recipe.objects.create(name=file['title'], created_by=self.request.user, internal=True, space=self.request.space, )
# set the description as an empty string for later use for the source URL, incase there is no description text.
# set the description as an empty string for later use for the source URL, in case there is no description text.
recipe.description = ''
try:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
if settings.DATABASES['default']['ENGINE'] not in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
self.stdout.write(self.style.WARNING(_('Only Postgress databases use full text search, no index to rebuild')))
self.stdout.write(self.style.WARNING(_('Only Postgresql databases use full text search, no index to rebuild')))
try:
language = DICTIONARY.get(translation.get_language(), 'simple')

View File

@ -140,5 +140,10 @@ class Migration(migrations.Migration):
old_name='ignore_shopping',
new_name='food_onhand',
),
migrations.AddField(
model_name='space',
name='show_facet_count',
field=models.BooleanField(default=False),
),
migrations.RunPython(copy_values_to_sle),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.11 on 2022-01-17 22:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0163_auto_20220105_0758'),
]
operations = [
# migrations.AddField(
# model_name='space',
# name='show_facet_count',
# field=models.BooleanField(default=False),
# ),
# removed due to quick fix in 0159 migration to maintain correct order
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.11 on 2022-01-18 19:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0164_space_show_facet_count'),
]
operations = [
migrations.RemoveField(
model_name='step',
name='type',
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 3.2.11 on 2022-01-20 14:39
from django.db import migrations, models
from django_scopes import scopes_disabled
def add_default_trigram(apps, schema_editor):
with scopes_disabled():
UserPreference = apps.get_model('cookbook', 'UserPreference')
UserPreference.objects.all().update(shopping_add_onhand=False)
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0165_remove_step_type'),
]
operations = [
migrations.AlterField(
model_name='userpreference',
name='shopping_add_onhand',
field=models.BooleanField(default=False),
),
migrations.RunPython(add_default_trigram),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.11 on 2022-01-20 22:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0166_alter_userpreference_shopping_add_onhand'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='left_handed',
field=models.BooleanField(default=False),
),
]

View File

@ -77,7 +77,10 @@ class TreeManager(MP_NodeManager):
for field in many_to_many:
field_model = getattr(obj, field).model
for related_obj in many_to_many[field]:
getattr(obj, field).add(field_model.objects.get(**dict(related_obj)))
if isinstance(related_obj, User):
getattr(obj, field).add(field_model.objects.get(id=related_obj.id))
else:
getattr(obj, field).add(field_model.objects.get(**dict(related_obj)))
return obj, True
except IntegrityError as e:
if 'Key (path)' in e.args[0]:
@ -148,6 +151,7 @@ class TreeModel(MP_Node):
return super().add_root(**kwargs)
# i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet
@staticmethod
def include_descendants(queryset=None, filter=None):
"""
:param queryset: Model Queryset to add descendants
@ -244,6 +248,7 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
allow_sharing = models.BooleanField(default=True)
demo = models.BooleanField(default=False)
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
show_facet_count = models.BooleanField(default=False)
def __str__(self):
return self.name
@ -332,8 +337,9 @@ class UserPreference(models.Model, PermissionModelMixin):
mealplan_autoadd_shopping = models.BooleanField(default=False)
mealplan_autoexclude_onhand = models.BooleanField(default=True)
mealplan_autoinclude_related = models.BooleanField(default=True)
shopping_add_onhand = models.BooleanField(default=True)
shopping_add_onhand = models.BooleanField(default=False)
filter_to_supermarket = models.BooleanField(default=False)
left_handed = models.BooleanField(default=False)
default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
shopping_recent_days = models.PositiveIntegerField(default=7)
csv_delim = models.CharField(max_length=2, default=",")
@ -510,7 +516,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
@staticmethod
def reset_inheritance(space=None):
# resets inheritted fields to the space defaults and updates all inheritted fields to root object values
# resets inherited fields to the space defaults and updates all inherited fields to root object values
inherit = space.food_inherit.all()
# remove all inherited fields from food
@ -571,17 +577,7 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixin):
TEXT = 'TEXT'
TIME = 'TIME'
FILE = 'FILE'
RECIPE = 'RECIPE'
name = models.CharField(max_length=128, default='', blank=True)
type = models.CharField(
choices=((TEXT, _('Text')), (TIME, _('Time')), (FILE, _('File')), (RECIPE, _('Recipe')),),
default=TEXT,
max_length=16
)
instruction = models.TextField(blank=True)
ingredients = models.ManyToManyField(Ingredient, blank=True)
time = models.IntegerField(default=0, blank=True)
@ -613,9 +609,7 @@ class NutritionInformation(models.Model, PermissionModelMixin):
)
proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32)
calories = models.DecimalField(default=0, decimal_places=16, max_digits=32)
source = models.CharField(
max_length=512, default="", null=True, blank=True
)
source = models.CharField( max_length=512, default="", null=True, blank=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
@ -624,6 +618,15 @@ class NutritionInformation(models.Model, PermissionModelMixin):
return f'Nutrition {self.pk}'
# class NutritionType(models.Model, PermissionModelMixin):
# name = models.CharField(max_length=128)
# icon = models.CharField(max_length=16, blank=True, null=True)
# description = models.CharField(max_length=512, blank=True, null=True)
#
# space = models.ForeignKey(Space, on_delete=models.CASCADE)
# objects = ScopedManager(space='space')
class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModelMixin):
name = models.CharField(max_length=128)
description = models.CharField(max_length=512, blank=True, null=True)

View File

@ -29,7 +29,11 @@ class Nextcloud(Provider):
client = Nextcloud.get_client(monitor.storage)
files = client.list(monitor.path)
files.pop(0) # remove first element because its the folder itself
try:
files.pop(0) # remove first element because its the folder itself
except IndexError:
pass # folder is empty, no recipes will be imported
import_count = 0
for file in files:

View File

@ -12,6 +12,7 @@ from rest_framework import serializers
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.fields import empty
from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.shopping_helper import list_from_recipe
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
FoodInheritField, ImportLog, ExportLog, Ingredient, Keyword, MealPlan, MealType,
@ -21,13 +22,18 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Fo
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
UserPreference, ViewLog)
from cookbook.templatetags.custom_tags import markdown
from recipes.settings import MEDIA_URL
class ExtendedRecipeMixin(serializers.ModelSerializer):
# adds image and recipe count to serializer when query param extended=1
image = serializers.SerializerMethodField('get_image')
numrecipe = serializers.SerializerMethodField('count_recipes')
# ORM path to this object from Recipe
recipe_filter = None
# list of ORM paths to any image
images = None
image = serializers.SerializerMethodField('get_image')
numrecipe = serializers.ReadOnlyField(source='recipe_count')
def get_fields(self, *args, **kwargs):
fields = super().get_fields(*args, **kwargs)
@ -37,8 +43,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
api_serializer = None
# extended values are computationally expensive and not needed in normal circumstances
try:
if bool(int(
self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer:
if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
return fields
except (AttributeError, KeyError) as e:
pass
@ -50,24 +55,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
return fields
def get_image(self, obj):
# TODO add caching
recipes = Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).exclude(
image__isnull=True).exclude(image__exact='')
try:
if recipes.count() == 0 and obj.has_children():
obj__in = self.recipe_filter + '__in'
recipes = Recipe.objects.filter(**{obj__in: obj.get_descendants()}, space=obj.space).exclude(
image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
except AttributeError:
# probably not a tree
pass
if recipes.count() != 0:
return random.choice(recipes).image.url
else:
return None
def count_recipes(self, obj):
return Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).count()
if obj.recipe_image:
return MEDIA_URL + obj.recipe_image
class CustomDecimalField(serializers.Field):
@ -98,7 +87,11 @@ class CustomOnHandField(serializers.Field):
return instance
def to_representation(self, obj):
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
shared_users = None
if request := self.context.get('request', None):
shared_users = getattr(request, '_shared_users', None)
if shared_users is None:
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
return obj.onhand_users.filter(id__in=shared_users).exists()
def to_internal_value(self, data):
@ -165,13 +158,19 @@ class FoodInheritFieldSerializer(WritableNestedModelSerializer):
class Meta:
model = FoodInheritField
fields = ('id', 'name', 'field', )
fields = ('id', 'name', 'field',)
read_only_fields = ['id']
class UserPreferenceSerializer(serializers.ModelSerializer):
class UserPreferenceSerializer(WritableNestedModelSerializer):
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True)
plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
shopping_share = UserNameSerializer(many=True, allow_null=True, required=False)
food_children_exist = serializers.SerializerMethodField('get_food_children_exist')
def get_food_children_exist(self, obj):
space = getattr(self.context.get('request', None), 'space', None)
return Food.objects.filter(depth__gt=0, space=space).exists()
def create(self, validated_data):
if not validated_data.get('user', None):
@ -183,10 +182,10 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
class Meta:
model = UserPreference
fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share',
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style', 'show_recent', 'plan_share',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix',
'filter_to_supermarket', 'shopping_add_onhand'
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'food_children_exist'
)
@ -379,14 +378,16 @@ class RecipeSimpleSerializer(serializers.ModelSerializer):
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
recipe = RecipeSimpleSerializer(allow_null=True, required=False)
shopping = serializers.SerializerMethodField('get_shopping_status')
# shopping = serializers.SerializerMethodField('get_shopping_status')
shopping = serializers.ReadOnlyField(source='shopping_status')
inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False)
food_onhand = CustomOnHandField(required=False, allow_null=True)
recipe_filter = 'steps__ingredients__food'
images = ['recipe__image']
def get_shopping_status(self, obj):
return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0
# def get_shopping_status(self, obj):
# return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
@ -396,15 +397,20 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
validated_data['supermarket_category'], sc_created = SupermarketCategory.objects.get_or_create(
name=validated_data.pop('supermarket_category')['name'],
space=self.context['request'].space)
onhand = validated_data.get('food_onhand', None)
onhand = validated_data.pop('food_onhand', None)
# assuming if on hand for user also onhand for shopping_share users
if not onhand is None:
shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all())
if onhand:
validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users
if self.instance:
onhand_users = self.instance.onhand_users.all()
else:
validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users))
onhand_users = []
if onhand:
validated_data['onhand_users'] = list(onhand_users) + shared_users
else:
validated_data['onhand_users'] = list(set(onhand_users) - set(shared_users))
obj, created = Food.objects.get_or_create(**validated_data)
return obj
@ -425,7 +431,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
model = Food
fields = (
'id', 'name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category',
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name'
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping'
)
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
@ -472,12 +478,12 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
# check if root type is recipe to prevent infinite recursion
# can be improved later to allow multi level embedding
if obj.step_recipe and type(self.parent.root) == RecipeSerializer:
return StepRecipeSerializer(obj.step_recipe).data
return StepRecipeSerializer(obj.step_recipe, context={'request': self.context['request']}).data
class Meta:
model = Step
fields = (
'id', 'name', 'type', 'instruction', 'ingredients', 'ingredients_markdown',
'id', 'name', 'instruction', 'ingredients', 'ingredients_markdown',
'ingredients_vue', 'time', 'order', 'show_as_header', 'file', 'step_recipe', 'step_recipe_data', 'numrecipe'
)
@ -493,6 +499,10 @@ class StepRecipeSerializer(WritableNestedModelSerializer):
class NutritionInformationSerializer(serializers.ModelSerializer):
carbohydrates = CustomDecimalField()
fats = CustomDecimalField()
proteins = CustomDecimalField()
calories = CustomDecimalField()
def create(self, validated_data):
validated_data['space'] = self.context['request'].space
@ -516,7 +526,7 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
def get_recipe_last_cooked(self, obj):
try:
last = obj.cooklog_set.filter(created_by=self.context['request'].user).last()
last = obj.cooklog_set.filter(created_by=self.context['request'].user).order_by('created_at').last()
if last:
return last.created_at
except TypeError:
@ -525,7 +535,7 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
# TODO make days of new recipe a setting
def is_recipe_new(self, obj):
if obj.created_at > (timezone.now() - timedelta(days=7)):
if getattr(obj, 'new_recipe', None) or obj.created_at > (timezone.now() - timedelta(days=7)):
return True
else:
return False
@ -536,6 +546,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
rating = serializers.SerializerMethodField('get_recipe_rating')
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
new = serializers.SerializerMethodField('is_recipe_new')
recent = serializers.ReadOnlyField()
def create(self, validated_data):
pass
@ -548,7 +559,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
fields = (
'id', 'name', 'description', 'image', 'keywords', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at',
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new'
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent'
)
read_only_fields = ['image', 'created_by', 'created_at']
@ -681,7 +692,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
) + f' ({value:.2g})'
def update(self, instance, validated_data):
if 'servings' in validated_data:
# TODO remove once old shopping list
if 'servings' in validated_data and self.context.get('view', None).__class__.__name__ != 'ShoppingListViewSet':
list_from_recipe(
list_recipe=instance,
servings=validated_data['servings'],
@ -717,9 +729,9 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
def run_validation(self, data):
if self.root.instance.__class__.__name__ == 'ShoppingListEntry':
if (
data.get('checked', False)
and self.root.instance
and not self.root.instance.checked
data.get('checked', False)
and self.root.instance
and not self.root.instance.checked
):
# if checked flips from false to true set completed datetime
data['completed_at'] = timezone.now()
@ -755,7 +767,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan',
'created_by', 'created_at', 'completed_at', 'delay_until'
)
read_only_fields = ('id', 'created_by', 'created_at',)
read_only_fields = ('id', 'created_by', 'created_at',)
# TODO deprecate
@ -870,7 +882,7 @@ class AutomationSerializer(serializers.ModelSerializer):
# CORS, REST and Scopes aren't currently working
# Scopes are evaluating before REST has authenticated the user assiging a None space
# Scopes are evaluating before REST has authenticated the user assigning a None space
# I've made the change below to fix the bookmarklet, other serializers likely need a similar/better fix
class BookmarkletImportSerializer(serializers.ModelSerializer):
def create(self, validated_data):
@ -941,7 +953,7 @@ class StepExportSerializer(WritableNestedModelSerializer):
class Meta:
model = Step
fields = ('name', 'type', 'instruction', 'ingredients', 'time', 'order', 'show_as_header')
fields = ('name', 'instruction', 'ingredients', 'time', 'order', 'show_as_header')
class RecipeExportSerializer(WritableNestedModelSerializer):

View File

@ -72,7 +72,7 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs):
return
inherit = inherit.values_list('field', flat=True)
# apply changes from parent to instance for each inheritted field
# apply changes from parent to instance for each inherited field
if instance.parent and inherit.count() > 0:
parent = instance.get_parent()
if 'ignore_shopping' in inherit:
@ -121,11 +121,3 @@ def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs
'servings': instance.servings
}
list_recipe = list_from_recipe(**kwargs)
# user = self.context['request'].user
# if user.userpreference.shopping_add_onhand:
# if checked := validated_data.get('checked', None):
# instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user)
# elif checked == False:
# instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user)

View File

@ -10461,4 +10461,9 @@ textarea, input:not([type="submit"]):not([class="multiselect__input"]):not([clas
.form-control-search {
font-size: 20px;
}
.ghost {
opacity: 0.5 !important;
background: #b98766 !important;
}

View File

@ -19,7 +19,7 @@
<div class="row">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<hr>
<hr>
<form class="login" method="POST" action="{% url 'account_login' %}">
{% csrf_token %}
{{ form | crispy }}
@ -29,12 +29,16 @@
{% endif %}
<button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
<a class="btn btn-secondary" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% if SIGNUP_ENABLED %}
<a class="btn btn-secondary" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% endif %}
{% if EMAIL_ENABLED %}
<a class="btn btn-warning float-right d-none d-xl-block d-lg-block"
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a>
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
{% endif %}
</form>
</div>
@ -44,7 +48,7 @@
{% if socialaccount_providers %}
<div class="row" style="margin-top: 2vh">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<h5>{% trans "Social Login" %}</h5>
<span>{% trans 'You can use any of the following providers to sign in.' %}</span>
@ -62,5 +66,8 @@
</div>
{% endif %}
<script>
$('#id_login').focus()
</script>
{% endblock %}

View File

@ -71,4 +71,8 @@
</div>
<script>
$('#id_username').focus()
</script>
{% endblock %}

View File

@ -54,7 +54,7 @@
<h2>{% trans 'Formatting' %}</h2>
<pre class="intro-code code-block"><code>
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}
{% trans 'or by leaving a blank line inbetween.' %}
{% trans 'or by leaving a blank line in between.' %}
**{% trans 'This text is bold' %}**
*{% trans 'This text is italic' %}*
@ -70,7 +70,7 @@
<div class="card">
<div class="card-body">
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}<br/>
{% trans 'or by leaving a blank line inbetween.' %}<br/><br/>
{% trans 'or by leaving a blank line in between.' %}<br/><br/>
<b>{% trans 'This text is bold' %}</b><br/>
<i>{% trans 'This text is italic' %}</i>
<blockquote>
@ -82,7 +82,7 @@
<br/>
<h2>{% trans 'Lists' %}</h2>
{% trans 'Lists can ordered or unorderd. It is <b>important to leave a blank line before the list!</b>' %}
{% trans 'Lists can ordered or unordered. It is <b>important to leave a blank line before the list!</b>' %}
<pre class="intro-code code-block"><code>
{% trans 'Ordered List' %}

View File

@ -27,7 +27,7 @@
{% endblocktrans %}</p>
<h4>{% trans 'Simple' %}</h4>
<p> {% blocktrans %}
Simple searches ignore punctuation and common words such as 'the', 'a', 'and'. And will treat seperate words as required.
Simple searches ignore punctuation and common words such as 'the', 'a', 'and'. And will treat separate words as required.
Searching for 'apple or flour' will return any recipe that includes both 'apple' and 'flour' anywhere in the fields that have been selected for a full text search.
{% endblocktrans %}</p>
<h4>{% trans 'Phrase' %}</h4>
@ -39,7 +39,7 @@
<p> {% blocktrans %}
Web searches simulate functionality found on many web search sites supporting special syntax.
Placing quotes around several words will convert those words into a phrase.
'or' is recongized as searching for the word (or phrase) immediately before 'or' OR the word (or phrase) directly after.
'or' is recognized as searching for the word (or phrase) immediately before 'or' OR the word (or phrase) directly after.
'-' is recognized as searching for recipes that do not include the word (or phrase) that comes immediately after.
For example searching for 'apple pie' or cherry -butter will return any recipe that includes the phrase 'apple pie' or the word 'cherry'
in any field included in the full text search but exclude any recipe that has the word 'butter' in any field included.
@ -59,7 +59,7 @@
{% blocktrans %}
Another approach to searching that also requires Postgresql is fuzzy search or trigram similarity. A trigram is a group of three consecutive characters.
For example searching for 'apple' will create x trigrams 'app', 'ppl', 'ple' and will create a score of how closely words match the generated trigrams.
One benefit of searching trigams is that a search for 'sandwich' will find mispelled words such as 'sandwhich' that would be missed by other methods.
One benefit of searching trigams is that a search for 'sandwich' will find misspelled words such as 'sandwhich' that would be missed by other methods.
{% endblocktrans %}
</div>

View File

@ -263,7 +263,7 @@
{% if request.user.userpreference.shopping_auto_sync > 0 %}
<div class="row" v-if="!onLine">
<div class="col col-md-12">
<div class="alert alert-warning" role="alert">{% trans 'You are offline, shopping list might not syncronize.' %}</div>
<div class="alert alert-warning" role="alert">{% trans 'You are offline, shopping list might not synchronize.' %}</div>
</div>
</div>
{% endif %}
@ -634,7 +634,7 @@
console.log('updating recipe', this.shopping_list.recipes[i])
recipe_promises.push(this.$http.post("{% url 'api:shoppinglistrecipe-list' %}", this.shopping_list.recipes[i], {}).then((response) => {
let old_id = this.shopping_list.recipes[i].id
console.log("list recipe create respose ", response.body)
console.log("list recipe create response ", response.body)
this.$set(this.shopping_list.recipes, i, response.body)
for (let e of this.shopping_list.entries.filter(item => item.list_recipe === old_id)) {
console.log("found recipe updating ID")
@ -834,7 +834,7 @@
this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe.id)).then((response) => {
for (let s of response.data.steps) {
for (let i of s.ingredients) {
if (!i.is_header && i.food !== null && i.food.food_onhand === false) {
if (!i.is_header && i.food !== null && !i.food.ignore_food) {
this.shopping_list.entries.push({
'list_recipe': slr.id,
'food': i.food,

View File

@ -498,6 +498,8 @@
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
placeholder="{% trans 'Select one' %}"
tag-placeholder="{% trans 'Select' %}"
label="text"
@ -536,6 +538,8 @@
:clear-on-select="true"
:allow-empty="false"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
label="text"
track-by="id"
:multiple="false"
@ -586,6 +590,8 @@
:clear-on-select="true"
:hide-selected="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
placeholder="{% trans 'Select one' %}"
tag-placeholder="{% trans 'Add Keyword' %}"
:taggable="true"
@ -654,7 +660,8 @@
</div>
<script src="{% url 'javascript-catalog' %}"></script>
<script src="{% url 'javascript-catalog' %}">
</script>
<script type="application/javascript">
let csrftoken = Cookies.get('csrftoken');
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
@ -693,7 +700,8 @@
import_duplicates: false,
recipe_files: [],
images: [],
mode: 'url'
mode: 'url',
options_limit:25
},
directives: {
tabindex: {
@ -703,9 +711,9 @@
}
},
mounted: function () {
this.searchKeywords('')
this.searchUnits('')
this.searchIngredients('')
// this.searchKeywords('')
// this.searchUnits('')
// this.searchIngredients('')
let uri = window.location.search.substring(1);
let params = new URLSearchParams(uri);
q = params.get("id")
@ -884,7 +892,20 @@
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
})
// let apiFactory = new ApiApiFactory()
// this.keywords_loading = true
// apiFactory
// .listKeywords(query, undefined, undefined, 1, this.options_limit)
// .then((response) => {
// this.keywords = response.data.results
// this.keywords_loading = false
// })
// .catch((err) => {
// console.log(err)
// StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
// })
},
searchUnits: function (query) {
this.units_loading = true
@ -922,6 +943,29 @@
console.log(err)
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
// let apiFactory = new ApiApiFactory()
// this.foods_loading = true
// apiFactory
// .listFoods(query, undefined, undefined, 1, this.options_limit)
// .then((response) => {
// this.foods = response.data.results
// if (this.recipe !== undefined) {
// for (let s of this.recipe.steps) {
// for (let i of s.ingredients) {
// if (i.food !== null && i.food.id === undefined) {
// this.foods.push(i.food)
// }
// }
// }
// }
// this.foods_loading = false
// })
// .catch((err) => {
// StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
// })
},
deleteNode: function (node, item, e) {
e.stopPropagation()

View File

@ -200,7 +200,7 @@ def test_shopping_recipe_userpreference(recipe, sle_count, use_mealplan, user2):
food = Food.objects.get(id=ingredients[2].food.id)
food.onhand_users.add(user)
food.save()
food = recipe.steps.filter(type=Step.RECIPE).first().step_recipe.steps.first().ingredients.first().food
food = recipe.steps.exclude(step_recipe=None).first().step_recipe.steps.first().ingredients.first().food
food = Food.objects.get(id=food.id)
food.onhand_users.add(user)
food.save()

View File

@ -269,10 +269,8 @@ class StepFactory(factory.django.DjangoModelFactory):
return
if kwargs.get('has_recipe', False):
self.step_recipe = RecipeFactory(space=self.space)
self.type = Step.RECIPE
elif extracted:
self.step_recipe = extracted
self.type = Step.RECIPE
@factory.post_generation
def ingredients(self, create, extracted, **kwargs):

View File

@ -12,8 +12,10 @@ from django.contrib.auth.models import User
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import FieldError, ValidationError
from django.core.files import File
from django.db.models import Case, ProtectedError, Q, Value, When
from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef, ProtectedError, Q,
Subquery, Value, When)
from django.db.models.fields.related import ForeignObjectRel
from django.db.models.functions import Coalesce
from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
@ -30,13 +32,14 @@ from rest_framework.response import Response
from rest_framework.viewsets import ViewSetMixin
from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, PathOverflow
from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.image_processing import handle_image
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, CustomIsOwner,
CustomIsShare, CustomIsShared, CustomIsUser,
group_required)
from cookbook.helper.recipe_html_import import get_recipe_from_source
from cookbook.helper.recipe_search import get_facet, old_search, search_recipes
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch, old_search
from cookbook.helper.recipe_url_import import get_from_scraper
from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
@ -100,7 +103,38 @@ class DefaultPagination(PageNumberPagination):
max_page_size = 200
class FuzzyFilterMixin(ViewSetMixin):
class ExtendedRecipeMixin():
'''
ExtendedRecipe annotates a queryset with recipe_image and recipe_count values
'''
@classmethod
def annotate_recipe(self, queryset=None, request=None, serializer=None, tree=False):
extended = str2bool(request.query_params.get('extended', None))
if extended:
recipe_filter = serializer.recipe_filter
images = serializer.images
space = request.space
# add a recipe count annotation to the query
# explanation on construction https://stackoverflow.com/a/43771738/15762829
recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(recipe_filter).annotate(count=Count('pk')).values('count')
queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0))
# add a recipe image annotation to the query
image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
if tree:
image_children_subquery = Recipe.objects.filter(**{f"{recipe_filter}__path__startswith": OuterRef('path')},
space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
else:
image_children_subquery = None
if images:
queryset = queryset.annotate(recipe_image=Coalesce(*images, image_subquery, image_children_subquery))
else:
queryset = queryset.annotate(recipe_image=Coalesce(image_subquery, image_children_subquery))
return queryset
class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
schema = FilterSchema()
def get_queryset(self):
@ -112,18 +146,18 @@ class FuzzyFilterMixin(ViewSetMixin):
if fuzzy:
self.queryset = (
self.queryset
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))),
default=Value(0))) # put exact matches at the top of the result set
.annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2)
.order_by('-exact', '-trigram')
.annotate(starts=Case(When(name__istartswith=query, then=(Value(.3, output_field=IntegerField()))), default=Value(0)))
.annotate(trigram=TrigramSimilarity('name', query))
.annotate(sort=F('starts')+F('trigram'))
.order_by('-sort')
)
else:
# TODO have this check unaccent search settings or other search preferences?
self.queryset = (
self.queryset
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))),
default=Value(0))) # put exact matches at the top of the result set
.filter(name__icontains=query).order_by('-exact', 'name')
.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))),
default=Value(0))) # put exact matches at the top of the result set
.filter(name__icontains=query).order_by('-starts', 'name')
)
updated_at = self.request.query_params.get('updated_at', None)
@ -141,12 +175,12 @@ class FuzzyFilterMixin(ViewSetMixin):
if random:
self.queryset = self.queryset.order_by("?")
self.queryset = self.queryset[:int(limit)]
return self.queryset
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class)
class MergeMixin(ViewSetMixin):
@decorators.action(detail=True, url_path='merge/(?P<target>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
@ decorators.action(detail=True, url_path='merge/(?P<target>[^/.]+)', methods=['PUT'], )
@ decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
def merge(self, request, pk, target):
self.description = f"Merge {self.basename} onto target {self.basename} with ID of [int]."
@ -211,7 +245,7 @@ class MergeMixin(ViewSetMixin):
return Response(content, status=status.HTTP_400_BAD_REQUEST)
class TreeMixin(MergeMixin, FuzzyFilterMixin):
class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin):
schema = TreeSchema()
model = None
@ -237,11 +271,13 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
except self.model.DoesNotExist:
self.queryset = self.model.objects.none()
else:
return super().get_queryset()
return self.queryset.filter(space=self.request.space).order_by('name')
return self.annotate_recipe(queryset=super().get_queryset(), request=self.request, serializer=self.serializer_class, tree=True)
self.queryset = self.queryset.filter(space=self.request.space).order_by('name')
@decorators.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, tree=True)
@ decorators.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], )
@ decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
def move(self, request, pk, parent):
self.description = f"Move {self.basename} to be a child of {self.basename} with ID of [int]. Use ID: 0 to move {self.basename} to the root."
if self.model.node_order_by:
@ -364,7 +400,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, StandardFilterMixin):
permission_classes = [CustomIsUser]
def get_queryset(self):
self.queryset = self.queryset.filter(space=self.request.space)
self.queryset = self.queryset.filter(space=self.request.space).order_by('name')
return super().get_queryset()
@ -413,7 +449,15 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
@decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,)
def get_queryset(self):
self.request._shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [self.request.user.id]
self.queryset = super().get_queryset()
shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'), checked=False).values('id')
# onhand_status = self.queryset.annotate(onhand_status=Exists(onhand_users_set__in=[shared_users]))
return self.queryset.annotate(shopping_status=Exists(shopping_status)).prefetch_related('onhand_users', 'inherit_fields').select_related('recipe', 'supermarket_category')
@ decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,)
# TODO DRF only allows one action in a decorator action without overriding get_operation_id_base() this should be PUT and DELETE probably
def shopping(self, request, pk):
if self.request.space.demo:
@ -561,7 +605,9 @@ class RecipePagination(PageNumberPagination):
max_page_size = 100
def paginate_queryset(self, queryset, request, view=None):
self.facets = get_facet(qs=queryset, request=request)
if queryset is None:
raise Exception
self.facets = RecipeFacet(request, queryset=queryset)
return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data):
@ -570,7 +616,7 @@ class RecipePagination(PageNumberPagination):
('next', self.get_next_link()),
('previous', self.get_previous_link()),
('results', data),
('facets', self.facets)
('facets', self.facets.get_facets(from_cache=True))
]))
@ -607,9 +653,11 @@ class RecipeViewSet(viewsets.ModelViewSet):
if not (share and self.detail):
self.queryset = self.queryset.filter(space=self.request.space)
self.queryset = search_recipes(self.request, self.queryset, self.request.GET)
return super().get_queryset()
# self.queryset = search_recipes(self.request, self.queryset, self.request.GET)
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x in list(self.request.GET)}
search = RecipeSearch(self.request, **params)
self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set')
return self.queryset
def list(self, request, *args, **kwargs):
if self.request.GET.get('debug', False):
@ -1051,7 +1099,7 @@ def recipe_from_source(request):
return JsonResponse(
{
'error': True,
'msg': _('No useable data could be found.')
'msg': _('No usable data could be found.')
},
status=400
)
@ -1100,10 +1148,20 @@ def ingredient_from_string(request):
@group_required('user')
def get_facets(request):
key = request.GET.get('hash', None)
food = request.GET.get('food', None)
keyword = request.GET.get('keyword', None)
facets = RecipeFacet(request, hash_key=key)
if food:
results = facets.add_food_children(food)
elif keyword:
results = facets.add_keyword_children(keyword)
else:
results = facets.get_facets()
return JsonResponse(
{
'facets': get_facet(request=request, use_cache=False, hash_key=key),
'facets': results,
},
status=200
)

View File

@ -45,21 +45,15 @@ def hook(request, token):
tb.save()
if tb.chat_id == str(data['message']['chat']['id']):
sl = ShoppingList.objects.filter(Q(created_by=tb.created_by)).filter(finished=False, space=tb.space).order_by('-created_at').first()
if not sl:
sl = ShoppingList.objects.create(created_by=tb.created_by, space=tb.space)
request.space = tb.space # TODO this is likely a bad idea. Verify and test
request.user = tb.created_by
ingredient_parser = IngredientParser(request, False)
amount, unit, ingredient, note = ingredient_parser.parse(data['message']['text'])
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
sl.entries.add(
ShoppingListEntry.objects.create(
food=f, unit=u, amount=amount
)
)
ShoppingListEntry.objects.create(food=f, unit=u, amount=amount, created_by=request.user, space=request.space)
return JsonResponse({'data': data['message']['text']})
except Exception:
pass

View File

@ -411,7 +411,7 @@ def user_settings(request):
if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user)
# these fields require postgress - just disable them if postgress isn't available
# these fields require postgresql - just disable them if postgresql isn't available
if not settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
'django.db.backends.postgresql']:
search_form.fields['search'].disabled = True
@ -567,6 +567,8 @@ def space(request):
form = SpacePreferenceForm(request.POST, prefix='space')
if form.is_valid():
request.space.food_inherit.set(form.cleaned_data['food_inherit'])
request.space.show_facet_count = form.cleaned_data['show_facet_count']
request.space.save()
if form.cleaned_data['reset_food_inherit']:
Food.reset_inheritance(space=request.space)

View File

@ -2,7 +2,23 @@ There are several questions and issues that come up from time to time. Here are
Please note that the existence of some questions is due the application not being perfect in some parts.
Many of those shortcomings are planned to be fixed in future release but simply could not be addressed yet due to time limits.
## CSRF Errors
## Is there a Tandoor app?
Tandoor can be installed as a progressive web app (PWA) on mobile and desktop devices. The PWA stores recently accessed recipes locally for offline use.
### Mobile browsers
#### Safari (iPhone/iPad)
Open Tandoor, click Safari's share button, select `Add to Home Screen`
### Desktop browsers
#### Google Chrome
Open Tandoor, open the menu behind the three vertical dots at the top right, select `Install Tandoor Recipes...`
#### Microsoft Edge
Open Tandoor, open the menu behind the three horizontal dots at the top right, select `Apps > Install Tandoor Recipes`
## Why am I getting CSRF Errors?
If you are getting CSRF Errors this is most likely due to a reverse proxy not passing the correct headers.
If you are using swag by linuxserver you might need `proxy_set_header X-Forwarded-Proto $scheme;` in your nginx config.
@ -10,15 +26,15 @@ If you are using a plain ngix you might need `proxy_set_header Host $http_host;`
Further discussions can be found in this [Issue #518](https://github.com/vabene1111/recipes/issues/518)
## Images not loading
If images are not loading this might be related to the same issue as the CSRF Errors.
A discussion about that can be found [Issue #452](https://github.com/vabene1111/recipes/issues/452)
## Why are images not loading?
If images are not loading this might be related to the same issue as the CSRF errors (see above).
A discussion about that can be found at [Issue #452](https://github.com/vabene1111/recipes/issues/452)
The other common issue is that the recommended nginx container is removed from the deployment stack.
If removed, the nginx webserver needs to be replaced by something else that servers the /mediafiles/ directory or
`GUNICORN_MEDIA` needs to be enabled to allow media serving by the application container itself.
## User Creation
## How can I create users?
To create a new user click on your name (top right corner) and select system. There click on invite links and create a new invite link.
It is not possible to create users through the admin because users must be assigned a default group and space.
@ -28,22 +44,27 @@ To change a users space you need to go to the admin and select User Infos.
If you use an external auth provider or proxy authentication make sure to specify a default group and space in the
environment configuration.
## Spaces
## What are spaces?
Spaces are a feature used to separate one installation of Tandoor into several parts.
In technical terms it is a multi tenant system.
You can compare a space to something like google drive or dropbox.
There is only one installation of the Dropbox system, but it handles multiple users without them noticing each other.
For Tandoor that means all people that work together on one recipe collection can be in one space.
If you want to host the collection of your friends family or your neighbor you can create a separate space for them (trough the admin interface).
If you want to host the collection of your friends family or your neighbor you can create a separate space for them (through the admin interface).
Sharing between spaces is currently not possible but is planned for future releases.
## Create Admin user / reset passwords
To create a superuser or reset a lost password if access to the container is lost you need to
## How can I reset passwords?
To reset a lost password if access to the container is lost you need to
1. execute into the container using `docker-compose exec web_recipes sh`
2. activate the virtual environment `source venv/bin/activate`
3. run `python manage.py createsuperuser` and follow the steps shown.
3. run `python manage.py changepassword <username>` and follow the steps shown.
To change a password enter `python manage.py changepassword <username>` in step 3.
## How can I add an admin user?
To create a superuser you need to
1. execute into the container using `docker-compose exec web_recipes sh`
2. activate the virtual environment `source venv/bin/activate`
3. run `python manage.py createsuperuser` and follow the steps shown.

View File

@ -25,14 +25,14 @@ SOCIAL_PROVIDERS=allauth.socialaccount.providers.github,allauth.socialaccount.pr
The exact formatting is important so make sure to follow the steps explained here!
Depending on your authentication provider you **might need** to configure it.
This needs to be done trough the settings system. To make the system flexible (allow multiple providers) and to
not require another file to be mounted into the container the configuration ins done trough a single
This needs to be done through the settings system. To make the system flexible (allow multiple providers) and to
not require another file to be mounted into the container the configuration ins done through a single
environment variable. The downside of this approach is that the configuration needs to be put into a single line
as environment files loaded by docker compose don't support multiple lines for a single variable.
Take the example configuration from the allauth docs, fill in your settings and then inline the whole object
(you can use a service like [www.freeformatter.com](https://www.freeformatter.com/json-formatter.html) for formatting).
Assign it to the `SOCIALACCOUNT_PROVIDERS` variable.
Assign it to the additional `SOCIALACCOUNT_PROVIDERS` variable.
```ini
SOCIALACCOUNT_PROVIDERS={"nextcloud":{"SERVER":"https://nextcloud.example.org"}}
@ -56,6 +56,25 @@ Use the superuser account to grant permissions to the newly created users.
I do not have a ton of experience with using various single signon providers and also cannot test all of them.
If you have any Feedback or issues let me know.
### Third-party authentication example
Keycloak is a popular IAM solution and integration is straight forward thanks to Django Allauth. This example can also be used as reference for other third-party authentication solutions, as documented by Allauth.
At Keycloak, create a new client and assign a `Client-ID`, this client comes with a `Secret-Key`. Both values are required later on. Make sure to define the correct Redirection-URL for the service, for example `https://tandoor.example.com/*`. Depending on your Keycloak setup, you need to assign roles and groups to grant access to the service.
To enable Keycloak as a sign in option, set those variables to define the social provider and specify its configuration:
```ini
SOCIAL_PROVIDERS=allauth.socialaccount.providers.keycloak
SOCIALACCOUNT_PROVIDERS='{ "keycloak": { "KEYCLOAK_URL": "https://auth.example.com/", "KEYCLOAK_REALM": "master" } }'
```
1. Restart the service, login as superuser and open the `Admin` page.
2. Make sure that the correct `Domain Name` is defined at `Sites`.
3. Select `Social Application` and chose `Keycloak` from the provider list.
4. Provide an arbitrary name for your authentication provider, and enter the `Client-ID` and `Secret Key` values obtained from Keycloak earlier.
5. Make sure to add your `Site` to the list of available sites and save the new `Social Application`.
You are now able to sign in using Keycloak.
### Linking accounts
To link an account to an already existing normal user go to the settings page of the user and link it.
Here you can also unlink your account if you no longer want to use a social login method.

View File

@ -35,7 +35,7 @@ The basic configuration is the same for all providers.
### Local
!!! info
There is currently no way to upload files trough the webinterface. This is a feature that might be added later.
There is currently no way to upload files through the webinterface. This is a feature that might be added later.
The local provider does not need any configuration.
For the monitor you will need to define a valid path on your host system.

View File

@ -9,7 +9,7 @@
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
<p align="center">
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=master" ></a>
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continuous%20Integration/badge.svg?branch=master" ></a>
<a href="https://github.com/vabene1111/recipes/stargazers" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/stars/vabene1111/recipes" ></a>
<a href="https://github.com/vabene1111/recipes/network/members" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/forks/vabene1111/recipes" ></a>
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>
@ -85,7 +85,7 @@ there are some greater overall goals for the future (in no particular order)
- Improve the UI! The Design is inconsistent and many pages work but don't look great. This needs to change.
- I strongly believe in Open Data and Systems. Thus adding importers and exporters for all relevant other recipe management systems is something i really want to do.
- Move all Javascript Libraries to a packet manger and clean up some of the mess I made in the early days
- Move all Javascript Libraries to a packet manager and clean up some of the mess I made in the early days
- Improve Test coverage and also the individual tests themselves
- Improve the documentation for all features and aspects of this project and add some application integrated help

View File

@ -60,6 +60,7 @@ The main, and also recommended, installation option is to install this applicati
### Plain
This configuration exposes the application through an nginx web server on port 80 of your machine.
Be aware that having some other web server or container running on your host machine on port 80 will block this from working.
```shell
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/plain/docker-compose.yml
@ -73,6 +74,8 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/d
Most deployments will likely use a reverse proxy.
If your reverse proxy is not listed here, please refer to [Others](https://docs.tandoor.dev/install/docker/#others).
#### Traefik
If you use traefik, this configuration is the one for you.
@ -135,27 +138,13 @@ In both cases, also make sure to mount `/media/` in your swag container to point
Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup.
#### Nginx Swag by LinuxServer
For step-by-step instructions to set this up from scratch, see [this example](swag.md).
[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io
### Others
It also contains templates for popular apps, including Tandoor Recipes, so you don't have to manually configure nginx and discard the template provided in Tandoor repo. Tandoor config is called `recipes.subdomain.conf.sample` which you can adapt for your instance
If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [PLAIN](https://docs.tandoor.dev/install/docker/#plain) setup above and change the outbound port to one of your liking.
If you're running Swag on the default port, you'll just need to change the container name to yours.
If your running Swag on a custom port, some headers must be changed. To do this,
- Create a copy of `proxy.conf`
- Replace `proxy_set_header X-Forwarded-Host $host;` and `proxy_set_header Host $host;` to
- `proxy_set_header X-Forwarded-Host $http_host;` and `proxy_set_header Host $http_host;`
- Update `recipes.subdomain.conf` to use the new file
- Restart the linuxserver/swag container and Recipes will work
More information [here](https://github.com/TandoorRecipes/recipes/issues/959#issuecomment-962648627).
In both cases, also make sure to mount `/media/` in your swag container to point to your Tandoor Recipes Media directory.
Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup.
An example port config (inside the respective docker-compose.yml) would be: `8123:80` instead of the `80:80` or if you want to be sure, that Tandoor is **just** accessible via your proxy and don't wanna bother with your firewall, then `127.0.0.1:8123:80` is a viable option too.
## Additional Information

View File

@ -2,7 +2,7 @@
# K8s Setup
This is a setup which should be sufficent for production use. Be sure to replace the default secrets!
This is a setup which should be sufficient for production use. Be sure to replace the default secrets!
# Files
@ -45,7 +45,7 @@ The creation of the persistent volume claims for media and static content. May y
## 40-sts-postgresql.yaml
The PostgreSQL stateful set, based on a bitnami image. It runs a init container as root to do the preparations. The postgres container itsef runs as a lower privileged user. The recipes app uses the database super user (postgres) as the recipies app is doing some db migrations on startup, which needs super user privileges.
The PostgreSQL stateful set, based on a bitnami image. It runs a init container as root to do the preparations. The postgres container itself runs as a lower privileged user. The recipes app uses the database super user (postgres) as the recipes app is doing some db migrations on startup, which needs super user privileges.
## 45-service-db.yaml
@ -53,7 +53,7 @@ Creating the database service.
## 50-deployment.yaml
The deployment first fires up a init container to do the database migrations and file modifications. This init container runs as root. The init conainer runs part of the [boot.sh](https://github.com/TandoorRecipes/recipes/blob/develop/boot.sh) script from the `vabene1111/recipes` image.
The deployment first fires up a init container to do the database migrations and file modifications. This init container runs as root. The init container runs part of the [boot.sh](https://github.com/TandoorRecipes/recipes/blob/develop/boot.sh) script from the `vabene1111/recipes` image.
The deployment then runs two containers, the recipes-nginx and the recipes container which runs the gunicorn app. The nginx container gets it's nginx.conf via config map to deliver static content `/static` and `/media`. The guincorn container gets it's secret key and the database password from the secret `recipes`. `gunicorn` runs as user `nobody`.
@ -94,5 +94,5 @@ These manifests are tested against Release 1.0.1. Newer versions may not work wi
To apply the manifest with kubectl, use the following command:
~~~
kubectl apply -f ./docs/k8s/
kubectl apply -f ./docs/install/k8s/
~~~

View File

@ -1,6 +1,6 @@
# Manual installation instructions
These intructions 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))
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
Be sure to use python 3.9 and pip related to python 3.9. Depending on your distribution calling `python` or `pip` will use python2 instead of python 3.9.
@ -115,7 +115,7 @@ ExecStart=/var/www/recipes/bin/gunicorn --error-logfile /tmp/gunicorn_err.log --
WantedBy=multi-user.target
```
*Note*: `-error-logfile /tmp/gunicorn_err.log --log-level debug --capture-output` are usefull for debugging and can be removed later
*Note*: `-error-logfile /tmp/gunicorn_err.log --log-level debug --capture-output` are useful for debugging and can be removed later
*Note2*: Fix the path in the `ExecStart` line to where you gunicorn and recipes are
@ -134,11 +134,11 @@ server {
#error_log /var/log/nginx/error.log;
# serve media files
location /staticfiles {
location /static {
alias /var/www/recipes/staticfiles;
}
location /mediafiles {
location /media {
alias /var/www/recipes/mediafiles;
}

View File

@ -57,4 +57,4 @@ apache:
I used two paths `<sub path>` and `<www path>` for simplicity. In my case I have `<sub path> = recipes` and `<www path> = serve/recipes`. One could also change the matching rules of traefik to have everything under one path.
I left out the TLS config in this example for simplicty.
I left out the TLS config in this example for simplicity.

118
docs/install/swag.md Normal file
View File

@ -0,0 +1,118 @@
!!! danger
Please refer to the [official documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup. This example shows just one setup that may or may not differ from yours in significant ways. This tutorial does not cover security measures, backups, and many other things that you might want to consider.
## Prerequisites
- You have a newly spun-up Ubuntu server with docker (pre-)installed.
- At least one `mydomain.com` and one `mysubdomain.mydomain.com` are pointing to the server's IP. (This tutorial does not cover subfolder installation.)
- You have an ssh terminal session open.
## Installation
### Download and edit Tandoor configuration
```
cd /opt
mkdir recipes
cd recipes
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env
base64 /dev/urandom | head -c50
```
Copy the response from that last command and paste the key into the `.env` file:
```
nano .env
```
You'll also need to enter a Postgres password into the `.env` file. Then, save the file and exit the editor.
### Install and configure Docker Compose
In keeping with [these instructions](https://docs.linuxserver.io/general/docker-compose):
```
cd /opt
curl -L --fail https://raw.githubusercontent.com/linuxserver/docker-docker-compose/master/run.sh -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
```
Next, create and edit the docker compose file.
```
nano docker-compose.yml
```
Paste the following and adjust your domains, subdomains and time zone.
```
---
version: "2.1"
services:
swag:
image: ghcr.io/linuxserver/swag
container_name: swag
cap_add:
- NET_ADMIN
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Berlin # <---- EDIT THIS <---- <----
- URL=mydomain.com # <---- EDIT THIS <---- <----
- SUBDOMAINS=mysubdomain,myothersubdomain # <---- EDIT THIS <---- <----
- EXTRA_DOMAINS=myotherdomain.com # <---- EDIT THIS <---- <----
- VALIDATION=http
volumes:
- ./swag:/config
- ./recipes/media:/media
ports:
- 443:443
- 80:80
restart: unless-stopped
db_recipes:
restart: always
container_name: db_recipes
image: postgres:11-alpine
volumes:
- ./recipes/db:/var/lib/postgresql/data
env_file:
- ./recipes/.env
recipes:
image: vabene1111/recipes
container_name: recipes
restart: unless-stopped
env_file:
- ./recipes/.env
environment:
- UID=1000
- GID=1000
- TZ=Europe/Berlin # <---- EDIT THIS <---- <----
volumes:
- ./recipes/static:/opt/recipes/staticfiles
- ./recipes/media:/opt/recipes/mediafiles
depends_on:
- db_recipes
```
Save and exit.
### Create containers and configure swag reverse proxy
```
docker-compose up -d
```
```
cd /opt/swag/nginx/proxy-confs
cp recipes.subdomain.conf.sample recipes.subdomain.conf
nano recipes.subdomain.conf
```
Change the line `server_name recipes.*;` to `server_name mysubdomain.*;`, save and exit.
### Finalize
```
cd /opt
docker restart swag recipes
```
Go to `https://mysubdomain.mydomain.com`. (If you get a "502 Bad Gateway" error, be patient. It might take a short while until it's functional.)

View File

@ -78,7 +78,7 @@ Easiest way is to do it via Reverse Proxy
- insert name
- Source:
- Protocol: HTTPS
- Hostname: URL if you acces from outside, otherwise ip in network
- Hostname: URL if you access from outside, otherwise ip in network
- Port: The port you want to access, has to be a different one that the one in the docker-compose file
- HSTS can be enabled
- Destination:
@ -88,14 +88,14 @@ Easiest way is to do it via Reverse Proxy
- Click on Custom Header and press Create -> Websocket
- Save
- Control Panel -> Security -> Firewall -> Edit Rules -> Create
- Ports: Select form a list of build-in applications -> Select -> You find your Reverse Proxy, enable it
- Ports: Select form a list of built-in applications -> Select -> You find your Reverse Proxy, enable it
- Source IP: Depends, All allows access from outside, i use specific to only connect in my network
- Action: Allow
- Save and make sure it's above the deny rules
[Deprecated, Note: ssl Path changed for DSM 7]
6.1 Additional SSL Setup
- create foler `ssl` inside `nginx` folder
- create folder `ssl` inside `nginx` folder
- download your ssl certificate from `security` tab in dsm `control panel`
- or create a task in `task manager` because Synology will update the certificate every few months
- set task to repeat every day

View File

@ -1,6 +1,6 @@
!!! danger
Please refer to [the offical documentation](https://doc.traefik.io/traefik/).
This example just shows something similar to my setup in case you dont understand the offical documentation.
Please refer to [the official documentation](https://doc.traefik.io/traefik/).
This example just shows something similar to my setup in case you dont understand the official documentation.
You need to create a network called `traefik` using `docker network create traefik`.
## docker-compose.yml

View File

@ -18,7 +18,7 @@ After that, you can go to the "Apps" tab in unRAID and search for Recipes and lo
![image](https://user-images.githubusercontent.com/724777/111038251-faa0cb00-83f5-11eb-9807-37815de8d795.png)
The default settings should by fine for most users, just be sure to enter a secret key that is randomly generated.
Then chooose apply.
Then choose apply.
![image](https://user-images.githubusercontent.com/724777/97094856-f3377b80-1626-11eb-98d5-e4b871a420f0.png)

View File

@ -2,7 +2,7 @@ There is currently no "good" way of backing up your data implemented in the appl
This mean that you will be responsible for backing up your data.
It is planned to add a "real" backup feature similar to applications like homeassistant where a snapshot can be
downloaded and restored trough the web interface.
downloaded and restored through the web interface.
!!! warning
When developing a new backup strategy, make sure to also test the restore process!

View File

@ -21,13 +21,13 @@ The following table roughly defines the capabilities of each role
!!! warning
Users without groups cannot do anything. Make sure to assign them a group!
You can either create new users trough the admin interface or by sending them invite links.
You can either create new users through the admin interface or by sending them invite links.
Invite links can be generated on the System page. If you specify a username during the creation of the link
the person using it won't be able to change that name.
## Managing Permissions
Management of permissions can currently only be achieved trough the django admin interface.
Management of permissions can currently only be achieved through the django admin interface.
!!! warning
Please do not rename the groups as this breaks the permission system.

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,50 +18,50 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,52 +18,52 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr "Englisch"
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr "Deutsch"
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
#, fuzzy
#| msgid "English"
msgid "Polish"
msgstr "Englisch"
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,50 +18,50 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,50 +18,50 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,50 +18,50 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -17,50 +17,50 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,50 +18,50 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -19,50 +19,50 @@ msgstr ""
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : "
"2);\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,50 +18,50 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,50 +18,50 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -17,50 +17,50 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,50 +18,50 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -17,50 +17,50 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: .\recipes\settings.py:353
#: .\recipes\settings.py:357
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:354
#: .\recipes\settings.py:358
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:355
#: .\recipes\settings.py:359
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:356
#: .\recipes\settings.py:360
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:357
#: .\recipes\settings.py:361
msgid "English"
msgstr ""
#: .\recipes\settings.py:358
#: .\recipes\settings.py:362
msgid "French"
msgstr ""
#: .\recipes\settings.py:359
#: .\recipes\settings.py:363
msgid "German"
msgstr ""
#: .\recipes\settings.py:360
#: .\recipes\settings.py:364
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:361
#: .\recipes\settings.py:365
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:362
#: .\recipes\settings.py:366
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:363
#: .\recipes\settings.py:367
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:364
#: .\recipes\settings.py:368
msgid "Spanish"
msgstr ""

View File

@ -1,3 +1,4 @@
import time
from os import getenv
from django.conf import settings
@ -13,19 +14,20 @@ class CustomRemoteUser(RemoteUserMiddleware):
Gist code by vstoykov, you can check his original gist at:
https://gist.github.com/vstoykov/1390853/5d2e8fac3ca2b2ada8c7de2fb70c021e50927375
Changes:
Ignoring static file requests and a certain useless admin request from triggering the logger.
Ignoring static file requests and a certain useless admin request from triggering the logger.
Updated statements to make it Python 3 friendly.
"""
def terminal_width():
"""
Function to compute the terminal width.
"""
width = 0
try:
import struct, fcntl, termios
import fcntl
import struct
import termios
s = struct.pack('HHHH', 0, 0, 0, 0)
x = fcntl.ioctl(1, termios.TIOCGWINSZ, s)
width = struct.unpack('HHHH', x)[1]

View File

@ -259,7 +259,7 @@ WSGI_APPLICATION = 'recipes.wsgi.application'
# Load settings from env files
if os.getenv('DATABASE_URL'):
match = re.match(
r'(?P<schema>\w+):\/\/(?P<user>[\w\d_-]+)(:(?P<password>[^@]+))?@(?P<host>[^:/]+)(:(?P<port>\d+))?(\/(?P<database>[\w\d_-]+))?',
r'(?P<schema>\w+):\/\/(?P<user>[\w\d_-]+)(:(?P<password>[^@]+))?@(?P<host>[^:/]+)(:(?P<port>\d+))?(\/(?P<database>[\w\d\/\._-]+))?',
os.getenv('DATABASE_URL')
)
settings = match.groupdict()
@ -372,10 +372,10 @@ LANGUAGES = [
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.0/howto/static-files/
SCRIPT_NAME = os.getenv('SCRIPT_NAME', '')
# path for django_js_reverse to generate the javascript file containing all urls. Only done because the default command (collectstatic_js_reverse) fails to update the manifest
JS_REVERSE_OUTPUT_PATH = os.path.join(BASE_DIR, "cookbook/static/django_js_reverse")
JS_REVERSE_SCRIPT_PREFIX = os.getenv('JS_REVERSE_SCRIPT_PREFIX', os.getenv('SCRIPT_NAME', ''))
JS_REVERSE_SCRIPT_PREFIX = os.getenv('JS_REVERSE_SCRIPT_PREFIX', SCRIPT_NAME)
STATIC_URL = os.getenv('STATIC_URL', '/static/')
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
@ -408,7 +408,7 @@ TEST_RUNNER = "cookbook.helper.CustomTestRunner.CustomTestRunner"
# settings for cross site origin (CORS)
# all origins allowed to support bookmarklet
# all of this may or may not work with nginx or other web servers
# TODO make this user configureable - enable or disable bookmarklets
# TODO make this user configurable - enable or disable bookmarklets
# TODO since token auth is enabled - this all should be https by default
CORS_ORIGIN_ALLOW_ALL = True

View File

@ -54,14 +54,20 @@
<div class="col-12 col-md-3 calender-options">
<h5>{{ $t("Planner_Settings") }}</h5>
<b-form>
<b-form-group id="UomInput" :label="$t('Period')" :description="$t('Plan_Period_To_Show')" label-for="UomInput">
<b-form-select id="UomInput" v-model="settings.displayPeriodUom" :options="options.displayPeriodUom"></b-form-select>
<b-form-group id="UomInput" :label="$t('Period')" :description="$t('Plan_Period_To_Show')"
label-for="UomInput">
<b-form-select id="UomInput" v-model="settings.displayPeriodUom"
:options="options.displayPeriodUom"></b-form-select>
</b-form-group>
<b-form-group id="PeriodInput" :label="$t('Periods')" :description="$t('Plan_Show_How_Many_Periods')" label-for="PeriodInput">
<b-form-select id="PeriodInput" v-model="settings.displayPeriodCount" :options="options.displayPeriodCount"></b-form-select>
<b-form-group id="PeriodInput" :label="$t('Periods')"
:description="$t('Plan_Show_How_Many_Periods')" label-for="PeriodInput">
<b-form-select id="PeriodInput" v-model="settings.displayPeriodCount"
:options="options.displayPeriodCount"></b-form-select>
</b-form-group>
<b-form-group id="DaysInput" :label="$t('Starting_Day')" :description="$t('Starting_Day')" label-for="DaysInput">
<b-form-select id="DaysInput" v-model="settings.startingDayOfWeek" :options="dayNames"></b-form-select>
<b-form-group id="DaysInput" :label="$t('Starting_Day')" :description="$t('Starting_Day')"
label-for="DaysInput">
<b-form-select id="DaysInput" v-model="settings.startingDayOfWeek"
:options="dayNames"></b-form-select>
</b-form-group>
<b-form-group id="WeekNumInput" :label="$t('Week_Numbers')">
<b-form-checkbox v-model="settings.displayWeekNumbers" name="week_num">
@ -73,19 +79,24 @@
<div class="col-12 col-md-9 col-lg-6">
<h5>{{ $t("Meal_Types") }}</h5>
<div>
<draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10" handle=".handle" @sort="sortMealTypes()">
<b-card no-body class="mt-1" v-for="(meal_type, index) in meal_types" v-hover :key="meal_type.id">
<b-card-header class="p-4">
<draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10" @sort="sortMealTypes()" ghost-class="ghost">
<b-card no-body class="mt-1 list-group-item p-2" style="cursor:move" v-for="(meal_type, index) in meal_types" v-hover
:key="meal_type.id">
<b-card-header class="p-2 border-0">
<div class="row">
<div class="col-2 handle">
<button type="button" class="btn btn-lg shadow-none"><i class="fas fa-arrows-alt-v"></i></button>
<div class="col-2">
<button type="button" class="btn btn-lg shadow-none"><i
class="fas fa-arrows-alt-v"></i></button>
</div>
<div class="col-10">
<h5>
{{ meal_type.icon }} {{ meal_type.name
}}<span class="float-right text-primary"
><i class="fa" v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }" @click="editOrSaveMealType(index)" aria-hidden="true"></i
></span>
<h5 class="mt-1 mb-1">
{{ meal_type.icon }} {{
meal_type.name
}}<span class="float-right text-primary" style="cursor:pointer"
><i class="fa"
v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }"
@click="editOrSaveMealType(index)" aria-hidden="true"></i
></span>
</h5>
</div>
</div>
@ -93,20 +104,29 @@
<b-card-body class="p-4" v-if="meal_type.editing">
<div class="form-group">
<label>{{ $t("Name") }}</label>
<input class="form-control" placeholder="Name" v-model="meal_type.name" />
<input class="form-control" placeholder="Name" v-model="meal_type.name"/>
</div>
<div class="form-group">
<emoji-input :field="'icon'" :label="$t('Icon')" :value="meal_type.icon"></emoji-input>
<emoji-input :field="'icon'" :label="$t('Icon')"
:value="meal_type.icon"></emoji-input>
</div>
<div class="form-group">
<label>{{ $t("Color") }}</label>
<input class="form-control" type="color" name="Name" :value="meal_type.color" @change="meal_type.color = $event.target.value" />
<input class="form-control" type="color" name="Name"
:value="meal_type.color"
@change="meal_type.color = $event.target.value"/>
</div>
<b-form-checkbox id="checkbox-1" v-model="meal_type.default" name="default_checkbox" class="mb-2">
<b-form-checkbox id="checkbox-1" v-model="meal_type.default"
name="default_checkbox" class="mb-2">
{{ $t("Default") }}
</b-form-checkbox>
<button class="btn btn-danger" @click="deleteMealType(index)">{{ $t("Delete") }}</button>
<button class="btn btn-primary float-right" @click="editOrSaveMealType(index)">{{ $t("Save") }}</button>
<button class="btn btn-danger" @click="deleteMealType(index)">{{
$t("Delete")
}}
</button>
<button class="btn btn-primary float-right" @click="editOrSaveMealType(index)">
{{ $t("Save") }}
</button>
</b-card-body>
</b-card>
</draggable>
@ -127,7 +147,15 @@
openEntryEdit(contextData.originalItem.entry)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{ $t("Edit") }}</a>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{
$t("Edit")
}}</a>
</ContextMenuItem>
<ContextMenuItem
v-if="contextData.originalItem.entry.recipe != null"
@click="$refs.menu.close();openRecipe(contextData.originalItem.entry.recipe)">
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pizza-slice"></i>
{{ $t("Recipe") }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
@ -135,7 +163,8 @@
moveEntryLeft(contextData)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i> {{ $t("Move") }}</a>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i>
{{ $t("Move") }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
@ -143,7 +172,8 @@
moveEntryRight(contextData)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i> {{ $t("Move") }}</a>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i>
{{ $t("Move") }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
@ -159,7 +189,8 @@
addToShopping(contextData)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i> {{ $t("Add_to_Shopping") }}</a>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i>
{{ $t("Add_to_Shopping") }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
@ -167,7 +198,8 @@
deleteEntry(contextData)
"
>
<a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
<a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i>
{{ $t("Delete") }}</a>
</ContextMenuItem>
</template>
</ContextMenu>
@ -198,10 +230,12 @@
<div class="col-12 mt-1" v-if="shopping_list.length > 0">
<b-button-group>
<b-button variant="success" @click="saveShoppingList"
><i class="fas fa-external-link-alt"></i>
><i class="fas fa-external-link-alt"></i>
{{ $t("Open") }}
</b-button>
<b-button variant="danger" @click="shopping_list = []"><i class="fa fa-trash"></i> {{ $t("Clear") }} </b-button>
<b-button variant="danger" @click="shopping_list = []"><i class="fa fa-trash"></i>
{{ $t("Clear") }}
</b-button>
</b-button-group>
</div>
</div>
@ -209,37 +243,46 @@
</div>
</template>
<transition name="slide-fade">
<div class="row fixed-bottom p-2 b-1 border-top text-center" style="background: rgba(255, 255, 255, 0.6)" v-if="current_tab === 0">
<div class="row fixed-bottom p-2 b-1 border-top text-center" style="background: rgba(255, 255, 255, 0.6)"
v-if="current_tab === 0">
<div class="col-md-3 col-6">
<button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i class="fas fa-calendar-plus"></i> {{ $t("Create") }}</button>
<button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i
class="fas fa-calendar-plus"></i> {{ $t("Create") }}
</button>
</div>
<div class="col-md-3 col-6">
<button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i class="fas fa-shopping-cart"></i> {{ $t("Shopping_list") }}</button>
<button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i
class="fas fa-shopping-cart"></i> {{ $t("Shopping_list") }}
</button>
</div>
<div class="col-md-3 col-6">
<a class="btn btn-block btn-primary shadow-none" :href="iCalUrl"
><i class="fas fa-download"></i>
><i class="fas fa-download"></i>
{{ $t("Export_To_ICal") }}
</a>
</div>
<div class="col-md-3 col-6">
<button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top :title="$t('Coming_Soon')">
<button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top
:title="$t('Coming_Soon')">
{{ $t("Auto_Planner") }}
</button>
</div>
<div class="col-12 d-flex justify-content-center mt-2 d-block d-md-none">
<b-button-toolbar key-nav aria-label="Toolbar with button groups">
<b-button-group class="mx-1">
<b-button v-html="'<<'" @click="setShowDate($refs.header.headerProps.previousPeriod)"></b-button>
<b-button v-html="'<<'"
@click="setShowDate($refs.header.headerProps.previousPeriod)"></b-button>
<b-button v-html="'<'" @click="setStartingDay(-1)"></b-button>
</b-button-group>
<b-button-group class="mx-1">
<b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i class="fas fa-home"></i> </b-button>
<b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i
class="fas fa-home"></i></b-button>
<b-form-datepicker button-only button-variant="secondary"></b-form-datepicker>
</b-button-group>
<b-button-group class="mx-1">
<b-button v-html="'>'" @click="setStartingDay(1)"></b-button>
<b-button v-html="'>>'" @click="setShowDate($refs.header.headerProps.nextPeriod)"></b-button>
<b-button v-html="'>>'"
@click="setShowDate($refs.header.headerProps.nextPeriod)"></b-button>
</b-button-group>
</b-button-toolbar>
</div>
@ -250,7 +293,7 @@
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import {BootstrapVue} from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import ContextMenu from "@/components/ContextMenu/ContextMenu"
@ -264,11 +307,11 @@ import moment from "moment"
import draggable from "vuedraggable"
import VueCookies from "vue-cookies"
import { ApiMixin, StandardToasts } from "@/utils/utils"
import { CalendarView, CalendarMathMixin } from "vue-simple-calendar/src/components/bundle"
import { ApiApiFactory } from "@/utils/openapi/api"
import {ApiMixin, StandardToasts, ResolveUrlMixin} from "@/utils/utils"
import {CalendarView, CalendarMathMixin} from "vue-simple-calendar/src/components/bundle"
import {ApiApiFactory} from "@/utils/openapi/api"
const { makeToast } = require("@/utils/utils")
const {makeToast} = require("@/utils/utils")
Vue.prototype.moment = moment
Vue.use(BootstrapVue)
@ -288,7 +331,7 @@ export default {
EmojiInput,
draggable,
},
mixins: [CalendarMathMixin, ApiMixin],
mixins: [CalendarMathMixin, ApiMixin, ResolveUrlMixin],
data: function () {
return {
showDate: new Date(),
@ -306,12 +349,12 @@ export default {
current_context_menu_item: null,
options: {
displayPeriodUom: [
{ text: this.$t("Week"), value: "week" },
{text: this.$t("Week"), value: "week"},
{
text: this.$t("Month"),
value: "month",
},
{ text: this.$t("Year"), value: "year" },
{text: this.$t("Year"), value: "year"},
],
displayPeriodCount: [1, 2, 3],
entryEditing: {
@ -369,7 +412,7 @@ export default {
dayNames: function () {
let options = []
this.getFormattedWeekdayNames(this.userLocale, "long", 0).forEach((day, index) => {
options.push({ text: day, value: index })
options.push({text: day, value: index})
})
return options
},
@ -411,6 +454,9 @@ export default {
},
},
methods: {
openRecipe: function (recipe) {
window.open(this.resolveDjangoUrl('view_recipe', recipe.id))
},
addToShopping(entry) {
if (entry.originalItem.entry.recipe !== null) {
this.shopping_list.push(entry.originalItem.entry)
@ -445,7 +491,7 @@ export default {
let apiClient = new ApiApiFactory()
apiClient
.createMealType({ name: this.$t("Meal_Type") })
.createMealType({name: this.$t("Meal_Type")})
.then((e) => {
this.periodChangedCallback(this.current_period)
})
@ -831,4 +877,9 @@ having to override as much.
.theme-default .cv-day.draghover {
box-shadow: inset 0 0 0.2em 0.2em yellow;
}
.ghost {
opacity: 0.5;
background: #c8ebfb;
}
</style>

View File

@ -18,7 +18,7 @@
<h3>
<!-- <span><b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button></span> -->
<model-menu />
<span>{{ this.this_model.name }}</span>
<span>{{ $t(this.this_model.name) }}</span>
<span v-if="apiName !== 'Step'">
<b-button variant="link" @click="startAction({ action: 'new' })">
<i class="fas fa-plus-circle fa-2x"></i>
@ -242,26 +242,6 @@ export default {
let results = result.data?.results ?? result.data
if (results?.length) {
// let secondaryRequest = undefined;
// if (this['items_' + column]?.length < getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1)) {
// // the item list is smaller than it should be based on the site the user is own
// // this happens when an item is deleted (or merged)
// // to prevent issues insert the last item of the previous search page before loading the new results
// params.page = params.page - 1
// secondaryRequest = this.genericAPI(this.this_model, this.Actions.LIST, params).then((result) => {
// let prev_page_results = result.data?.results ?? result.data
// if (prev_page_results?.length) {
// results = [prev_page_results[prev_page_results.length]].concat(results)
//
// this['items_' + column] = this['items_' + column].concat(results) //TODO duplicate code, find some elegant workaround
// this[column + '_counts']['current'] = getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1) + results.length
// this[column + '_counts']['max'] = result.data?.count ?? 0
// }
// })
// } else {
//
// }
this["items_" + column] = this["items_" + column].concat(results)
this[column + "_counts"]["current"] = getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1) + results.length
this[column + "_counts"]["max"] = result.data?.count ?? 0
@ -280,11 +260,32 @@ export default {
return this.genericAPI(this.this_model, this.Actions.FETCH, { id: id })
},
saveThis: function (item) {
// look for and destroy any existing cards to prevent duplicates in the GET case of get_or_create
// then place all new items at the top of the list - could sort instead
this.items_left = [item].concat(this.destroyCard(item?.id, this.items_left))
// this creates a deep copy to make sure that columns stay independent
this.items_right = [{ ...item }].concat(this.destroyCard(item?.id, this.items_right))
if (!item?.id) {
// if there is no item id assume it's a new item
this.genericAPI(this.this_model, this.Actions.CREATE, item)
.then((result) => {
// look for and destroy any existing cards to prevent duplicates in the GET case of get_or_create
// then place all new items at the top of the list - could sort instead
this.items_left = [result.data].concat(this.destroyCard(result?.data?.id, this.items_left))
// this creates a deep copy to make sure that columns stay independent
this.items_right = [{ ...result.data }].concat(this.destroyCard(result?.data?.id, this.items_right))
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
} else {
this.genericAPI(this.this_model, this.Actions.UPDATE, item)
.then((result) => {
this.refreshThis(item.id)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
})
.catch((err) => {
console.log(err, err.response)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
}
},
// this currently assumes shopping is only applicable on FOOD model
addShopping: function (food) {

Some files were not shown because too many files have changed in this diff Show More