django-content-studio 1.0.0a1__py3-none-any.whl → 1.0.0b1.post1__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.
@@ -1,7 +1,7 @@
1
1
  __title__ = "Django Content Studio"
2
- __version__ = "1.0.0-canary.1"
2
+ __version__ = "1.0.0-beta.1"
3
3
  __author__ = "Leon van der Grient"
4
- __license__ = "BSD 3-Clause"
4
+ __license__ = "MIT"
5
5
 
6
6
  # Version synonym
7
7
  VERSION = __version__
content_studio/admin.py CHANGED
@@ -1,53 +1,151 @@
1
- from django.contrib.admin import *
1
+ from typing import Type
2
+
3
+ from django.contrib import admin
4
+ from django.db import models
5
+ from django.db.models import Model
2
6
  from rest_framework.request import HttpRequest
3
7
 
4
- from .dashboard import Dashboard
8
+ from . import widgets, formats
5
9
  from .form import FormSet, FormSetGroup
6
10
  from .login_backends import LoginBackendManager
7
11
  from .token_backends import TokenBackendManager
12
+ from .utils import get_related_field_name
13
+
14
+ register = admin.register
15
+
16
+
17
+ class StackedInline(admin.StackedInline):
18
+ pass
19
+
8
20
 
21
+ class TabularInline(admin.TabularInline):
22
+ pass
9
23
 
10
- class AdminSite(AdminSite):
24
+
25
+ class AdminSite(admin.AdminSite):
11
26
  """
12
- Enhanced admin site for Django Content Studio and integration with
13
- Django Content Framework.
27
+ Enhanced admin site for Django Content Studio.
14
28
  """
15
29
 
16
30
  token_backend = TokenBackendManager()
17
31
 
18
32
  login_backend = LoginBackendManager()
19
33
 
20
- dashboard = Dashboard()
21
-
22
- def __init__(self, *args, **kwargs):
23
- super().__init__(*args, **kwargs)
34
+ dashboard = None
35
+
36
+ model_groups = None
37
+
38
+ default_widget_mapping = {
39
+ models.CharField: widgets.InputWidget,
40
+ models.IntegerField: widgets.InputWidget,
41
+ models.SmallIntegerField: widgets.InputWidget,
42
+ models.BigIntegerField: widgets.InputWidget,
43
+ models.PositiveIntegerField: widgets.InputWidget,
44
+ models.PositiveSmallIntegerField: widgets.InputWidget,
45
+ models.PositiveBigIntegerField: widgets.InputWidget,
46
+ models.FloatField: widgets.InputWidget,
47
+ models.DecimalField: widgets.InputWidget,
48
+ models.SlugField: widgets.SlugWidget,
49
+ models.TextField: widgets.TextAreaWidget,
50
+ models.BooleanField: widgets.CheckboxWidget,
51
+ models.NullBooleanField: widgets.CheckboxWidget,
52
+ models.ForeignKey: widgets.ForeignKeyWidget,
53
+ models.ManyToManyField: widgets.ManyToManyWidget,
54
+ models.OneToOneField: widgets.ForeignKeyWidget,
55
+ models.DateField: widgets.DateWidget,
56
+ models.DateTimeField: widgets.DateTimeWidget,
57
+ models.TimeField: widgets.TimeWidget,
58
+ models.JSONField: widgets.JSONWidget,
59
+ # Common third-party fields
60
+ "AutoSlugField": widgets.SlugWidget,
61
+ }
62
+
63
+ default_format_mapping = {
64
+ models.CharField: formats.TextFormat,
65
+ models.IntegerField: formats.NumberFormat,
66
+ models.SmallIntegerField: formats.NumberFormat,
67
+ models.BigIntegerField: formats.NumberFormat,
68
+ models.PositiveIntegerField: formats.NumberFormat,
69
+ models.PositiveSmallIntegerField: formats.NumberFormat,
70
+ models.PositiveBigIntegerField: formats.NumberFormat,
71
+ models.FloatField: formats.NumberFormat,
72
+ models.DecimalField: formats.NumberFormat,
73
+ models.SlugField: formats.TextFormat,
74
+ models.TextField: formats.TextFormat,
75
+ models.BooleanField: formats.BooleanFormat,
76
+ models.NullBooleanField: formats.BooleanFormat,
77
+ models.DateField: formats.DateFormat,
78
+ models.DateTimeField: formats.DateTimeFormat,
79
+ models.TimeField: formats.TimeFormat,
80
+ models.ForeignKey: formats.ForeignKeyFormat,
81
+ models.OneToOneField: formats.ForeignKeyFormat,
82
+ models.JSONField: formats.JSONFormat,
83
+ }
84
+
85
+ def setup(self):
24
86
  # Add token backend's view set to the
