Skip to content

Commit

Permalink
Merge branch 'develop'
Browse files Browse the repository at this point in the history
  • Loading branch information
vabene1111 committed Sep 21, 2022
2 parents 9ef2124 + b493933 commit 2902262
Show file tree
Hide file tree
Showing 69 changed files with 7,087 additions and 6,184 deletions.
4 changes: 4 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ SHOPPING_MIN_AUTOSYNC_INTERVAL=5
# when unset: 1 (true) - this is temporary until an appropriate amount of time has passed for everyone to migrate
GUNICORN_MEDIA=0

# GUNICORN SERVER RELATED SETTINGS (see https://docs.gunicorn.org/en/stable/design.html#how-many-workers for recommended settings)
# GUNICORN_WORKERS=1
# GUNICORN_THREADS=1

# S3 Media settings: store mediafiles in s3 or any compatible storage backend (e.g. minio)
# as long as S3_ACCESS_KEY is not set S3 features are disabled
# S3_ACCESS_KEY=
Expand Down
4 changes: 3 additions & 1 deletion boot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
source venv/bin/activate

TANDOOR_PORT="${TANDOOR_PORT:-8080}"
GUNICORN_WORKERS="${GUNICORN_WORKERS}"
GUNICORN_THREADS="${GUNICORN_THREADS}"
NGINX_CONF_FILE=/opt/recipes/nginx/conf.d/Recipes.conf

display_warning() {
Expand Down Expand Up @@ -63,4 +65,4 @@ echo "Done"

chmod -R 755 /opt/recipes/mediafiles

exec gunicorn -b :$TANDOOR_PORT --access-logfile - --error-logfile - --log-level INFO recipes.wsgi
exec gunicorn -b :$TANDOOR_PORT --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level INFO recipes.wsgi
2 changes: 1 addition & 1 deletion cookbook/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class SpaceAdmin(admin.ModelAdmin):

class UserSpaceAdmin(admin.ModelAdmin):
list_display = ('user', 'space',)
search_fields = ('user', 'space',)
search_fields = ('user__username', 'space__name',)


admin.site.register(UserSpace, UserSpaceAdmin)
Expand Down
1 change: 1 addition & 0 deletions cookbook/helper/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ def context_settings(request):
'TERMS_URL': settings.TERMS_URL,
'PRIVACY_URL': settings.PRIVACY_URL,
'IMPRINT_URL': settings.IMPRINT_URL,
'SHOPPING_MIN_AUTOSYNC_INTERVAL': settings.SHOPPING_MIN_AUTOSYNC_INTERVAL,
}
4 changes: 2 additions & 2 deletions cookbook/helper/ingredient_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ def parse(self, ingredient):

# some people/languages put amount and unit at the end of the ingredient string
# if something like this is detected move it to the beginning so the parser can handle it
if len(ingredient) < 1000 and re.search(r'^([A-z])+(.)*[1-9](\d)*\s([A-z])+', ingredient):
match = re.search(r'[1-9](\d)*\s([A-z])+', ingredient)
if len(ingredient) < 1000 and re.search(r'^([^\W\d_])+(.)*[1-9](\d)*\s*([^\W\d_])+', ingredient):
match = re.search(r'[1-9](\d)*\s*([^\W\d_])+', ingredient)
print(f'reording from {ingredient} to {ingredient[match.start():match.end()] + " " + ingredient.replace(ingredient[match.start():match.end()], "")}')
ingredient = ingredient[match.start():match.end()] + ' ' + ingredient.replace(ingredient[match.start():match.end()], '')

Expand Down
4 changes: 2 additions & 2 deletions cookbook/helper/mdx_urlize.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ def handleMatch(self, m):
class UrlizeExtension(markdown.Extension):
""" Urlize Extension for Python-Markdown. """

def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
""" Replace autolink with UrlizePattern """
md.inlinePatterns['autolink'] = UrlizePattern(URLIZE_RE, md)
md.inlinePatterns.register(UrlizePattern(URLIZE_RE, md), 'autolink', 120)


def makeExtension(*args, **kwargs):
Expand Down
62 changes: 53 additions & 9 deletions cookbook/helper/permission_helper.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import inspect

