django-camomilla-cms 6.0.0b14__py2.py3-none-any.whl → 6.0.0b16__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 (51) hide show
  1. camomilla/__init__.py +1 -1
  2. camomilla/dynamic_pages_urls.py +2 -1
  3. camomilla/fields/__init__.py +1 -3
  4. camomilla/managers/pages.py +22 -33
  5. camomilla/model_api.py +9 -4
  6. camomilla/models/media.py +1 -1
  7. camomilla/models/menu.py +23 -11
  8. camomilla/models/page.py +76 -38
  9. camomilla/openapi/schema.py +4 -0
  10. camomilla/serializers/base/__init__.py +3 -1
  11. camomilla/serializers/fields/__init__.py +2 -2
  12. camomilla/serializers/fields/json.py +2 -2
  13. camomilla/serializers/fields/related.py +5 -1
  14. camomilla/serializers/mixins/__init__.py +56 -18
  15. camomilla/serializers/mixins/filter_fields.py +56 -0
  16. camomilla/serializers/utils.py +3 -1
  17. camomilla/serializers/validators.py +9 -5
  18. camomilla/settings.py +0 -4
  19. camomilla/storages/default.py +6 -0
  20. camomilla/storages/optimize.py +2 -2
  21. camomilla/storages/overwrite.py +2 -2
  22. camomilla/templates/defaults/parts/menu.html +1 -1
  23. camomilla/theme/__init__.py +1 -1
  24. camomilla/theme/admin.py +1 -1
  25. camomilla/translation.py +1 -1
  26. camomilla/utils/query_parser.py +148 -0
  27. camomilla/utils/setters.py +37 -0
  28. camomilla/views/base/__init__.py +2 -2
  29. camomilla/views/menus.py +0 -3
  30. camomilla/views/mixins/__init__.py +9 -2
  31. camomilla/views/mixins/pagination.py +4 -13
  32. {django_camomilla_cms-6.0.0b14.dist-info → django_camomilla_cms-6.0.0b16.dist-info}/METADATA +2 -3
  33. {django_camomilla_cms-6.0.0b14.dist-info → django_camomilla_cms-6.0.0b16.dist-info}/RECORD +46 -40
  34. {django_camomilla_cms-6.0.0b14.dist-info → django_camomilla_cms-6.0.0b16.dist-info}/WHEEL +1 -1
  35. tests/fixtures/__init__.py +17 -0
  36. tests/test_api.py +2 -11
  37. tests/test_camomilla_filters.py +7 -13
  38. tests/test_media.py +80 -0
  39. tests/test_model_api.py +68 -0
  40. tests/test_model_api_permissions.py +39 -0
  41. tests/test_query_parser.py +59 -0
  42. tests/test_utils.py +64 -64
  43. tests/utils/__init__.py +0 -0
  44. tests/utils/api.py +29 -0
  45. camomilla/structured/__init__.py +0 -125
  46. camomilla/structured/cache.py +0 -202
  47. camomilla/structured/fields.py +0 -150
  48. camomilla/structured/models.py +0 -47
  49. camomilla/structured/utils.py +0 -114
  50. {django_camomilla_cms-6.0.0b14.dist-info → django_camomilla_cms-6.0.0b16.dist-info}/LICENSE +0 -0
  51. {django_camomilla_cms-6.0.0b14.dist-info → django_camomilla_cms-6.0.0b16.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,56 @@
1
+ from rest_framework import serializers
2
+ from camomilla.serializers.fields.related import RelatedField
3
+ from collections import defaultdict
4
+
5
+
6
+ class FilterFieldsMixin(serializers.ModelSerializer):
7
+ """
8
+ Mixin to filter fields from a serializer, including handling nested fields.
9
+ """
10
+
11
+ def __init__(self, *args, **kwargs):
12
+ self.inherited_fields_filter = kwargs.pop("inherited_fields_filter", [])
13
+ return super().__init__(*args, **kwargs)
14
+
15
+
16
+ inherited_fields_filter = []
17
+
18
+
19
+ def get_default_field_names(self, *args):
20
+ field_names = super().get_default_field_names(*args)
21
+ request = self.context.get("request", None)
22
+
23
+ if request is not None and request.method == "GET":
24
+ fields = request.query_params.get("fields", "").split(",")
25
+ fields = [f for f in fields if f != ""]
26
+ if len(self.inherited_fields_filter) > 0:
27
+ fields = self.inherited_fields_filter
28
+
29
+ self.filtered_fields = set()
30
+ self.childs_fields = defaultdict(set)
31
+ for field in fields:
32
+ if "__" in field:
33
+ parent_field, child_field = field.split("__", 1)
34
+ if parent_field in field_names:
35
+ self.filtered_fields.add(parent_field)
36
+ self.childs_fields[parent_field].add(child_field)
37
+
38
+
39
+ else:
40
+ if field in field_names:
41
+ self.filtered_fields.add(field)
42
+
43
+ if len(self.filtered_fields) > 0:
44
+ return list(self.filtered_fields)
45
+ else:
46
+ return field_names
47
+
48
+ return field_names
49
+
50
+ def build_field(self, field_name, info, model_class, nested_depth):
51
+ field_class, field_kwargs = super().build_field(field_name, info, model_class, nested_depth)
52
+ inherited_fields_filter = self.childs_fields.get(field_name, []) if hasattr(self, "childs_fields") else []
53
+ if len(inherited_fields_filter) > 0 and issubclass(field_class, RelatedField):
54
+ field_kwargs["inherited_fields_filter"] = list(inherited_fields_filter)
55
+ return field_class, field_kwargs
56
+
@@ -1,6 +1,7 @@
1
1
  def get_standard_bases() -> tuple:
2
2
  from rest_framework.serializers import ModelSerializer
3
3
  from camomilla.serializers.fields import FieldsOverrideMixin
4
+ from camomilla.serializers.mixins.filter_fields import FilterFieldsMixin
4
5
  from camomilla.contrib.rest_framework.serializer import RemoveTranslationsMixin
5
6
  from camomilla.serializers.mixins import (
6
7
  JSONFieldPatchMixin,
@@ -10,12 +11,13 @@ def get_standard_bases() -> tuple:
10
11
  )
11
12
 
12
13
  return (
14
+ SetupEagerLoadingMixin,
15
+ FilterFieldsMixin,
13
16
  NestMixin,
14
17
  FieldsOverrideMixin,
15
18
  JSONFieldPatchMixin,
16
19
  OrderingMixin,
17
20
  RemoveTranslationsMixin,
18
- SetupEagerLoadingMixin,
19
21
  ModelSerializer,
20
22
  )
21
23
 
@@ -8,7 +8,7 @@ from camomilla.utils.translation import get_nofallbacks, set_nofallbacks
8
8
 
9
9
 
10
10
  class UniquePermalinkValidator:
11
- message = _("This slug generates a non-unique permalink.")
11
+ message = _("There is an other page with same permalink.")
12
12
 
13
13
  requires_context = True
14
14
 
@@ -29,13 +29,17 @@ class UniquePermalinkValidator:
29
29
  instance, parent_page_field, None
30
30
  )
