keyword update, tests sqlite and PG

This commit is contained in:
smilerz 2021-07-30 14:00:09 -05:00
parent 2f91c2f86e
commit 2da0f5c478
26 changed files with 245 additions and 277 deletions

View File

@ -18,8 +18,6 @@ from .models import (Comment, CookLog, Food, Ingredient, InviteLink, Keyword,
from cookbook.managers import DICTIONARY
from cookbook.managers import DICTIONARY
class CustomUserAdmin(UserAdmin):
def has_add_permission(self, request, obj=None):

View File

@ -117,7 +117,7 @@ def search_recipes(request, queryset, params):
)
queryset = queryset.filter(query_filter).annotate(rank=search_rank)
else:
queryset = queryset.filter(name__icontains=search_string)
queryset = queryset.filter(query_filter)
if len(search_keywords) > 0:
if search_keywords_or == 'true':

View File

@ -52,7 +52,7 @@ class Chowdown(Integration):
for k in tags.split(','):
print(f'adding keyword {k.strip()}')
keyword, created = Keyword.get_or_create(name=k.strip(), space=self.request.space)
keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space)
recipe.keywords.add(keyword)
step = Step.objects.create(

View File

@ -16,7 +16,7 @@ from django_scopes import scope
from cookbook.forms import ImportExportBase
from cookbook.helper.image_processing import get_filetype
from cookbook.models import Keyword, Recipe
from recipes.settings import DEBUG
from recipes.settings import DATABASES, DEBUG
class Integration:
@ -33,8 +33,29 @@ class Integration:
"""
self.request = request
self.export_type = export_type
# TODO add all import keywords under the importer root node
self.keyword = Keyword.objects.first()
name = f'Import {export_type}'
description = f'Imported by {request.user.get_user_name()} at {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}. Type: {export_type}'
icon = '📥'
count = Keyword.objects.filter(name__icontains=name, space=request.space).count()
if count != 0:
pk = Keyword.objects.filter(name__icontains=name, space=request.space).order_by('id').first().id
name = name + " " + str(pk)
if DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
parent = Keyword.objects.get_or_create(name='Import', space=request.space)
parent.add_child(
name=name,
description=description,
icon=icon,
space=request.space
)
else:
self.keyword = Keyword.objects.create(
name=name,
description=description,
icon=icon,
space=request.space
)
def do_export(self, recipes):
"""
@ -181,7 +202,7 @@ class Integration:
except BadZipFile:
il.msg += 'ERROR ' + _(
'Importer expected a .zip file. Did you choose the correct importer type for your data ?') + '\n'
except:
except Exception as e:
msg = 'ERROR ' + _(
'An unexpected error occurred during the import. Please make sure you have uploaded a valid file.') + '\n'
self.handle_exception(e, log=il, message=msg)

View File

@ -27,7 +27,7 @@ def set_default_search_vector(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0141_keyword_to_tree'),
('cookbook', '0141_auto_20210713_1042'),
]
operations = [
migrations.AddField(

View File

@ -19,7 +19,7 @@ from django.utils.translation import gettext as _
from treebeard.mp_tree import MP_Node, MP_NodeManager
from django_scopes import ScopedManager, scopes_disabled
from django_prometheus.models import ExportModelOperationsMixin
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT,
from recipes.settings import (COMMENT_PREF_DEFAULT, DATABASES, FRACTION_PREF_DEFAULT,
STICKY_NAV_PREF_DEFAULT)
@ -37,8 +37,20 @@ def get_model_name(model):
return ('_'.join(re.findall('[A-Z][^A-Z]*', model.__name__))).lower()
class PermissionModelMixin:
class TreeManager(MP_NodeManager):
def get_or_create(self, **kwargs):
# model.Manager get_or_create() is not compatible with MP_Tree
kwargs['name'] = kwargs['name'].strip()
q = self.filter(name__iexact=kwargs['name'], space=kwargs['space'])
if len(q) != 0:
return q[0], False
else:
with scopes_disabled():
node = self.model.add_root(**kwargs)
return node, True
class PermissionModelMixin:
@staticmethod
def get_space_key():
return ('space',)
@ -265,7 +277,6 @@ class SyncLog(models.Model, PermissionModelMixin):
class Keyword(ExportModelOperationsMixin('keyword'), MP_Node, PermissionModelMixin):
# TODO create get_or_create method
node_order_by = ['name']
name = models.CharField(max_length=64)
icon = models.CharField(max_length=16, blank=True, null=True)
@ -274,7 +285,7 @@ class Keyword(ExportModelOperationsMixin('keyword'), MP_Node, PermissionModelMix
updated_at = models.DateTimeField(auto_now=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space', _manager_class=MP_NodeManager)
objects = ScopedManager(space='space', _manager_class=TreeManager)
_full_name_separator = ' > '
@ -291,19 +302,6 @@ class Keyword(ExportModelOperationsMixin('keyword'), MP_Node, PermissionModelMix
return self.get_parent().id
return None
@classmethod
def get_or_create(self, **kwargs):
# an attempt to mimic get_or_create functionality with Keywords
# function attempts to get the keyword,
# if the length of the return is 0 will add a root node
kwargs['name'] = kwargs['name'].strip()
q = self.get_tree().filter(name=kwargs['name'], space=kwargs['space'])
if len(q) != 0:
return q[0], False
else:
kw = Keyword.add_root(**kwargs)
return kw, True
@property
def full_name(self):
"""
@ -337,6 +335,7 @@ class Keyword(ExportModelOperationsMixin('keyword'), MP_Node, PermissionModelMix
def get_num_children(self):
return self.get_children().count()
# use self.objects.get_or_create() instead
@classmethod
def add_root(self, **kwargs):
with scopes_disabled():

View File

@ -224,7 +224,7 @@ class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
# since multi select tags dont have id's
# duplicate names might be routed to create
validated_data['space'] = self.context['request'].space
obj, created = Keyword.get_or_create(**validated_data)
obj, created = Keyword.objects.get_or_create(**validated_data)
return obj
class Meta:

View File

@ -1 +1 @@
.shake[data-v-88855b04]{-webkit-animation:shake-data-v-88855b04 .82s cubic-bezier(.36,.07,.19,.97) both;animation:shake-data-v-88855b04 .82s cubic-bezier(.36,.07,.19,.97) both;transform:translateZ(0);-webkit-backface-visibility:hidden;backface-visibility:hidden;perspective:1000px}@-webkit-keyframes shake-data-v-88855b04{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}@keyframes shake-data-v-88855b04{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}
.shake[data-v-54d4941f]{-webkit-animation:shake-data-v-54d4941f .82s cubic-bezier(.36,.07,.19,.97) both;animation:shake-data-v-54d4941f .82s cubic-bezier(.36,.07,.19,.97) both;transform:translateZ(0);-webkit-backface-visibility:hidden;backface-visibility:hidden;perspective:1000px}@-webkit-keyframes shake-data-v-54d4941f{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}@keyframes shake-data-v-54d4941f{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -7,6 +7,15 @@ from django_scopes import scopes_disabled
from cookbook.models import Keyword
from cookbook.tests.conftest import get_random_recipe
# ------------------ IMPORTANT -------------------
#
# if changing any capabilities associated with keywords
# you will need to ensure that it is tested against both
# SqlLite and PostgresSQL
# adding load_env() to settings.py will enable Postgress access
#
# ------------------ IMPORTANT -------------------
LIST_URL = 'api:keyword-list'
DETAIL_URL = 'api:keyword-detail'
MOVE_URL = 'api:keyword-move'
@ -16,7 +25,7 @@ MERGE_URL = 'api:keyword-merge'
# TODO are there better ways to manage these fixtures?
@pytest.fixture()
def obj_1(space_1):
return Keyword.add_root(name='test_1', space=space_1)
return Keyword.objects.get_or_create(name='test_1', space=space_1)[0]
@pytest.fixture()
@ -31,12 +40,12 @@ def obj_1_1_1(obj_1_1, space_1):
@pytest.fixture
def obj_2(space_1):
return Keyword.add_root(name='test_2', space=space_1)
return Keyword.objects.get_or_create(name='test_2', space=space_1)[0]
@pytest.fixture()
def obj_3(space_2):
return Keyword.add_root(name='test_3', space=space_2)
return Keyword.objects.get_or_create(name='test_3', space=space_2)[0]
@pytest.fixture()

View File

@ -332,7 +332,7 @@ class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMi
return super().get_queryset()
class KeywordViewSet(viewsets.ModelViewSet, StandardFilterMixin):
class KeywordViewSet(viewsets.ModelViewSet, TreeMixin):
queryset = Keyword.objects
model = Keyword
serializer_class = KeywordSerializer
@ -447,67 +447,6 @@ class RecipePagination(PageNumberPagination):
max_page_size = 100
# TODO move to separate class to cleanup
class RecipeSchema(AutoSchema):
def get_path_parameters(self, path, method):
if not is_list_view(path, method, self.view):
return super(RecipeSchema, self).get_path_parameters(path, method)
parameters = super().get_path_parameters(path, method)
parameters.append({
"name": 'query', "in": "query", "required": False,
"description": 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'keywords', "in": "query", "required": False,
"description": 'Id of keyword a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'foods', "in": "query", "required": False,
"description": 'Id of food a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'books', "in": "query", "required": False,
"description": 'Id of book a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'keywords_or', "in": "query", "required": False,
"description": 'If recipe should have all (AND) or any (OR) of the provided keywords.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'foods_or', "in": "query", "required": False,
"description": 'If recipe should have all (AND) or any (OR) any of the provided foods.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'books_or', "in": "query", "required": False,
"description": 'If recipe should be in all (AND) or any (OR) any of the provided books.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'internal', "in": "query", "required": False,
"description": 'true or false. If only internal recipes should be returned or not.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'random', "in": "query", "required": False,
"description": 'true or false. returns the results in randomized order.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'new', "in": "query", "required": False,
"description": 'true or false. returns new results first in search results',
'schema': {'type': 'string', },
})
return parameters
class RecipeViewSet(viewsets.ModelViewSet):
queryset = Recipe.objects
serializer_class = RecipeSerializer

View File

@ -148,13 +148,8 @@ def import_url(request):
recipe.steps.add(step)
all_keywords = Keyword.get_tree()
for kw in data['keywords']:
q = all_keywords.filter(name=kw['text'], space=request.space)
if len(q) != 0:
recipe.keywords.add(q[0])
elif data['all_keywords']:
k = Keyword.add_root(name=kw['text'], space=request.space)
k = Keyword.objects.get_or_create(name=kw['text'], space=request.space)
recipe.keywords.add(k)
for ing in data['recipeIngredient']:

View File

@ -11,10 +11,8 @@ from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.models import Group
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db.models import Avg, Q
from django.db.models import Sum
from django.http import HttpResponseRedirect
from django.http import JsonResponse
from django.db.models import Avg, Q, Sum
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render, redirect
from django.urls import reverse, reverse_lazy
from django.utils import timezone
@ -26,7 +24,8 @@ from rest_framework.authtoken.models import Token
from cookbook.filters import RecipeFilter
from cookbook.forms import (CommentForm, Recipe, User,
UserCreateForm, UserNameForm, UserPreference,
UserPreferenceForm, SpaceJoinForm, SpaceCreateForm, SearchPreferenceForm)
UserPreferenceForm, SpaceJoinForm, SpaceCreateForm,
SearchPreferenceForm, AllAuthSignupForm)
from cookbook.helper.ingredient_parser import parse
from cookbook.helper.permission_helper import group_required, share_link_valid, has_group_permission
from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,

View File

@ -19,7 +19,7 @@ from django.contrib.staticfiles.storage import staticfiles_storage
from django.utils.translation import gettext_lazy as _
from dotenv import load_dotenv
from webpack_loader.loader import WebpackLoader
load_dotenv()
# load_dotenv()
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Get vars from .env files
@ -279,6 +279,16 @@ else:
# }
# }
# SQLite testing DB
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'OPTIONS': ast.literal_eval(os.getenv('DB_OPTIONS')) if os.getenv('DB_OPTIONS') else {},
'NAME': 'db.sqlite3',
'CONN_MAX_AGE': 600,
}
}
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',

View File

@ -453,7 +453,7 @@ export default {
apiClient.listRecipes(
undefined, keyword, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, pageSize
undefined, undefined, undefined, undefined, undefined, pageSize, undefined
).then(result => {
if (col == 'left') {
parent = this.findKeyword(this.keywords, kw.id)

View File

@ -2,7 +2,7 @@
<div row>
<b-card no-body d-flex flex-column :class="{'border border-primary' : over, 'shake': isError}"
refs="keywordCard"
style="height: 10vh;" :style="{'cursor:grab' : draggle}"
style="height: 10vh;" :style="{'cursor:grab' : draggable}"
@dragover.prevent
@dragenter.prevent
:draggable="draggable"

View File

@ -97,7 +97,6 @@
"merge_confirmation": "Replace {source} with {target}",
"move_selection": "Select a parent to move {child} to.",
"merge_selection": "Replace all occurences of {source} with the selected {type}.",
"Advanced Search Settings": "Advanced Search Settings",
"Download": "Download",
"Root": "Root"
}

View File

@ -4193,13 +4193,12 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
* @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books.
* @param {string} [internal] true or false. If only internal recipes should be returned or not.
* @param {string} [random] true or false. returns the results in randomized order.
* @param {string} [_new] true or false. returns new results first in search results
* @param {number} [page] A page number within the paginated result set.
* @param {number} [pageSize] Number of results to return per page.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listRecipes: async (query?: string, keywords?: string, foods?: string, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options: any = {}): Promise<RequestArgs> => {
listRecipes: async (query?: string, keywords?: string, foods?: string, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, page?: number, pageSize?: number, options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/recipe/`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -4248,10 +4247,6 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
localVarQueryParameter['random'] = random;
}
if (_new !== undefined) {
localVarQueryParameter['new'] = _new;
}
if (page !== undefined) {
localVarQueryParameter['page'] = page;
}
@ -8076,14 +8071,13 @@ export const ApiApiFp = function(configuration?: Configuration) {
* @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books.
* @param {string} [internal] true or false. If only internal recipes should be returned or not.
* @param {string} [random] true or false. returns the results in randomized order.
* @param {string} [_new] true or false. returns new results first in search results
* @param {number} [page] A page number within the paginated result set.
* @param {number} [pageSize] Number of results to return per page.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listRecipes(query?: string, keywords?: string, foods?: string, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<InlineResponse200>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listRecipes(query, keywords, foods, books, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options);
async listRecipes(query?: string, keywords?: string, foods?: string, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<InlineResponse2001>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listRecipes(query, keywords, foods, books, keywordsOr, foodsOr, booksOr, internal, random, page, pageSize, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -9590,14 +9584,13 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
* @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books.
* @param {string} [internal] true or false. If only internal recipes should be returned or not.
* @param {string} [random] true or false. returns the results in randomized order.
* @param {string} [_new] true or false. returns new results first in search results
* @param {number} [page] A page number within the paginated result set.
* @param {number} [pageSize] Number of results to return per page.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listRecipes(query?: string, keywords?: string, foods?: string, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any): AxiosPromise<InlineResponse200> {
return localVarFp.listRecipes(query, keywords, foods, books, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options).then((request) => request(axios, basePath));
listRecipes(query?: string, keywords?: string, foods?: string, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, page?: number, pageSize?: number, options?: any): AxiosPromise<InlineResponse2001> {
return localVarFp.listRecipes(query, keywords, foods, books, keywordsOr, foodsOr, booksOr, internal, random, page, pageSize, options).then((request) => request(axios, basePath));
},
/**
*
@ -11130,15 +11123,14 @@ export class ApiApi extends BaseAPI {
* @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books.
* @param {string} [internal] true or false. If only internal recipes should be returned or not.
* @param {string} [random] true or false. returns the results in randomized order.
* @param {string} [_new] true or false. returns new results first in search results
* @param {number} [page] A page number within the paginated result set.
* @param {number} [pageSize] Number of results to return per page.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public listRecipes(query?: string, keywords?: string, foods?: string, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any) {
return ApiApiFp(this.configuration).listRecipes(query, keywords, foods, books, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options).then((request) => request(this.axios, this.basePath));
public listRecipes(query?: string, keywords?: string, foods?: string, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, page?: number, pageSize?: number, options?: any) {
return ApiApiFp(this.configuration).listRecipes(query, keywords, foods, books, keywordsOr, foodsOr, booksOr, internal, random, page, pageSize, options).then((request) => request(this.axios, this.basePath));
}
/**