from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test
from django.core.cache import caches
from django.core.cache import cache
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.http import HttpResponseRedirect
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from oauth2_provider.contrib.rest_framework import TokenHasScope, TokenHasReadWriteScope
from oauth2_provider.models import AccessToken
from rest_framework import permissions
from rest_framework.permissions import SAFE_METHODS

from cookbook.models import ShareLink, Recipe, UserPreference, UserSpace
from cookbook.models import ShareLink, Recipe, UserSpace


def get_allowed_groups(groups_required):
Expand All @@ -27,25 +31,37 @@ def get_allowed_groups(groups_required):
return groups_allowed


def has_group_permission(user, groups):
def has_group_permission(user, groups, no_cache=False):
"""
Tests if a given user is member of a certain group (or any higher group)
Superusers always bypass permission checks.
Unauthenticated users can't be member of any group thus always return false.
:param no_cache: (optional) do not return cached results, always check agains DB
: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
"""
if not user.is_authenticated:
return False
groups_allowed = get_allowed_groups(groups)

CACHE_KEY = hash((inspect.stack()[0][3], (user.pk, user.username, user.email), groups_allowed))
if not no_cache:
cached_result = cache.get(CACHE_KEY, default=None)
if cached_result is not None:
return cached_result

result = False
print('running check', user, groups_allowed)
if user.is_authenticated:
if user_space := user.userspace_set.filter(active=True):
if len(user_space) != 1:
return False # do not allow any group permission if more than one space is active, needs to be changed when simultaneous multi-space-tenancy is added
if bool(user_space.first().groups.filter(name__in=groups_allowed)):
return True
return False
result = False # do not allow any group permission if more than one space is active, needs to be changed when simultaneous multi-space-tenancy is added
elif bool(user_space.first().groups.filter(name__in=groups_allowed)):
result = True

cache.set(CACHE_KEY, result, timeout=10)
return result


def is_object_owner(user, obj):
Expand Down Expand Up @@ -104,15 +120,15 @@ def share_link_valid(recipe, share):
"""
try:
CACHE_KEY = f'recipe_share_{recipe.pk}_{share}'
if c := caches['default'].get(CACHE_KEY, False):
if c := cache.get(CACHE_KEY, False):
return c

if link := ShareLink.objects.filter(recipe=recipe, uuid=share, abuse_blocked=False).first():
if 0 < settings.SHARING_LIMIT < link.request_count:
return False
link.request_count += 1
link.save()
caches['default'].set(CACHE_KEY, True, timeout=3)
cache.set(CACHE_KEY, True, timeout=3)
return True
return False
except ValidationError:
Expand Down Expand Up @@ -338,6 +354,34 @@ def has_object_permission(self, request, view, obj): # object write permissions
return False


class CustomTokenHasScope(TokenHasScope):
"""
Custom implementation of Django OAuth Toolkit TokenHasScope class
Only difference: if any other authentication method except OAuth2Authentication is used the scope check is ignored
IMPORTANT: do not use this class without any other permission class as it will not check anything besides token scopes
"""

def has_permission(self, request, view):
if type(request.auth) == AccessToken:
return super().has_permission(request, view)
else:
return request.user.is_authenticated


class CustomTokenHasReadWriteScope(TokenHasReadWriteScope):
"""
Custom implementation of Django OAuth Toolkit TokenHasReadWriteScope class
Only difference: if any other authentication method except OAuth2Authentication is used the scope check is ignored
IMPORTANT: do not use this class without any other permission class as it will not check anything besides token scopes
"""

def has_permission(self, request, view):
if type(request.auth) == AccessToken:
return super().has_permission(request, view)
else:
return True


def above_space_limit(space): # TODO add file storage limit
"""
Test if the space has reached any limit (e.g. max recipes, users, ..)
Expand Down
61 changes: 34 additions & 27 deletions cookbook/helper/recipe_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from datetime import date, timedelta

from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity
from django.core.cache import cache
from django.core.cache import caches
from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value, When)
from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value, When, FilteredRelation)
from django.db.models.functions import Coalesce, Lower, Substr
from django.utils import timezone, translation
from django.utils.translation import gettext as _
Expand All @@ -21,7 +22,7 @@
class RecipeSearch():
_postgres = settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']