31
31
  for language in activate_languages():
32
- f_name = build_localized_fieldname("slug", language)
33
- slug = value.get(f_name, instance and get_nofallbacks(instance, "slug"))
32
+ autopermalink_f = build_localized_fieldname("autopermalink", language)
33
+ f_name = build_localized_fieldname("permalink", language)
34
+ permalink = value.get(f_name, instance and get_nofallbacks(instance, "permalink"))
35
+ permalink = UrlNode.sanitize_permalink(permalink)
36
+ autopermalink = value.get(autopermalink_f, instance and get_nofallbacks(instance, "autopermalink"))
37
+ if autopermalink:
38
+ continue
34
39
  fake_instance = serializer.Meta.model()
35
- set_nofallbacks(fake_instance, "slug", slug)
40
+ set_nofallbacks(fake_instance, "permalink", permalink)
36
41
  if parent_page:
37
42
  set_nofallbacks(fake_instance, parent_page_field, parent_page)
38
- permalink = fake_instance.generate_permalink(safe=False)
39
43
  qs = UrlNode.objects.exclude(**exclude_kwargs)
40
44
  if qs.filter(permalink=permalink).exists():
41
45
  errors[f_name] = self.message
camomilla/settings.py CHANGED
@@ -79,10 +79,6 @@ TEMPLATE_CONTEXT_FILES = pointed_getter(
79
79
  django_settings, "CAMOMILLA.RENDER.TEMPLATE_CONTEXT_FILES", []
80
80
  )