25
87
  # Content Studio router.
26
88
  self.token_backend.set_up_router()
27
89
  # Add login backend's view set to the
28
90
  # Content Studio router.
29
91
  self.login_backend.set_up_router()
92
+ # Add dashboard's view set to the
93
+ # Content Studio router.
94
+ if self.dashboard:
95
+ self.dashboard.set_up_router()
96
+
97
+ def get_thumbnail(self, obj) -> str:
98
+ """
99
+ Method for getting and manipulating the image path (or URL).
100
+ By default, this returns the image path as is.
101
+ """
102
+ return obj.file.url
30
103
 
31
104
 
32
105
  admin_site = AdminSite()
33
106
 
34
107
 
35
- class ModelAdmin(ModelAdmin):
108
+ class ModelAdmin(admin.ModelAdmin):
36
109
  """
37
110
  Enhanced model admin for Django Content Studio and integration with
38
111
  Django Content Framework. Although it's relatively backwards compatible,
39
112
  some default behavior has been changed.
40
113
  """
41
114
 
115
+ # Whether the model is a singleton and should not show
116
+ # the list view.
117
+ is_singleton = False
118
+
119
+ # Override the widget used for certain fields by adding
120
+ # a map of field to widget. Fields that are not included
121
+ # will fall back to their default widget.
122
+ #
123
+ # @example
124
+ # widget_mapping = {'is_published': widgets.SwitchWidget}
125
+ widget_mapping = None
126
+
127
+ # Override the format used for certain fields by adding
128
+ # a map of field to format. Fields that are not included
129
+ # will fall back to their default format.
130
+ #
131
+ # @example
132
+ # format_mapping = {'file_size': widgets.FileSizeWidget}
133
+ format_mapping = None
134
+
42
135
  # We set a lower limit than Django's default of 100
43
136
  list_per_page = 20
44
137
 
138
+ # Description shown below model name on list pages
139
+ list_description = ""
140
+
45
141
  # Configure the main section in the edit-view.
46
142
  edit_main: list[type[FormSetGroup | FormSet | str]] = []
47
143
 
48
144
  # Configure the sidebar in the edit-view.
49
145
  edit_sidebar: list[type[FormSet | str]] = []
50
146
 
147
+ icon = None
148
+
51
149
  def save_model(self, request, obj, form, change):
52
150
  if hasattr(obj, "edited_by"):
53
151
  obj.edited_by = request.user
@@ -88,14 +186,38 @@ class AdminSerializer:
88
186
 
89
187
  def serialize(self, request: HttpRequest):
90
188
  admin_class = self.admin_class
189
+ format_mapping = getattr(admin_class, "format_mapping", None) or {}
190
+ widget_mapping = getattr(admin_class, "widget_mapping", None) or {}
91
191
 