def __init__(self, request, **params):
def __init__(self, request, **params):
self._request = request
self._queryset = None
if f := params.get('filter', None):
Expand All @@ -35,7 +36,13 @@ def __init__(self, request, **params):
else:
self._params = {**(params or {})}
if self._request.user.is_authenticated:
self._search_prefs = request.user.searchpreference
CACHE_KEY = f'search_pref_{request.user.id}'
cached_result = cache.get(CACHE_KEY, default=None)
if cached_result is not None:
self._search_prefs = cached_result
else:
self._search_prefs = request.user.searchpreference
cache.set(CACHE_KEY, self._search_prefs, timeout=10)
else:
self._search_prefs = SearchPreference()
self._string = self._params.get('query').strip() if self._params.get('query', None) else None
Expand Down Expand Up @@ -110,19 +117,20 @@ def __init__(self, request, **params):
)
self.search_rank = None
self.orderby = []
self._default_sort = ['-favorite'] # TODO add user setting
self._filters = None
self._fuzzy_match = None

def get_queryset(self, queryset):
self._queryset = queryset
self._queryset = self._queryset.prefetch_related('keywords')

self._build_sort_order()
self._recently_viewed(num_recent=self._num_recent)
self._cooked_on_filter(cooked_date=self._cookedon)
self._created_on_filter(created_date=self._createdon)
self._updated_on_filter(updated_date=self._updatedon)
self._viewed_on_filter(viewed_date=self._viewedon)
self._favorite_recipes(timescooked=self._timescooked)
self._favorite_recipes(times_cooked=self._timescooked)
self._new_recipes()
self.keyword_filters(**self._keywords)
self.food_filters(**self._foods)
Expand All @@ -149,7 +157,7 @@ def _build_sort_order(self):
else:
order = []
# TODO add userpreference for default sort order and replace '-favorite'
default_order = ['-favorite']
default_order = ['-name']
# recent and new_recipe are always first; they float a few recipes to the top
if self._num_recent:
order += ['-recent']
Expand Down Expand Up @@ -206,7 +214,7 @@ def string_filters(self, string=None):
else:
self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0))
if self._sort_includes('score') and self._fulltext_include and self._fuzzy_match is not None:
self._queryset = self._queryset.annotate(score=F('rank')+F('simularity'))
self._queryset = self._queryset.annotate(score=F('rank') + F('simularity'))
else:
query_filter = Q()
for f in [x + '__unaccent__iexact' if x in self._unaccent_include else x + '__iexact' for x in SearchFields.objects.all().values_list('field', flat=True)]:
Expand Down Expand Up @@ -287,25 +295,25 @@ def _recently_viewed(self, num_recent=None):
'recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent]
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))

def _favorite_recipes(self, timescooked=None):
if self._sort_includes('favorite') or timescooked:
lessthan = '-' in (timescooked or []) or not self._sort_includes('-favorite')
if lessthan:
def _favorite_recipes(self, times_cooked=None):
if self._sort_includes('favorite') or times_cooked:
less_than = '-' in (times_cooked or []) or not self._sort_includes('-favorite')
if less_than:
default = 1000
else:
default = 0
favorite_recipes = CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk')
).values('recipe').annotate(count=Count('pk', distinct=True)).values('count')
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default))
if timescooked is None:
if times_cooked is None:
return

if timescooked == '0':
if times_cooked == '0':
self._queryset = self._queryset.filter(favorite=0)
elif lessthan:
self._queryset = self._queryset.filter(favorite__lte=int(timescooked[1:])).exclude(favorite=0)
elif less_than:
self._queryset = self._queryset.filter(favorite__lte=int(times_cooked[1:])).exclude(favorite=0)
else:
self._queryset = self._queryset.filter(favorite__gte=int(timescooked))
self._queryset = self._queryset.filter(favorite__gte=int(times_cooked))

def keyword_filters(self, **kwargs):
if all([kwargs[x] is None for x in kwargs]):
Expand Down Expand Up @@ -505,10 +513,10 @@ def _makenow_filter(self, missing=None):
shopping_users = [*self._request.user.get_shopping_share(), self._request.user]