81
81
 
82
- STRUCTURED_FIELD_CACHE_ENABLED = pointed_getter(
83
- django_settings, "CAMOMILLA.STRUCTURED_FIELD.CACHE_ENABLED", True
84
- )
85
-
86
82
  DEBUG = pointed_getter(django_settings, "CAMOMILLA.DEBUG", django_settings.DEBUG)
87
83
 
88
84
  # camomilla settings example
@@ -0,0 +1,6 @@
1
+ from django.utils.module_loading import import_string
2
+ from django.conf import settings
3
+
4
+
5
+ def get_default_storage_class():
6
+ return import_string(settings.STORAGES["default"]["BACKEND"])
@@ -2,13 +2,13 @@ import traceback
2
2
  from io import BytesIO
3
3
 
4
4
  from django.core.files.base import ContentFile
5
- from django.core.files.storage import get_storage_class
6
5
  from PIL import Image
7
6
 
8
7
  from camomilla import settings
8
+ from camomilla.storages.default import get_default_storage_class
9
9
 
10
10
 
11
- class OptimizedStorage(get_storage_class()):
11
+ class OptimizedStorage(get_default_storage_class()):
12
12
  MEDIA_MAX_WIDTH = settings.MEDIA_OPTIMIZE_MAX_WIDTH
13
13
  MEDIA_MAX_HEIGHT = settings.MEDIA_OPTIMIZE_MAX_HEIGHT
14
14
  MEDIA_DPI = settings.MEDIA_OPTIMIZE_DPI
@@ -1,7 +1,7 @@
1
- from django.core.files.storage import get_storage_class
1
+ from camomilla.storages.default import get_default_storage_class
2
2
 
3
3
 
4
- class OverwriteStorage(get_storage_class()):
4
+ class OverwriteStorage(get_default_storage_class()):
5
5
  def _save(self, name, content):
6
6
  if self.exists(name):
7
7
  self.delete(name)
@@ -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 }}">{{ item.title }}</a>
7
+ <a href="{{ item.link.url }}{% if is_preview %}?preview=true{% endif %}">{{ item.title }}</a>
8
8
  {% else %}
9
9
  <span>{{item.title}}</span>
10
10
  {% endif %}
@@ -1 +1 @@
1
- __version__ = "6.0.0-beta.14"
1
+ __version__ = "6.0.0-beta.16"
camomilla/theme/admin.py CHANGED
@@ -26,7 +26,7 @@ class UserProfileAdmin(admin.ModelAdmin):
26
26
  class ArticleAdminForm(forms.ModelForm):
27
27
  class Meta:
28
28
  model = Article
29
- exclude = ("slug",)
29
+ fields = "__all__"
30
30
  widgets = {"content": CKEditorUploadingWidget}
31
31
 
32
32
 
camomilla/translation.py CHANGED
@@ -16,7 +16,7 @@ class SeoMixinTranslationOptions(TranslationOptions):
16
16
 
17
17
 
18
18
  class AbstractPageTranslationOptions(SeoMixinTranslationOptions):
19
- fields = ("breadcrumbs_title", "slug", "status", "indexable", "template_data")
19
+ fields = ("breadcrumbs_title", "autopermalink", "status", "indexable", "template_data")
20
20
 
21
21
 
22
22
  @register(Article)
