django-camomilla-cms 6.0.0b16__py2.py3-none-any.whl → 6.0.0b17__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. camomilla/__init__.py +1 -1
  2. camomilla/contrib/modeltranslation/hvad_migration.py +9 -9
  3. camomilla/dynamic_pages_urls.py +6 -2
  4. camomilla/managers/pages.py +87 -2
  5. camomilla/model_api.py +6 -4
  6. camomilla/models/menu.py +9 -4
  7. camomilla/models/page.py +178 -117
  8. camomilla/openapi/schema.py +15 -10
  9. camomilla/redirects.py +10 -0
  10. camomilla/serializers/base/__init__.py +4 -4
  11. camomilla/serializers/fields/__init__.py +5 -17
  12. camomilla/serializers/fields/related.py +5 -3
  13. camomilla/serializers/mixins/__init__.py +23 -240
  14. camomilla/serializers/mixins/fields.py +20 -0
  15. camomilla/serializers/mixins/filter_fields.py +9 -8
  16. camomilla/serializers/mixins/json.py +34 -0
  17. camomilla/serializers/mixins/language.py +32 -0
  18. camomilla/serializers/mixins/nesting.py +35 -0
  19. camomilla/serializers/mixins/optimize.py +91 -0
  20. camomilla/serializers/mixins/ordering.py +34 -0
  21. camomilla/serializers/mixins/page.py +58 -0
  22. camomilla/{contrib/rest_framework/serializer.py → serializers/mixins/translation.py} +16 -56
  23. camomilla/serializers/utils.py +3 -3
  24. camomilla/serializers/validators.py +6 -2
  25. camomilla/settings.py +10 -2
  26. camomilla/storages/default.py +7 -1
  27. camomilla/templates/defaults/parts/menu.html +1 -1
  28. camomilla/templatetags/menus.py +3 -0
  29. camomilla/theme/__init__.py +1 -1
  30. camomilla/theme/{admin.py → admin/__init__.py} +22 -20
  31. camomilla/theme/admin/pages.py +46 -0
  32. camomilla/theme/admin/translations.py +13 -0
  33. camomilla/theme/apps.py +1 -5
  34. camomilla/translation.py +7 -1
  35. camomilla/urls.py +2 -5
  36. camomilla/utils/query_parser.py +42 -23
  37. camomilla/utils/translation.py +47 -5
  38. camomilla/views/base/__init__.py +35 -5
  39. camomilla/views/medias.py +1 -1
  40. camomilla/views/mixins/__init__.py +17 -76
  41. camomilla/views/mixins/bulk_actions.py +22 -0
  42. camomilla/views/mixins/language.py +33 -0
  43. camomilla/views/mixins/optimize.py +18 -0
  44. camomilla/views/mixins/pagination.py +11 -8
  45. camomilla/views/mixins/permissions.py +6 -0
  46. camomilla/views/pages.py +12 -2
  47. {django_camomilla_cms-6.0.0b16.dist-info → django_camomilla_cms-6.0.0b17.dist-info}/METADATA +23 -16
  48. {django_camomilla_cms-6.0.0b16.dist-info → django_camomilla_cms-6.0.0b17.dist-info}/RECORD +60 -43
  49. {django_camomilla_cms-6.0.0b16.dist-info → django_camomilla_cms-6.0.0b17.dist-info}/WHEEL +1 -1
  50. tests/test_camomilla_filters.py +1 -1
  51. tests/test_media.py +98 -65
  52. tests/test_menu.py +97 -0
  53. tests/test_model_api_register.py +393 -0
  54. tests/test_pages.py +343 -0
  55. tests/test_query_parser.py +1 -2
  56. tests/test_templates_context.py +111 -0
  57. tests/utils/api.py +0 -1
  58. tests/utils/media.py +9 -0
  59. camomilla/contrib/rest_framework/__init__.py +0 -0
  60. camomilla/serializers/fields/json.py +0 -48
  61. {django_camomilla_cms-6.0.0b16.dist-info → django_camomilla_cms-6.0.0b17.dist-info/licenses}/LICENSE +0 -0
  62. {django_camomilla_cms-6.0.0b16.dist-info → django_camomilla_cms-6.0.0b17.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,5 @@
1
1
  from functools import cached_property
2
- from typing import Iterable, Union, List
3
- from django.http import QueryDict
4
-
2
+ from typing import Iterable, List
5
3
  from modeltranslation import settings as mt_settings
6
4
  from modeltranslation.translator import NotRegistered, translator
7
5
  from modeltranslation.utils import build_localized_fieldname
@@ -9,50 +7,8 @@ from rest_framework import serializers
9
7
  from rest_framework.exceptions import ValidationError
10
8
  from camomilla.utils.getters import pointed_getter
11
9
  from camomilla.utils.translation import is_translatable
12
-
13
-
14
- TRANS_ACCESSOR = "translations"
15
-
16
-
17
- def plain_to_nest(data, fields, accessor=TRANS_ACCESSOR):
18
- """
19
- This function transforms a plain dictionary with translations fields (es. {"title_en": "Hello"})
20
- into a dictionary with nested translations fields (es. {"translations": {"en": {"title": "Hello"}}}).
21
- """
22
- trans_data = {}
23
- for lang in mt_settings.AVAILABLE_LANGUAGES:
24
- lang_data = {}
25
- for field in fields:
26
- trans_field_name = build_localized_fieldname(field, lang)
27
- if trans_field_name in data:
28
- lang_data[field] = data.pop(trans_field_name)
29
- if lang_data.keys():
30
- trans_data[lang] = lang_data
31
- if trans_data.keys():
32
- data[accessor] = trans_data
33
- return data
34
-
35
-
36
- def nest_to_plain(data: Union[dict, QueryDict], fields: List[str], accessor=TRANS_ACCESSOR):
37
- """
38
- This function is the inverse of plain_to_nest.
39
- It transforms a dictionary with nested translations fields (es. {"translations": {"en": {"title": "Hello"}}})
40
- into a plain dictionary with translations fields (es. {"title_en": "Hello"}).
41
- """
42
- if isinstance(data, QueryDict):
43
- data = data.dict()
44
- translations = data.pop(accessor, {})
45
- for lang in mt_settings.AVAILABLE_LANGUAGES:
46
- nest_trans = translations.pop(lang, {})
47
- for k in fields:
48
- data.pop(k, None) # this removes all trans field without lang
49
- if k in nest_trans:
50
- # this saves on the default field the default language value
51
- if lang == mt_settings.DEFAULT_LANGUAGE:
52
- data[k] = nest_trans[k]
53
- key = build_localized_fieldname(k, lang)
54
- data[key] = data.get(key, nest_trans[k])
55
- return data
10
+ from camomilla.utils.translation import nest_to_plain, plain_to_nest
11
+ from camomilla.settings import API_TRANSLATION_ACCESSOR
56
12
 
57
13
 
58
14
  class TranslationsMixin(serializers.ModelSerializer):
@@ -67,6 +23,16 @@ class TranslationsMixin(serializers.ModelSerializer):
67
23
  `{"translations": {"en": {"title": "Hello"}, "it": {"title": "Ciao"}}` -> `{"title_en": "Hello", "title_it": "Ciao"}`
68
24
  """
69
25
 
26
+ def _transform_input(self, data):
27
+ return nest_to_plain(
28
+ data, self.translation_fields or [], API_TRANSLATION_ACCESSOR
29
+ )
30
+
31
+ def _transform_output(self, data):
32
+ return plain_to_nest(
33
+ data, self.translation_fields or [], API_TRANSLATION_ACCESSOR
34
+ )
35
+
70
36
  @cached_property
71
37
  def translation_fields(self) -> List[str]:
72
38
  try:
@@ -81,22 +47,16 @@ class TranslationsMixin(serializers.ModelSerializer):
81
47
  yield field
82
48
 
83
49
  def to_internal_value(self, data):
84
- if self.translation_fields:
85
- nest_to_plain(data, self.translation_fields)
86
- return super().to_internal_value(data)
50
+ return super().to_internal_value(self._transform_input(data))
87
51
 
88
52
  def to_representation(self, instance):
89
- representation = super().to_representation(instance)
90
- if self.translation_fields:
91
- plain_to_nest(representation, self.translation_fields)
92
- return representation
53
+ return self._transform_output(super().to_representation(instance))
93
54
 
94
55
  def run_validation(self, *args, **kwargs):
95
56
  try:
96
57
  return super().run_validation(*args, **kwargs)
97
58
  except ValidationError as ex:
98
- if self.translation_fields:
99
- plain_to_nest(ex.detail, self.translation_fields)
59
+ ex.detail.update(self._transform_input(ex.detail))
100
60
  raise ValidationError(detail=ex.detail)
101
61
 
102
62
  @property
@@ -1,13 +1,13 @@
1
1
  def get_standard_bases() -> tuple:
2
2
  from rest_framework.serializers import ModelSerializer
3
- from camomilla.serializers.fields import FieldsOverrideMixin
4
- from camomilla.serializers.mixins.filter_fields import FilterFieldsMixin
5
- from camomilla.contrib.rest_framework.serializer import RemoveTranslationsMixin
6
3
  from camomilla.serializers.mixins import (
7
4
  JSONFieldPatchMixin,
8
5
  NestMixin,
9
6
  OrderingMixin,
10
7
  SetupEagerLoadingMixin,
8
+ FieldsOverrideMixin,
9
+ FilterFieldsMixin,
10
+ RemoveTranslationsMixin,
11
11
  )
12
12
 
13
13
  return (
@@ -31,9 +31,13 @@ class UniquePermalinkValidator:
31
31
  for language in activate_languages():
32
32
  autopermalink_f = build_localized_fieldname("autopermalink", language)
33
33
  f_name = build_localized_fieldname("permalink", language)
34
- permalink = value.get(f_name, instance and get_nofallbacks(instance, "permalink"))
34
+ permalink = value.get(
35
+ f_name, instance and get_nofallbacks(instance, "permalink")
36
+ )
35
37
  permalink = UrlNode.sanitize_permalink(permalink)
36
- autopermalink = value.get(autopermalink_f, instance and get_nofallbacks(instance, "autopermalink"))
38
+ autopermalink = value.get(
39
+ autopermalink_f, instance and get_nofallbacks(instance, "autopermalink")
40
+ )
37
41
  if autopermalink:
38
42
  continue
39
43
  fake_instance = serializer.Meta.model()
camomilla/settings.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from django.conf import settings as django_settings
2
- from modeltranslation.settings import ENABLE_REGISTRATIONS
2
+ from modeltranslation.settings import ENABLE_REGISTRATIONS, AVAILABLE_LANGUAGES
3
3
 
4
4
  from camomilla.utils.getters import pointed_getter
5
5
 
@@ -53,6 +53,10 @@ ENABLE_TRANSLATIONS = (
53
53
  ENABLE_REGISTRATIONS and "modeltranslation" in django_settings.INSTALLED_APPS
54
54
  )
55
55
 
56
+ DEFAULT_LANGUAGE = pointed_getter(django_settings, "LANGUAGE_CODE", "en")
57
+
58
+ LANGUAGE_CODES = AVAILABLE_LANGUAGES
59
+
56
60
  MEDIA_OPTIMIZE_MAX_WIDTH = pointed_getter(
57
61
  django_settings, "CAMOMILLA.MEDIA.OPTIMIZE.MAX_WIDTH", 1980
58
62
  )
@@ -79,6 +83,10 @@ TEMPLATE_CONTEXT_FILES = pointed_getter(
79
83
  django_settings, "CAMOMILLA.RENDER.TEMPLATE_CONTEXT_FILES", []
80
84
  )
81
85
 
86
+ API_TRANSLATION_ACCESSOR = pointed_getter(
87
+ django_settings, "CAMOMILLA.API.TRANSLATION_ACCESSOR", "translations"
88
+ )
89
+
82
90
  DEBUG = pointed_getter(django_settings, "CAMOMILLA.DEBUG", django_settings.DEBUG)
83
91
 
84
92
  # camomilla settings example
@@ -100,6 +108,6 @@ DEBUG = pointed_getter(django_settings, "CAMOMILLA.DEBUG", django_settings.DEBUG
100
108
  # "STRUCTURED_FIELD": {
101
109
  # "CACHE_ENABLED": True
102
110
  # }
103
- # "API": {"NESTING_DEPTH": 10 },
111
+ # "API": {"NESTING_DEPTH": 10, "TRANSLATION_ACCESSOR": "translations"},
104
112
  # "DEBUG": False
105
113
  # }
@@ -1,6 +1,12 @@
1
1
  from django.utils.module_loading import import_string
2
2
  from django.conf import settings
3
3
 
4
+ from django import VERSION as DJANGO_VERSION
5
+
4
6
 
5
7
  def get_default_storage_class():
6
- return import_string(settings.STORAGES["default"]["BACKEND"])
8
+ if DJANGO_VERSION >= (4, 2):
9
+ storage = settings.STORAGES["default"]["BACKEND"]
10
+ else:
11
+ storage = settings.DEFAULT_FILE_STORAGE
12
+ return import_string(storage)
@@ -4,7 +4,7 @@
4
4
  {% for item in menu.nodes %}
5
5
  <li>
6
6
  {% if item.link.url %}
7
- <a href="{{ item.link.url }}{% if is_preview %}?preview=true{% endif %}">{{ item.title }}</a>
7
+ <a href="{{ item|node_url }}{% if is_preview %}?preview=true{% endif %}">{{ item.title }}</a>
8
8
  {% else %}
9
9
  <span>{{item.title}}</span>
10
10
  {% endif %}
@@ -22,6 +22,9 @@ def render_menu(
22
22
  menu_key: str,
23
23
  template_path: str = "defaults/parts/menu.html",
24
24
  ):
25
+ if context is not None and not isinstance(context, dict):
26
+ context = context.__dict__
27
+
25
28
  return context.get("menus", Menu.defaultdict())[menu_key].render(
26
29
  template_path=template_path,
27
30
  context=context,
@@ -1 +1 @@
1
- __version__ = "6.0.0-beta.16"
1
+ __version__ = "6.0.0-beta.17"
@@ -1,33 +1,30 @@
1
- from ckeditor_uploader.widgets import CKEditorUploadingWidget
1
+ from tinymce.widgets import TinyMCE
2
2
  from django import forms
3
3
  from django.contrib import admin
4
4
  from django.http import HttpResponse
5
-
6
- from camomilla import settings
7
-
8
- if settings.ENABLE_TRANSLATIONS:
9
- from modeltranslation.admin import (
10
- TabbedTranslationAdmin as TranslationAwareModelAdmin,
11
- )
12
- else:
13
- from django.contrib.admin import ModelAdmin as TranslationAwareModelAdmin
14
-
15
- from camomilla.models import Article, Content, Media, MediaFolder, Page, Tag, Menu
16
-
17
-
18
- class AbstractPageAdmin(TranslationAwareModelAdmin):
19
- change_form_template = "admin/camomilla/page/change_form.html"
5
+ from .pages import AbstractPageModelForm, AbstractPageAdmin
6
+ from .translations import TranslationAwareModelAdmin
7
+ from camomilla.models import (
8
+ Article,
9
+ Content,
10
+ Media,
11
+ MediaFolder,
12
+ Page,
13
+ Tag,
14
+ Menu,
15
+ UrlRedirect,
16
+ )
20
17
 
21
18
 
22
19
  class UserProfileAdmin(admin.ModelAdmin):
23
20
  pass
24
21
 
25
22
 
26
- class ArticleAdminForm(forms.ModelForm):
23
+ class ArticleAdminForm(AbstractPageModelForm):
27
24
  class Meta:
28
25
  model = Article
29
26
  fields = "__all__"
30
- widgets = {"content": CKEditorUploadingWidget}
27
+ widgets = {"content": TinyMCE()}
31
28
 
32
29
 
33
30
  class ArticleAdmin(AbstractPageAdmin):
@@ -47,7 +44,7 @@ class ContentAdminForm(forms.ModelForm):
47
44
  class Meta:
48
45
  model = Content
49
46
  fields = "__all__"
50
- widgets = {"content": CKEditorUploadingWidget}
47
+ widgets = {"content": TinyMCE()}
51
48
 
52
49
 
53
50
  class ContentAdmin(TranslationAwareModelAdmin):
@@ -81,13 +78,17 @@ class MediaAdmin(TranslationAwareModelAdmin):
81
78
 
82
79
 
83
80
  class PageAdmin(AbstractPageAdmin):
84
- readonly_fields = ("permalink",)
81
+ pass
85
82
 
86
83
 
87
84
  class MenuAdmin(TranslationAwareModelAdmin):
88
85
  pass
89
86
 
90
87
 
88
+ class UrlRedirectAdmin(admin.ModelAdmin):
89
+ pass
90
+
91
+
91
92
  admin.site.register(Article, ArticleAdmin)
92
93
  admin.site.register(MediaFolder, MediaFolderAdmin)
93
94
  admin.site.register(Tag, TagAdmin)
@@ -95,3 +96,4 @@ admin.site.register(Content, ContentAdmin)
95
96
  admin.site.register(Media, MediaAdmin)
96
97
  admin.site.register(Page, PageAdmin)
97
98
  admin.site.register(Menu, MenuAdmin)
99
+ admin.site.register(UrlRedirect, UrlRedirectAdmin)
@@ -0,0 +1,46 @@
1
+ from django import forms
2
+ from camomilla import settings
3
+ from .translations import TranslationAwareModelAdmin
4
+ from camomilla.models import UrlNode
5
+
6
+
7
+ class AbstractPageModelFormMeta(forms.models.ModelFormMetaclass):
8
+ def __new__(mcs, name, bases, attrs):
9
+ new_class = super().__new__(mcs, name, bases, attrs)
10
+ fields_to_add = forms.fields_for_model(UrlNode, UrlNode.LANG_PERMALINK_FIELDS)
11
+ if settings.ENABLE_TRANSLATIONS:
12
+ for i, field_name in enumerate(fields_to_add.keys()):
13
+ field_classes = ["mt", f"mt-field-{field_name.replace('_', '-')}"]
14
+ i == 0 and field_classes.append("mt-default")
15
+ fields_to_add[field_name].widget.attrs.update(
16
+ {"class": " ".join(field_classes)}
17
+ )
18
+ new_class.base_fields.update(fields_to_add)
19
+ return new_class
20
+
21
+
22
+ class AbstractPageModelForm(
23
+ forms.models.BaseModelForm, metaclass=AbstractPageModelFormMeta
24
+ ):
25
+
26
+ def get_initial_for_field(self, field, field_name):
27
+ if field_name in UrlNode.LANG_PERMALINK_FIELDS:
28
+ return getattr(self.instance, field_name)
29
+ return super().get_initial_for_field(field, field_name)
30
+
31
+ def save(self, commit: bool = True):
32
+ model = super().save(commit=False)
33
+ for field_name in UrlNode.LANG_PERMALINK_FIELDS:
34
+ if field_name in self.cleaned_data:
35
+ if getattr(model, field_name) != self.cleaned_data[field_name]:
36
+ # sets autopermalink to False if permalink is manually set
37
+ setattr(model, f"auto{field_name}", False)
38
+ setattr(model, field_name, self.cleaned_data[field_name])
39
+ if commit:
40
+ model.save()
41
+ return model
42
+
43
+
44
+ class AbstractPageAdmin(TranslationAwareModelAdmin):
45
+ form = AbstractPageModelForm
46
+ change_form_template = "admin/camomilla/page/change_form.html"
@@ -0,0 +1,13 @@
1
+ from camomilla import settings
2
+
3
+ if settings.ENABLE_TRANSLATIONS:
4
+ from modeltranslation.admin import (
5
+ TabbedTranslationAdmin as TranslationAwareModelAdmin,
6
+ )
7
+ else:
8
+ from django.contrib.admin import ModelAdmin as TranslationAwareModelAdmin
9
+
10
+
11
+ __all__ = [
12
+ "TranslationAwareModelAdmin",
13
+ ]
camomilla/theme/apps.py CHANGED
@@ -29,12 +29,8 @@ class CamomillaThemeConfig(AppConfig):
29
29
  name = "camomilla.theme"
30
30
 
31
31
  def ready(self):
32
- set_default_settings(
33
- CKEDITOR_UPLOAD_PATH="editor-uploads/", X_FRAME_OPTIONS="SAMEORIGIN"
34
- )
35
32
  add_apps(
36
- "ckeditor_uploader",
37
- "ckeditor",
33
+ "tinymce",
38
34
  "django_jsonform",
39
35
  "admin_interface",
40
36
  "colorfield",
camomilla/translation.py CHANGED
@@ -16,7 +16,13 @@ class SeoMixinTranslationOptions(TranslationOptions):
16
16
 
17
17
 
18
18
  class AbstractPageTranslationOptions(SeoMixinTranslationOptions):
19
- fields = ("breadcrumbs_title", "autopermalink", "status", "indexable", "template_data")
19
+ fields = (
20
+ "breadcrumbs_title",
21
+ "autopermalink",
22
+ "status",
23
+ "indexable",
24
+ "template_data",
25
+ )
20
26
 
21
27
 
22
28
  @register(Article)
camomilla/urls.py CHANGED
@@ -1,4 +1,3 @@
1
- from django.shortcuts import redirect
2
1
  from django.urls import include, path
3
2
  from rest_framework import routers
4
3
  from importlib.util import find_spec
@@ -21,6 +20,7 @@ from camomilla.views import (
21
20
  MenuViewSet,
22
21
  )
23
22
  from camomilla.views.pages import fetch_page
23
+ from camomilla.redirects import url_patterns as old_redirects
24
24
 
25
25
  router = routers.DefaultRouter()
26
26
 
@@ -30,18 +30,15 @@ router.register(r"contents", ContentViewSet, "camomilla-content")
30
30
  router.register(r"media", MediaViewSet, "camomilla-media")
31
31
  router.register(r"media-folders", MediaFolderViewSet, "camomilla-media_folders")
32
32
  router.register(r"pages", PageViewSet, "camomilla-pages")
33
- router.register(r"sitemap", PageViewSet, "camomilla-sitemap")
34
33
  router.register(r"users", UserViewSet, "camomilla-users")
35
34
  router.register(r"permissions", PermissionViewSet, "camomilla-permissions")
36
35
  router.register(r"menus", MenuViewSet, "camomilla-menus")
37
36
 
38
37
  urlpatterns = [
38
+ *old_redirects,
39
39
  path("", include(router.urls)),
40
40
  path("pages-router/", fetch_page),
41
41
  path("pages-router/<path:permalink>", fetch_page),
42
- path(
43
- "profiles/me/", lambda _: redirect("../../users/current/"), name="profiles-me"
44
- ),
45
42
  path("token-auth/", CamomillaObtainAuthToken.as_view(), name="api_token"),
46
43
  path("auth/login/", CamomillaAuthLogin.as_view(), name="login"),
47
44
  path("auth/logout/", CamomillaAuthLogout.as_view(), name="logout"),
@@ -1,14 +1,21 @@
1
1
  import re
2
2
  from django.db.models import Q
3
+ from typing import List, Dict, Optional
3
4
 
4
- CONDITION_PATTERN = re.compile(r"(\w+__\w+='[^']+'|\w+__\w+=\S+)") # Updated regex to handle quoted values
5
- LOGICAL_OPERATORS = {"AND", "OR"}
6
5
 
7
6
  class ConditionParser:
8
- def __init__(self, query):
7
+ CONDITION_PATTERN = re.compile(
8
+ r"(\w+__\w+='[^']+'|\w+__\w+=\S+|\w+='[^']+'|\w+=\S+)"
9
+ )
10
+ LOGICAL_OPERATORS = {"AND", "OR"}
11
+
12
+ __db_query: Optional[Q] = None
13
+
14
+ def __init__(self, query: str):
15
+ self.__db_query = None
9
16
  self.query = query
10
-
11
- def parse(self, query=None):
17
+
18
+ def parse(self, query: str = None) -> Dict:
12
19
  """Parse the query or subquery. If no query is provided, use the instance's query."""
13
20
  if query is None:
14
21
  query = self.query
@@ -19,31 +26,31 @@ class ConditionParser:
19
26
  return tokens[0]
20
27
  return self.build_tree(tokens)
21
28
 
22
- def tokenize(self, query):
29
+ def tokenize(self, query: str) -> List:
23
30
  tokens = []
24
31
  i = 0
25
32
  while i < len(query):
26
- if query[i] == '(':
33
+ if query[i] == "(":
27
34
  j = i + 1
28
35
  open_parens = 1
29
36
  while j < len(query) and open_parens > 0:
30
- if query[j] == '(':
37
+ if query[j] == "(":
31
38
  open_parens += 1
32
- elif query[j] == ')':
39
+ elif query[j] == ")":
33
40
  open_parens -= 1
34
41
  j += 1
35
42
  if open_parens == 0:
36
- subquery = query[i + 1:j - 1]
43
+ subquery = query[i + 1 : j - 1]
37
44
  tokens.append(self.parse(subquery)) # Pass the subquery here
38
45
  i = j
39
46
  else:
40
47
  raise ValueError("Mismatched parentheses")
41
- elif query[i:i+3] == 'AND' or query[i:i+2] == 'OR':
42
- operator = 'AND' if query[i:i+3] == 'AND' else 'OR'
48
+ elif query[i : i + 3] == "AND" or query[i : i + 2] == "OR":
49
+ operator = "AND" if query[i : i + 3] == "AND" else "OR"
43
50
  tokens.append(operator)
44
- i += 3 if operator == 'AND' else 2
51
+ i += 3 if operator == "AND" else 2
45
52
  else:
46
- match = CONDITION_PATTERN.match(query[i:])
53
+ match = self.CONDITION_PATTERN.match(query[i:])
47
54
  if match:
48
55
  condition = self.parse_condition(match.group())
49
56
  tokens.append(condition)
@@ -52,10 +59,10 @@ class ConditionParser:
52
59
  i += 1
53
60
  return tokens
54
61
 
55
- def parse_condition(self, condition_str):
62
+ def parse_condition(self, condition: str) -> Optional[Dict]:
56
63
  """Parse a single condition into field lookup and value."""
57
- if '=' in condition_str:
58
- field_lookup, value = condition_str.split("=")
64
+ if "=" in condition:
65
+ field_lookup, value = condition.split("=")
59
66
  value = value.strip("'").strip('"') # Remove single or double quotes
60
67
  value = self.parse_value(value) # Parse the value
61
68
  return {"field_lookup": field_lookup, "value": value}
@@ -71,7 +78,7 @@ class ConditionParser:
71
78
  string = int(string)
72
79
  return string
73
80
 
74
- def build_tree(self, tokens):
81
+ def build_tree(self, tokens: List[str]) -> Dict:
75
82
  """Build a tree-like structure with operators and conditions."""
76
83
  if not tokens:
77
84
  return None
@@ -88,23 +95,26 @@ class ConditionParser:
88
95
  if isinstance(output_stack[-1], dict):
89
96
  output_stack[-1] = {
90
97
  "operator": operator,
91
- "conditions": [output_stack[-1], token]
98
+ "conditions": [output_stack[-1], token],
92
99
  }
93
100
  else:
94
101
  output_stack[-1]["conditions"].append(token)
95
102
  else:
96
103
  output_stack.append(token)
97
104
 
98
- elif token in LOGICAL_OPERATORS:
105
+ elif token in self.LOGICAL_OPERATORS:
99
106
  # Operator found (AND/OR), handle precedence
100
107
  operator_stack.append(token)
101
108
 
102
109
  # If only one item in output_stack, return it directly
103
110
  if len(output_stack) == 1:
104
111
  return output_stack[0]
105
- return {"operator": "AND", "conditions": output_stack} # Default to AND if no operators
112
+ return {
113
+ "operator": "AND",
114
+ "conditions": output_stack,
115
+ } # Default to AND if no operators
106
116
 
107
- def to_q(self, parsed_tree):
117
+ def to_q(self, parsed_tree: Dict) -> Q:
108
118
  """Convert parsed tree structure into Q objects."""
109
119
  if isinstance(parsed_tree, list):
110
120
  # If parsed_tree is a list, combine all conditions with AND by default
@@ -140,9 +150,18 @@ class ConditionParser:
140
150
 
141
151
  raise ValueError("Parsed tree structure is invalid")
142
152
 
143
- def parse_to_q(self):
153
+ def parse_to_q(self) -> Q:
144
154
  """Parse the query and convert to Q object."""
145
155
  parsed_tree = self.parse()
146
156
  if not parsed_tree:
147
157
  return Q() # Return an empty Q if parsing fails
148
158
  return self.to_q(parsed_tree)
159
+
160
+ @property
161
+ def db_query(self) -> Q:
162
+ if self.__db_query is None:
163
+ self.__db_query = self.parse_to_q()
164
+ return self.__db_query
165
+
166
+ def __str__(self) -> str:
167
+ return f"ConditionParser({self.db_query})"
@@ -1,11 +1,12 @@
1
1
  import re
2
- from typing import Any, Sequence, Iterator
2
+ from typing import Any, Sequence, Iterator, Union, List
3
3
 
4
4
  from django.db.models import Model, Q
5
5
  from django.utils.translation.trans_real import activate, get_language
6
6
  from modeltranslation.settings import AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE
7
7
  from modeltranslation.utils import build_localized_fieldname
8
8
  from camomilla.settings import BASE_URL
9
+ from django.http import QueryDict
9
10
 
10
11
 
11
12
  def activate_languages(languages: Sequence[str] = AVAILABLE_LANGUAGES) -> Iterator[str]:
@@ -34,11 +35,9 @@ def get_nofallbacks(instance: Model, attr: str, *args, **kwargs) -> Any:
34
35
 
35
36
  def url_lang_decompose(url):
36
37
  if BASE_URL and url.startswith(BASE_URL):
37
- url = url[len(BASE_URL):]
38
+ url = url[len(BASE_URL) :]
38
39
  data = {"url": url, "permalink": url, "language": DEFAULT_LANGUAGE}
39
- result = re.match(
40
- f"^\/?({'|'.join(AVAILABLE_LANGUAGES)})?\/(.*)", url
41
- ) # noqa: W605
40
+ result = re.match(rf"^/?({'|'.join(AVAILABLE_LANGUAGES)})?/(.*)", url) # noqa: W605
42
41
  groups = result and result.groups()
43
42
  if groups and len(groups) == 2:
44
43
  data["language"] = groups[0]
@@ -70,3 +69,46 @@ def is_translatable(model: Model) -> bool:
70
69
  from modeltranslation.translator import translator
71
70
 
72
71
  return model in translator.get_registered_models()
72
+
73
+
74
+ def plain_to_nest(data, fields, accessor="translations"):
75
+ """
76
+ This function transforms a plain dictionary with translations fields (es. {"title_en": "Hello"})
77
+ into a dictionary with nested translations fields (es. {"translations": {"en": {"title": "Hello"}}}).
78
+ """
79
+ trans_data = {}
80
+ for lang in AVAILABLE_LANGUAGES:
81
+ lang_data = {}
82
+ for field in fields:
83
+ trans_field_name = build_localized_fieldname(field, lang)
84
+ if trans_field_name in data:
85
+ lang_data[field] = data.pop(trans_field_name)
86
+ if lang_data.keys():
87
+ trans_data[lang] = lang_data
88
+ if trans_data.keys():
89
+ data[accessor] = trans_data
90
+ return data
91
+
92
+
93
+ def nest_to_plain(
94
+ data: Union[dict, QueryDict], fields: List[str], accessor="translations"
95
+ ):
96
+ """
97
+ This function is the inverse of plain_to_nest.
98
+ It transforms a dictionary with nested translations fields (es. {"translations": {"en": {"title": "Hello"}}})
99
+ into a plain dictionary with translations fields (es. {"title_en": "Hello"}).
100
+ """
101
+ if isinstance(data, QueryDict):
102
+ data = data.dict()
103
+ translations = data.pop(accessor, {})
104
+ for lang in AVAILABLE_LANGUAGES:
105
+ nest_trans = translations.pop(lang, {})
106
+ for k in fields:
107
+ data.pop(k, None) # this removes all trans field without lang
108
+ if k in nest_trans:
109
+ # this saves on the default field the default language value
110
+ if lang == DEFAULT_LANGUAGE:
111
+ data[k] = nest_trans[k]
112
+ key = build_localized_fieldname(k, lang)
113
+ data[key] = data.get(key, nest_trans[k])
114
+ return data