onhand_filter = (
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
| Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users) # or substitute food onhand
| Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users))
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
| Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users) # or substitute food onhand
| Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users))
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
)
makenow_recipes = Recipe.objects.annotate(
count_food=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__isnull=False), distinct=True),
Expand All @@ -517,10 +525,10 @@ def _makenow_filter(self, missing=None):
steps__ingredients__food__recipe__isnull=True), distinct=True),
has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users), then=Value(1)), default=Value(0)),
has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users), then=Value(1)), default=Value(0))
).annotate(missingfood=F('count_food')-F('count_onhand')-F('count_ignore_shopping')).filter(missingfood=missing)
).annotate(missingfood=F('count_food') - F('count_onhand') - F('count_ignore_shopping')).filter(missingfood=missing)
self._queryset = self._queryset.distinct().filter(id__in=makenow_recipes.values('id'))

@ staticmethod
@staticmethod
def __children_substitute_filter(shopping_users=None):
children_onhand_subquery = Food.objects.filter(
path__startswith=OuterRef('path'),
Expand All @@ -536,10 +544,10 @@ def __children_substitute_filter(shopping_users=None):
).annotate(child_onhand_count=Exists(children_onhand_subquery)
).filter(child_onhand_count=True)

@ staticmethod
@staticmethod
def __sibling_substitute_filter(shopping_users=None):
sibling_onhand_subquery = Food.objects.filter(
path__startswith=Substr(OuterRef('path'), 1, Food.steplen*(OuterRef('depth')-1)),
path__startswith=Substr(OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)),
depth=OuterRef('depth'),
onhand_users__in=shopping_users
)
Expand All @@ -563,7 +571,7 @@ def __init__(self, request, queryset=None, hash_key=None, cache_timeout=3600):

self._request = request
self._queryset = queryset
self.hash_key = hash_key or str(hash(frozenset(self._queryset.values_list('pk'))))
self.hash_key = hash_key or str(hash(self._queryset.query))
self._SEARCH_CACHE_KEY = f"recipes_filter_{self.hash_key}"
self._cache_timeout = cache_timeout
self._cache = caches['default'].get(self._SEARCH_CACHE_KEY, {})
Expand Down Expand Up @@ -743,7 +751,7 @@ def _keyword_queryset(self, queryset, keyword=None):
).filter(depth=depth, count__gt=0
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
else:
return queryset.filter(depth=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())
return queryset.filter(depth=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())

def _food_queryset(self, queryset, food=None):
depth = getattr(food, 'depth', 0) + 1
Expand All @@ -755,4 +763,3 @@ def _food_queryset(self, queryset, food=None):
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
else:
return queryset.filter(depth__lte=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())

3 changes: 2 additions & 1 deletion cookbook/helper/scope_middleware.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.urls import reverse
from django_scopes import scope, scopes_disabled
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from rest_framework.authentication import TokenAuthentication
from rest_framework.authtoken.models import Token
from rest_framework.exceptions import AuthenticationFailed
Expand Down Expand Up @@ -55,7 +56,7 @@ def __call__(self, request):
else:
if request.path.startswith(prefix + '/api/'):
try:
if auth := TokenAuthentication().authenticate(request):
if auth := OAuth2Authentication().authenticate(request):
user_space = auth[0].userspace_set.filter(active=True).first()
if user_space:
request.space = user_space.space
Expand Down
6 changes: 3 additions & 3 deletions cookbook/locale/da/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-29 18:42+0200\n"
"PO-Revision-Date: 2022-05-10 15:32+0000\n"
"PO-Revision-Date: 2022-08-18 14:32+0000\n"
"Last-Translator: Mathias Rasmussen <[email protected]>\n"
"Language-Team: Danish <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/da/>\n"
Expand Down Expand Up @@ -2377,9 +2377,9 @@ msgid ""
" "
msgstr ""
"At servere mediefiler direkte med gunicorn/python er <b>ikke anbefalet</b>!\n"
" Følg venligst trinne beskrevet\n"
" Følg venligst trinnene beskrevet\n"
" <a href=\"https://github.com/vabene1111/recipes/releases/tag/0.8.1\""
">here</a> for at opdtere\n"
">her</a> for at opdatere\n"
" din installation.\n"
" "

Expand Down
Loading

0 comments on commit 2902262

Please sign in to comment.