@@ -0,0 +1,148 @@
1
+ import re
2
+ from django.db.models import Q
3
+
4
+ CONDITION_PATTERN = re.compile(r"(\w+__\w+='[^']+'|\w+__\w+=\S+)") # Updated regex to handle quoted values
5
+ LOGICAL_OPERATORS = {"AND", "OR"}
6
+
7
+ class ConditionParser:
8
+ def __init__(self, query):
9
+ self.query = query
10
+
11
+ def parse(self, query=None):
12
+ """Parse the query or subquery. If no query is provided, use the instance's query."""
13
+ if query is None:
14
+ query = self.query
15
+
16
+ tokens = self.tokenize(query)
17
+ # If there's just one token and it's a dictionary (single condition), return it
18
+ if len(tokens) == 1 and isinstance(tokens[0], dict):
19
+ return tokens[0]
20
+ return self.build_tree(tokens)
21
+
22
+ def tokenize(self, query):
23
+ tokens = []
24
+ i = 0
25
+ while i < len(query):
26
+ if query[i] == '(':
27
+ j = i + 1
28
+ open_parens = 1
29
+ while j < len(query) and open_parens > 0:
30
+ if query[j] == '(':
31
+ open_parens += 1
32
+ elif query[j] == ')':
33
+ open_parens -= 1
34
+ j += 1
35
+ if open_parens == 0:
36
+ subquery = query[i + 1:j - 1]
37
+ tokens.append(self.parse(subquery)) # Pass the subquery here
38
+ i = j
39
+ else:
40
+ 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'
43
+ tokens.append(operator)
44
+ i += 3 if operator == 'AND' else 2
45
+ else:
46
+ match = CONDITION_PATTERN.match(query[i:])
47
+ if match:
48
+ condition = self.parse_condition(match.group())
49
+ tokens.append(condition)
50
+ i += match.end()
51
+ else:
52
+ i += 1
53
+ return tokens
54
+
55
+ def parse_condition(self, condition_str):
56
+ """Parse a single condition into field lookup and value."""
57
+ if '=' in condition_str:
58
+ field_lookup, value = condition_str.split("=")
59
+ value = value.strip("'").strip('"') # Remove single or double quotes
60
+ value = self.parse_value(value) # Parse the value
61
+ return {"field_lookup": field_lookup, "value": value}
62
+ return None
63
+
64
+ def parse_value(self, string: str):
65
+ """Parse single condition values based on specific rules."""
66
+ if string and string.startswith("[") and string.endswith("]"):
67
+ string = [self.parse_value(substr) for substr in string[1:-1].split(",")]
68
+ elif string and string.lower() in ["true", "false"]:
69
+ string = string.lower() == "true"
70
+ elif string and string.isdigit():
71
+ string = int(string)
72
+ return string
73
+
74
+ def build_tree(self, tokens):
75
+ """Build a tree-like structure with operators and conditions."""
76
+ if not tokens:
77
+ return None
78
+
79
+ output_stack = []
80
+ operator_stack = []
81
+
82
+ # Process each token in the query
83
+ for token in tokens:
84
+ if isinstance(token, dict):
85
+ # Handle a single condition
86
+ if operator_stack:
87
+ operator = operator_stack.pop()
88
+ if isinstance(output_stack[-1], dict):
89
+ output_stack[-1] = {
90
+ "operator": operator,
91
+ "conditions": [output_stack[-1], token]
92
+ }
93
+ else:
94
+ output_stack[-1]["conditions"].append(token)
95
+ else:
96
+ output_stack.append(token)
97
+
98
+ elif token in LOGICAL_OPERATORS:
99
+ # Operator found (AND/OR), handle precedence
100
+ operator_stack.append(token)
101
+
102
+ # If only one item in output_stack, return it directly
103
+ if len(output_stack) == 1:
104
+ return output_stack[0]
105
+ return {"operator": "AND", "conditions": output_stack} # Default to AND if no operators
106
+
107
+ def to_q(self, parsed_tree):
108
+ """Convert parsed tree structure into Q objects."""
109
+ if isinstance(parsed_tree, list):
110
+ # If parsed_tree is a list, combine all conditions with AND by default
111
+ q_objects = [self.to_q(cond) for cond in parsed_tree]
112
+ combined_q = Q()
113
+ for q_obj in q_objects:
114
+ combined_q &= q_obj
115
+ return combined_q
116
+
117
+ if isinstance(parsed_tree, dict):
118
+ if "field_lookup" in parsed_tree:
119
+ # Base case: a single condition
120
+ return Q(**{parsed_tree["field_lookup"]: parsed_tree["value"]})
121
+
122
+ elif "operator" in parsed_tree and "conditions" in parsed_tree:
123
+ operator = parsed_tree["operator"]
124
+ conditions = parsed_tree["conditions"]
125
+
126
+ q_objects = [self.to_q(cond) for cond in conditions]
127
+
128
+ if operator == "AND":
129
+ combined_q = Q()
130
+ for q_obj in q_objects:
131
+ combined_q &= q_obj
132
+ return combined_q
133
+ elif operator == "OR":
134
+ combined_q = Q()
135
+ for q_obj in q_objects:
136
+ combined_q |= q_obj
137
+ return combined_q
138
+ else:
139
+ raise ValueError(f"Unknown operator: {operator}")
140
+
141
+ raise ValueError("Parsed tree structure is invalid")
142
+
143
+ def parse_to_q(self):
144
+ """Parse the query and convert to Q object."""
145
+ parsed_tree = self.parse()
146
+ if not parsed_tree:
147
+ return Q() # Return an empty Q if parsing fails
148
+ return self.to_q(parsed_tree)
@@ -0,0 +1,37 @@
1
+ from typing import Sequence
2
+ from .getters import pointed_getter
3
+
4
+
5
+ def set_key(data, key, val):
6
+ if isinstance(data, Sequence):
7
+ key = int(key)
8
+ if key < len(data):
9
+ data[key] = val
10
+ else:
11
+ data.append(val)
12
+ return data
13
+ elif isinstance(data, dict):
14
+ data[key] = val
15
+ else:
16
+ setattr(data, key, val)
17
+ return data
18
+
19
+
20
+ def get_key(data, key, default):
21
+ if isinstance(data, Sequence):
22
+ try:
23
+ return data[int(key)]
24
+ except IndexError:
25
+ return default
26
+ return pointed_getter(data, key, default)
27
+
28
+
29
+ def pointed_setter(data, path, value):
30
+ path = path.split(".")
31
+ key = path.pop(0)
32
+ if not len(path):
33
+ return set_key(data, key, value)
34
+ default = [] if path[0].isdigit() else {}
35
+ return set_key(
36
+ data, key, pointed_setter(get_key(data, key, default), ".".join(path), value)
37
+ )
@@ -1,8 +1,8 @@
1
- from ..mixins import OptimViewMixin, PaginateStackMixin, OrderingMixin
1
+ from ..mixins import OptimViewMixin, PaginateStackMixin, OrderingMixin, CamomillaBasePermissionMixin
2
2
  from rest_framework import viewsets