92
192
  return {
193
+ "icon": getattr(admin_class, "icon", None),
194
+ "is_singleton": getattr(admin_class, "is_singleton", False),
93
195
  "edit": {
94
196
  "main": self.serialize_edit_main(request),
95
197
  "sidebar": self.serialize_edit_sidebar(request),
198
+ "inlines": [
199
+ {
200
+ "model": inline.model._meta.label_lower,
201
+ "fk_name": get_related_field_name(inline, admin_class.model),
202
+ "list_display": getattr(inline, "list_display", None)
203
+ or ["__str__"],
204
+ }
205
+ for inline in admin_class.inlines
206
+ ],
96
207
  },
97
208
  "list": {
98
209
  "per_page": admin_class.list_per_page,
210
+ "description": getattr(admin_class, "list_description", ""),
211
+ "display": admin_class.list_display,
212
+ "search": len(admin_class.search_fields) > 0,
213
+ "filter": admin_class.list_filter,
214
+ "sortable_by": admin_class.sortable_by,
215
+ },
216
+ "widget_mapping": {
217
+ field: widget.serialize() for field, widget in widget_mapping.items()
218
+ },
219
+ "format_mapping": {
220
+ field: format.serialize() for field, format in format_mapping.items()
99
221
  },
100
222
  "permissions": {
101
223
  "add_permission": admin_class.has_add_permission(request),
@@ -152,3 +274,25 @@ class AdminSerializer:
152
274
  return edit_sidebar
153
275
 
154
276
  return [FormSet(fields=edit_sidebar)]
277
+
278
+
279
+ class ModelGroup:
280
+ name = None
281
+ label = None
282
+ icon = None
283
+ color = None
284
+ models = None
285
+
286
+ def __init__(
287
+ self,
288
+ name: str,
289
+ label: str = None,
290
+ icon: str = None,
291
+ color: str = None,
292
+ models: list[Type[Model]] = None,
293
+ ):
294
+ self.name = name
295
+ self.label = label or name.capitalize()
296
+ self.icon = icon
297
+ self.color = color
298
+ self.models = models or []
content_studio/apps.py CHANGED
@@ -1,9 +1,10 @@
1
1
  from django.apps import AppConfig
2
2
  from django.contrib import admin
3
- from rest_framework import serializers
4
3
 
5
- from headless.utils import is_runserver
6
4
  from . import VERSION
5
+ from .paginators import ContentPagination
6
+ from .settings import cs_settings
7
+ from .utils import is_runserver
7
8
 
8
9
 
9
10
  class DjangoContentStudioConfig(AppConfig):
@@ -29,31 +30,71 @@ class DjangoContentStudioConfig(AppConfig):
29
30
  ":white_check_mark:",
30
31
  f"[green]Found {registered_models} admin models[/green]",
31
32
  )
33
+ # Set up admin site routes
34
+ admin_site = cs_settings.ADMIN_SITE
35
+ admin_site.setup()
36
+
37
+ # Set up content CRUD APIs
32
38
  self._create_crud_api()
39
+
33
40
  log("\n")
34
41
 
35
42
  def _create_crud_api(self):
36
- from .viewsets import BaseModelViewSet
37
- from .router import content_studio_router
38
43
  from .utils import log
39
44
 
40
- for _model, admin_model in admin.site._registry.items():
41
-
42
- class Serializer(serializers.ModelSerializer):
43
- class Meta:
44
- model = _model
45
- fields = "__all__"
45
+ for model, admin_model in admin.site._registry.items():
46
+ self._create_view_set(model, admin_model)
46
47
 
47
- class ViewSet(BaseModelViewSet):
48
- serializer_class = Serializer
49
- queryset = _model.objects.all()
48
+ for inline in admin_model.inlines:
49
+ self._create_view_set(
50
+ parent=model, model=inline.model, admin_model=inline
51
+ )
50
52
 
51
- content_studio_router.register(
52
- f"api/content/{_model._meta.label_lower}",
53
- ViewSet,
54
- f"content_studio_api-{_model._meta.label_lower}",
55
- )
56
53
  log(
57
54
  ":white_check_mark:",
58
55
  f"[green]Created CRUD API[/green]",
59
56
  )
57
+
58
+ def _create_view_set(self, model, admin_model, parent=None):
59
+ from .viewsets import BaseModelViewSet
60
+ from .router import content_studio_router
61
+ from .serializers import ContentSerializer
62
+
63
+ class Pagination(ContentPagination):
64
+ page_size = getattr(admin_model, "list_per_page", 10)
65
+
66
+ class ViewSet(BaseModelViewSet):
67
+ _model = model
68
+ _admin_model = admin_model
69
+ is_singleton = getattr(admin_model, "is_singleton", False)
70
+ pagination_class = Pagination
71
+ queryset = _model.objects.all()
72
+ search_fields = list(getattr(_admin_model, "search_fields", []))
73
+
74
+ def get_serializer_class(self):
75
+ # For list views we include the specified list_display fields.
76
+ if self.action == "list" and not self.is_singleton:
77
+ available_fields = [
78
+ "id",
79
+ "__str__",
80
+ ] + list(getattr(self._admin_model, "list_display", []))
81
+ # In all other cases we include all fields.
82
+ else:
83
+ available_fields = "__all__"
84
+
85
+ class Serializer(ContentSerializer):
86
+
87
+ class Meta:
88
+ model = self._model
89
+ fields = available_fields
90
+
91
+ return Serializer
92
+
93
+ if parent:
94
+ prefix = f"api/inlines/{parent._meta.label_lower}/{model._meta.label_lower}"
95
+ basename = f"content_studio_api-{parent._meta.label_lower}-{model._meta.label_lower}"
96
+ else:
97
+ prefix = f"api/content/{model._meta.label_lower}"
98
+ basename = f"content_studio_api-{model._meta.label_lower}"
99
+
100
+ content_studio_router.register(prefix, ViewSet, basename)
@@ -0,0 +1,64 @@
1
+ from rest_framework.decorators import action
2
+ from rest_framework.exceptions import NotFound
3
+ from rest_framework.parsers import JSONParser
4
+ from rest_framework.renderers import JSONRenderer
5
+ from rest_framework.response import Response
6
+ from rest_framework.viewsets import ViewSet
7
+
8
+ from content_studio.settings import cs_settings
9
+
10
+
11
+ class Dashboard:
12
+ """
13
+ The Dashboard class is used to define the structure of the dashboard
14
+ in Django Content Studio.
15
+ """
16
+
17
+ widgets = None
18
+
19
+ def __init__(self, **kwargs):
20
+ self.widgets = kwargs.get("widgets", [])
21
+
22
+ def set_up_router(self):
23
+ from content_studio.router import content_studio_router
24
+
25
+ content_studio_router.register(
26
+ "api/dashboard",
27
+ DashboardViewSet,
28
+ basename="content_studio_dashboard",
29
+ )
30
+
31
+ def serialize(self):
32
+ return {
33
+ "widgets": [
34
+ {"name": w.__class__.__name__, "col_span": getattr(w, "col_span", 1)}
35
+ for w in self.widgets
36
+ ]
37
+ }
38
+
39
+
40
+ class DashboardViewSet(ViewSet):
41
+ parser_classes = [JSONParser]
42
+ renderer_classes = [JSONRenderer]
43
+
44
+ def __init__(self, *args, **kwargs):
45
+ super(ViewSet, self).__init__()
46
+ admin_site = cs_settings.ADMIN_SITE
47
+
48
+ self.dashboard = admin_site.dashboard
49
+ self.authentication_classes = [
50
+ admin_site.token_backend.active_backend.authentication_class
51
+ ]
52
+
53
+ @action(detail=False, url_path="(?P<name>[^/.]+)")
54
+ def get(self, request, name=None):
55
+ widget = None
56
+
57
+ for w in self.dashboard.widgets:
58
+ if name == w.__class__.__name__.lower():
59
+ widget = w
60
+
61
+ if not widget:
62
+ raise NotFound()
63
+
64
+ return Response(data=widget.get_data(request))
@@ -0,0 +1,34 @@
1
+ from django.contrib.admin.models import LogEntry
2
+ from rest_framework import serializers
3
+
4
+ from content_studio.serializers import ContentSerializer
5
+
6
+
7
+ class LogEntrySerializer(ContentSerializer):
8
+ object_model = serializers.SerializerMethodField()
9
+
10
+ class Meta:
11
+ model = LogEntry
12
+ fields = [
13
+ "id",
14
+ "action_flag",
15
+ "action_time",
16
+ "user",
17
+ "object_id",
18
+ "object_repr",
19
+ "object_model",
20
+ ]
21
+
22
+ def get_object_model(self, obj):
23
+ return f"{obj.content_type.app_label}.{obj.content_type.model}"
24
+
25
+
26
+ class ActivityLogWidget:
27
+ """
28
+ Widget for showing activity logs.
29
+ """
30
+
31
+ col_span = 2
32
+
33
+ def get_data(self, request):
34
+ return LogEntrySerializer(LogEntry.objects.all()[0:5], many=True).data
@@ -0,0 +1,124 @@
1
+ from decimal import Decimal
2
+
3
+ from django.db import models
4
+ from rest_framework.exceptions import ParseError
5
+ from rest_framework.filters import BaseFilterBackend
6
+
7
+ from .utils import flatten
8
+
9
+
10
+ class LookupFilter(BaseFilterBackend):
11
+ """
12
+ A permissive filter backend that basically allows every supported lookup
13
+ for a given field. Automatically handles multi-value lookups and booleans.
14
+ Also supports exclusion.
15
+ """
16
+
17
+ MULTI_VALUE_LOOKUPS = ["in", "range"]
18
+
19
+ EXCLUDE_SYMBOL = "~"
20
+
21
+ NON_FILTER_FIELDS = [
22
+ "search",
23
+ "limit",
24
+ "page",
25
+ ]
26
+
27
+ def filter_queryset(self, request, queryset, view):
28
+ """
29
+ Build the queryset based on the query params and the view's model.
30
+ Only apply filters in list endpoints.
31
+ """
32
+ if getattr(view, "action", None) == "list":
33
+ try:
34
+ filter_kwargs, exclude_kwargs = self.get_filter_kwargs(
35
+ model_class=view.queryset.model,
36
+ query_params=request.query_params,
37
+ )
38
+ except Exception as e:
39
+ print(e)
40
+ raise ParseError(detail="Invalid filter parameters")
41
+ return queryset.filter(**filter_kwargs).exclude(**exclude_kwargs).distinct()
42
+
43
+ return queryset
44
+
45
+ def get_filter_kwargs(self, model_class, query_params):
46
+ filter_kwargs = {}
47
+ exclude_kwargs = {}
48
+
49
+ field_lookups = self.get_field_lookups(model_class=model_class)
50
+
51
+ for key, value in query_params.lists():
52
+ if key in self.NON_FILTER_FIELDS:
53
+ continue
54
+ # By default Django supports repeated multi-values (e.g. `a=1&a=2`)
55
+ # but we allow for comma-seperated multi-values as well (e.g. `a=1,2`).
56
+ value = flatten([param.split(",") for param in value])
57
+ is_exclude = key.startswith(self.EXCLUDE_SYMBOL)
58
+ # The first part of a key is considered the field name
59
+ field_name = key.split("__")[0]
60
+ # Get the model field.
61
+ field = model_class._meta.get_field(field_name)
62
+ # Get the allowed lookups for this field.
63
+ lookups = field_lookups.get(field_name, [])
64
+ try:
65
+ # The last part of a key is considered its lookup
66
+ # but it's not required.
67
+ lookup = key.split("__")[-1]
68
+ if lookup not in lookups:
69
+ lookup = None
70
+ except IndexError:
71
+ lookup = None
72
+
73
+ # Some lookups allow multiple values, otherwise
74
+ # the first value is used.
75
+ is_multi = lookup in self.MULTI_VALUE_LOOKUPS
76
+ # Depending on the field type we cast the value to
77
+ # its correct type (i.e. number, boolean, etc.).
78
+ if is_multi:
79
+ casted_value = [self.cast_field_value(v, field) for v in value]
80
+ elif lookup == "isnull":
81
+ casted_value = value[0] in ["1", "true", "on"]
82
+ else:
83
+ casted_value = self.cast_field_value(value[0], field)
84
+
85
+ if is_exclude:
86
+ exclude_kwargs[key] = casted_value
87
+ else:
88
+ filter_kwargs[key] = casted_value
89
+
90
+ return filter_kwargs, exclude_kwargs
91
+
92
+ @staticmethod
93
+ def get_field_lookups(model_class):
94
+ """
95
+ Allow all supported lookups.
96
+ """
97
+ field_lookups = {}
98
+ for model_field in model_class._meta.get_fields():
99
+ lookup_list = model_field.get_lookups().keys()
100
+ field_lookups[model_field.name] = lookup_list
101
+ return field_lookups
102
+
103
+ def cast_field_value(self, value: str, field):
104
+ value = value.strip().lower()
105
+
106
+ if isinstance(field, models.BooleanField):
107
+ if value in ["1", "true", "on"]:
108
+ return True
109
+ if value in ["0", "false", "off"]:
110
+ return False
111
+ if isinstance(field, models.NullBooleanField):
112
+ if value in ["null", "none", "empty"]:
113
+ return None
114
+
115
+ if isinstance(field, models.IntegerField):
116
+ return int(value)
117
+
118
+ if isinstance(field, models.DecimalField):
119
+ return Decimal(value)
120
+
121
+ if isinstance(field, models.FloatField):
122
+ return float(value)
123
+
124
+ return value
content_studio/form.py CHANGED
@@ -1,16 +1,23 @@
1
+ ###
2
+ # Form field classes are used for grouping, ordering and laying out fields.
3
+ ###
4
+
5
+
1
6
  class Field:
2
7
  """
3
8
  Field class for configuring the fields in content edit views in Django Content Studio.
4
9
  """
5
10
 
6
- def __init__(self, name: str, col_span: int = 1):
11
+ def __init__(self, name: str, col_span: int = 1, label: str = None):
7
12
  self.name = name
8
13
  self.col_span = col_span
14
+ self.label = label
9
15
 
10
16
  def serialize(self):
11
17
  return {
12
18
  "name": self.name,
13
19
  "col_span": self.col_span,
20
+ "label": self.label,
14
21
  }
15
22
 
16
23
 
@@ -20,18 +27,23 @@ class FieldLayout:
20
27
  """
21
28
 
22
29
  def __init__(self, fields: list[str | Field] = None, columns: int = 1):
23
- self.fields = fields or []
30
+ self.fields = [self._normalize_field(f) for f in fields] if fields else []
24
31
  self.columns = columns
25
32
 
26
33
  def serialize(self):
27
34
  return {
28
- "fields": [
29
- field.serialize() if isinstance(field, Field) else field
30
- for field in self.fields
31
- ],
35
+ "fields": [field.serialize() for field in self.fields],
32
36
  "columns": self.columns,
33
37
  }
34
38
 
39
+ def _normalize_field(self, field):
40
+ if isinstance(field, str):
41
+ return Field(field)
42
+ elif isinstance(field, Field):
43
+ return field
44
+ else:
45
+ raise ValueError(f"Invalid field: {field}. Must be a string or Field.")
46
+
35
47
 
36
48
  class FormSet:
37
49
  """
@@ -47,18 +59,27 @@ class FormSet:
47
59
  ):
48
60
  self.title = title
49
61
  self.description = description
50
- self.fields = fields or []
62
+ self.fields = [self._normalize_field(f) for f in fields] if fields else []
51
63
 
52
64
  def serialize(self):
53
65
  return {
54
66
  "title": self.title,
55
67
  "description": self.description,
56
- "fields": [
57
- field.serialize() if isinstance(field, (Field, FieldLayout)) else field
58
- for field in self.fields
59
- ],
68
+ "fields": [field.serialize() for field in self.fields],
60
69
  }
61
70
 
71
+ def _normalize_field(self, field):
72
+ if isinstance(field, str):
73
+ return Field(field)
74
+ elif isinstance(field, Field):
75
+ return field
76
+ elif isinstance(field, FieldLayout):
77
+ return field
78
+ else:
79
+ raise ValueError(
80
+ f"Invalid field: {field}. Must be a string, Field or FieldLayout."
81
+ )
82
+
62
83
 
63
84
  class FormSetGroup:
64
85
  """