3
3
 
4
4
 
5
5
  class BaseModelViewset(
6
- OptimViewMixin, OrderingMixin, PaginateStackMixin, viewsets.ModelViewSet
6
+ CamomillaBasePermissionMixin, OptimViewMixin, OrderingMixin, PaginateStackMixin, viewsets.ModelViewSet
7
7
  ):
8
8
  pass
camomilla/views/menus.py CHANGED
@@ -13,8 +13,6 @@ from camomilla.serializers.page import BasicUrlNodeSerializer
13
13
  from camomilla.views.base import BaseModelViewset
14
14
  from camomilla.views.decorators import active_lang
15
15
 
16
- from django.utils.translation import get_language
17
-
18
16
 
19
17
  class MenuViewSet(BaseModelViewset):
20
18
  queryset = Menu.objects.all()
@@ -80,5 +78,4 @@ class MenuViewSet(BaseModelViewset):
80
78
  def search_urlnode(self, request, *args, **kwargs):
81
79
  url_node = request.GET.get("q", "")
82
80
  qs = UrlNode.objects.filter(permalink__icontains=url_node).order_by("permalink")
83
- print(get_language())
84
81
  return Response(BasicUrlNodeSerializer(qs, many=True).data)
@@ -30,9 +30,16 @@ class GetUserLanguageMixin(object):
30
30
  return super().initialize_request(request, *args, **kwargs)
31
31
 
32
32
  def get_queryset(self):
33
+ if hasattr(super(), "get_queryset"):
34
+ return super().get_queryset()
33
35
  return self.model.objects.all()
34
36
 
35
37
 
38
+ class CamomillaBasePermissionMixin:
39
+ def get_permissions(self):
40
+ return [*super().get_permissions(), CamomillaBasePermissions()]
41
+
42
+
36
43
  class OptimViewMixin:
37
44
  def get_serializer_class(self):
38
45
  if hasattr(self, "action_serializers"):
@@ -46,8 +53,8 @@ class OptimViewMixin:
46
53
  def get_queryset(self):
47
54
  queryset = super().get_queryset()
48
55
  serializer = self.get_serializer_class()
49
- if hasattr(serializer, "setup_eager_loading"):
50
- queryset = self.get_serializer_class().setup_eager_loading(queryset)
56
+ if hasattr(serializer, "optimize_qs"):
57
+ queryset = serializer.optimize_qs(queryset, context=self.get_serializer_context())
51
58
  return queryset
52
59
 
53
60
 
@@ -2,6 +2,7 @@ from rest_framework.response import Response
2
2
  from django.db.models import Q
3
3
  from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
4
4
  from django.contrib.postgres.search import SearchVector, SearchQuery, TrigramSimilarity
5
+ from camomilla.utils.query_parser import ConditionParser
5
6
 
6
7
 
7
8
  class TrigramSearchMixin:
@@ -32,18 +33,9 @@ class PaginateStackMixin:
32
33
  list_handler, "shared_model", getattr(list_handler, "model", None)
33
34
  )
34
35
 
35
- def parse_qs_value(self, string: str):
36
- if string and string.startswith("[") and string.endswith("]"):
37
- string = [self.parse_qs_value(substr) for substr in string[1:-1].split(",")]
38
- elif string and string.lower() in ["true", "false"]:
39
- string = string.lower() == "true"
40
- elif string and string.isdigit():
41
- string = int(string)
42
- return string
43
-
44
36
  def parse_filter(self, filter):
45
- filter_name, value = filter.split("=")
46
- return filter_name, self.parse_qs_value(value)
37
+ parser = ConditionParser(filter)
38
+ return parser.parse_to_q()
47
39
 
48
40
  def handle_pagination(self, list_handler=None, items_per_page=None):
49
41
  list_handler = list_handler if list_handler is not None else self.get_queryset()
@@ -84,8 +76,7 @@ class PaginateStackMixin:
84
76
  filters = dict(self.request.GET).get("fltr", [])
85
77
  for filter in filters:
86
78
  try:
87
- filter_name, value = self.parse_filter(filter)
88
- list_handler = list_handler.filter(**{filter_name: value})
79
+ list_handler = list_handler.filter(self.parse_filter(filter))
89
80
  except Exception:
90
81
  pass
91
82
  return list_handler
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-camomilla-cms
3
- Version: 6.0.0b14
3
+ Version: 6.0.0b16
4
4
  Summary: Django powered cms
5
5
  Author-email: Lotrèk <dimmitutto@lotrek.it>
6
6
  License: MIT
@@ -20,10 +20,9 @@ Requires-Dist: djangorestframework <4.0.0,>=3.10.0
20
20
  Requires-Dist: django-admin-interface <1.0.0,>=0.26.0
21
21
  Requires-Dist: Pillow <10.0.0,>=6.2.0
22
22
  Requires-Dist: django-ckeditor <7.0.0,>=5.7.1
23
+ Requires-Dist: django-structured-json-field ==0.2.0
23
24
  Requires-Dist: python-magic <0.5,>=0.4
24
- Requires-Dist: django-jsonform ~=2.19.0
25
25
  Requires-Dist: Django >=3.2
26
- Requires-Dist: pydantic ~=2.2.1
27
26
 
28
27
  # camomilla django cms [![PyPI](https://img.shields.io/pypi/v/django-camomilla-cms?style=flat-square)](https://pypi.org/project/django-camomilla-cms) ![Codecov](https://img.shields.io/codecov/c/github/lotrekagency/camomilla?style=flat-square) ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/lotrekagency/camomilla/Test,%20Coverage%20and%20Release?style=flat-square) [![GitHub](https://img.shields.io/github/license/lotrekagency/camomilla?style=flat-square)](./LICENSE)
29
28