django-content-studio 1.0.0a1__py3-none-any.whl → 1.0.0b1__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.
@@ -0,0 +1,59 @@
1
+ ###
2
+ # Formats determine how fields are displayed in Django Content Studio.
3
+ # Each Django model field has a default format, but this can be
4
+ # overridden in the admin model.
5
+ ###
6
+
7
+
8
+ class BaseFormat:
9
+ @classmethod
10
+ def serialize(cls):
11
+ return {"name": cls.__name__}
12
+
13
+
14
+ class ForeignKeyFormat(BaseFormat):
15
+ pass
16
+
17
+
18
+ class MediaFormat(BaseFormat):
19
+ pass
20
+
21
+
22
+ class TextFormat(BaseFormat):
23
+ pass
24
+
25
+
26
+ class HtmlFormat(BaseFormat):
27
+ pass
28
+
29
+
30
+ class BooleanFormat(BaseFormat):
31
+ pass
32
+
33
+
34
+ class DateFormat(BaseFormat):
35
+ pass
36
+
37
+
38
+ class DateTimeFormat(BaseFormat):
39
+ pass
40
+
41
+
42
+ class TimeFormat(BaseFormat):
43
+ pass
44
+
45
+
46
+ class NumberFormat(BaseFormat):
47
+ pass
48
+
49
+
50
+ class FileSizeFormat(BaseFormat):
51
+ pass
52
+
53
+
54
+ class TagFormat(BaseFormat):
55
+ pass
56
+
57
+
58
+ class JSONFormat(BaseFormat):
59
+ pass
@@ -29,12 +29,15 @@ class UsernamePasswordViewSet(ViewSet):
29
29
  password=serializer.validated_data["password"],
30
30
  )
31
31
 
32
- if user is None or not user.is_active:
33
- raise PermissionDenied()
32
+ if user is None:
33
+ raise PermissionDenied(detail="Invalid username or password.")
34
+
35
+ if not user.is_active:
36
+ raise PermissionDenied(detail="User account is disabled.")
34
37
 
35
38
  from ..admin import admin_site
36
39
 
37
- return admin_site.auth_backend.active_backend.get_response_for_user(user)
40
+ return admin_site.token_backend.active_backend.get_response_for_user(user)
38
41
 
39
42
 
40
43
  class UsernamePasswordBackend:
@@ -0,0 +1,25 @@
1
+ from rest_framework import serializers
2
+
3
+ from content_studio.settings import cs_settings
4
+
5
+
6
+ class MediaItemSerializer(serializers.ModelSerializer):
7
+ thumbnail = serializers.SerializerMethodField()
8
+
9
+ class Meta:
10
+ model = cs_settings.MEDIA_LIBRARY_MODEL
11
+ fields = "__all__"
12
+
13
+ def get_thumbnail(self, obj):
14
+ admin_site = cs_settings.ADMIN_SITE
15
+
16
+ if admin_site and obj.type == "image":
17
+ return admin_site.get_thumbnail(obj)
18
+
19
+ return None
20
+
21
+
22
+ class MediaFolderSerializer(serializers.ModelSerializer):
23
+ class Meta:
24
+ model = cs_settings.MEDIA_LIBRARY_FOLDER_MODEL
25
+ fields = ["id", "name", "parent"]
@@ -0,0 +1,132 @@
1
+ from rest_framework import status, exceptions
2
+ from rest_framework.decorators import action
3
+ from rest_framework.filters import OrderingFilter, SearchFilter
4
+ from rest_framework.parsers import JSONParser, MultiPartParser
5
+ from rest_framework.permissions import DjangoModelPermissions
6
+ from rest_framework.renderers import JSONRenderer
7
+ from rest_framework.response import Response
8
+ from rest_framework.viewsets import ModelViewSet
9
+
10
+ from content_studio.paginators import ContentPagination
11
+ from content_studio.settings import cs_settings
12
+ from .serializers import MediaItemSerializer, MediaFolderSerializer
13
+
14
+
15
+ class MediaLibraryViewSet(ModelViewSet):
16
+ _media_model = None
17
+ lookup_field = "id"
18
+ parser_classes = [JSONParser, MultiPartParser]
19
+ renderer_classes = [JSONRenderer]
20
+ permission_classes = [DjangoModelPermissions]
21
+ filter_backends = [SearchFilter, OrderingFilter]
22
+ pagination_class = ContentPagination
23
+
24
+ def __init__(self, *args, **kwargs):
25
+ super(MediaLibraryViewSet, self).__init__()
26
+
27
+ admin_site = cs_settings.ADMIN_SITE
28
+
29
+ self.authentication_classes = [
30
+ admin_site.token_backend.active_backend.authentication_class
31
+ ]
32
+
33
+ def get_queryset(self):
34
+ if not self._media_model:
35
+ self._media_model = cs_settings.MEDIA_LIBRARY_MODEL
36
+
37
+ if not self._media_model:
38
+ raise exceptions.MethodNotAllowed(
39
+ method="GET", detail="Media model not defined."
40
+ )
41
+
42
+ folder = self.request.query_params.get("folder", None)
43
+ qs = self._media_model.objects.all()
44
+
45
+ if folder:
46
+ if folder == "root":
47
+ return qs.filter(folder__isnull=True)
48
+ return qs.filter(folder=folder)
49
+
50
+ return qs
51
+
52
+ def get_serializer_class(self):
53
+ if self._media_model:
54
+ return MediaItemSerializer
55
+
56
+ raise exceptions.MethodNotAllowed(
57
+ method="GET", detail="Media model not defined."
58
+ )
59
+
60
+
61
+ class MediaFolderViewSet(ModelViewSet):
62
+ _folder_model = None
63
+ lookup_field = "id"
64
+ parser_classes = [JSONParser]
65
+ renderer_classes = [JSONRenderer]
66
+ permission_classes = [DjangoModelPermissions]
67
+ filter_backends = [SearchFilter, OrderingFilter]
68
+ pagination_class = ContentPagination
69
+
70
+ def __init__(self, *args, **kwargs):
71
+ super(MediaFolderViewSet, self).__init__()
72
+
73
+ admin_site = cs_settings.ADMIN_SITE
74
+
75
+ self.authentication_classes = [
76
+ admin_site.token_backend.active_backend.authentication_class
77
+ ]
78
+
79
+ def get_queryset(self):
80
+ if not self._folder_model:
81
+ self._folder_model = cs_settings.MEDIA_LIBRARY_FOLDER_MODEL
82
+
83
+ if not self._folder_model:
84
+ raise exceptions.MethodNotAllowed(
85
+ method="GET", detail="Media folder model not defined."
86
+ )
87
+
88
+ parent = self.request.query_params.get("parent", None)
89
+ qs = self._folder_model.objects.all()
90
+
91
+ if self.action != "list":
92
+ return qs
93
+
94
+ # The list endpoint is always within the scope of a folder
95
+ if not parent:
96
+ return qs.filter(parent__isnull=True)
97
+ return qs.filter(parent=parent)
98
+
99
+ def get_serializer_class(self):
100
+ if self._folder_model:
101
+ return MediaFolderSerializer
102
+
103
+ raise exceptions.MethodNotAllowed(
104
+ method="GET", detail="Media folder model not defined."
105
+ )
106
+
107
+ @action(methods=["get"], detail=False, url_path="path")
108
+ def get(self, request, *args, **kwargs):
109
+
110
+ if not self._folder_model:
111
+ self._folder_model = cs_settings.MEDIA_LIBRARY_FOLDER_MODEL
112
+
113
+ if not self._folder_model:
114
+ raise exceptions.MethodNotAllowed(
115
+ method="GET", detail="Folder model not defined."
116
+ )
117
+
118
+ folder_id = request.query_params.get("folder", None)
119
+
120
+ if not folder_id:
121
+ return Response(data=[])
122
+
123
+ try:
124
+ folder = self._folder_model.objects.get(pk=folder_id)
125
+ path = []
126
+ while folder:
127
+ path.insert(0, folder)
128
+ folder = folder.parent
129
+
130
+ return Response(data=MediaFolderSerializer(path, many=True).data)
131
+ except self._folder_model.DoesNotExist:
132
+ return Response(status=status.HTTP_404_NOT_FOUND)
content_studio/models.py CHANGED
@@ -1,7 +1,5 @@
1
1
  from django.db import models
2
2
 
3
- from content_framework import fields as cf_fields
4
- from . import widgets
5
3
  from .utils import is_jsonable
6
4
 
7
5
 
@@ -9,61 +7,50 @@ class ModelSerializer:
9
7
  def __init__(self, model: type[models.Model]):
10
8
  self.model = model
11
9
 
12
- widgets = {
13
- models.CharField: widgets.InputWidget,
14
- models.IntegerField: widgets.InputWidget,
15
- models.SmallIntegerField: widgets.InputWidget,
16
- models.BigIntegerField: widgets.InputWidget,
17
- models.PositiveIntegerField: widgets.InputWidget,
18
- models.PositiveSmallIntegerField: widgets.InputWidget,
19
- models.PositiveBigIntegerField: widgets.InputWidget,
20
- models.FloatField: widgets.InputWidget,
21
- models.DecimalField: widgets.InputWidget,
22
- models.SlugField: widgets.SlugWidget,
23
- models.TextField: widgets.TextAreaWidget,
24
- models.BooleanField: widgets.BooleanWidget,
25
- models.NullBooleanField: widgets.BooleanWidget,
26
- cf_fields.MultipleChoiceField: widgets.MultipleChoiceWidget,
27
- cf_fields.TagField: widgets.TagWidget,
28
- cf_fields.HTMLField: widgets.RichTextWidget,
29
- cf_fields.URLPathField: widgets.URLPathWidget,
30
- }
31
-
32
10
  def serialize(self):
33
11
  model = self.model
34
12
 
35
13
  return {
36
- "label": model._meta.label,
14
+ "label": model._meta.label_lower,
37
15
  "verbose_name": model._meta.verbose_name,
38
16
  "verbose_name_plural": model._meta.verbose_name_plural,
39
17
  "fields": self.get_fields(),
40
18
  }
41
19
 
42
20
  def get_fields(self):
43
- fields = {}
21
+ fields = {
22
+ "__str__": {
23
+ "type": "CharField",
24
+ "readonly": True,
25
+ }
26
+ }
27
+ standard_fields = self.model._meta.fields
28
+ m2m_fields = self.model._meta.many_to_many
44
29
 
45
- for field in self.model._meta.fields:
30
+ for field in standard_fields + m2m_fields:
46
31
  fields[field.name] = self.get_field(field)
47
32
 
48
33
  return fields
49
34
 
50
35
  def get_field(self, field):
51
- widget = self.get_widget(field)
52
-
53
36
  data = {
54
37
  "verbose_name": field.verbose_name,
55
38
  "required": not field.null or not field.blank,
39
+ "type": field.__class__.__name__,
56
40
  }
57
41
 
42
+ if hasattr(field, "widget_class"):
43
+ data["widget_class"] = field.widget_class.__name__
44
+
45
+ if hasattr(field, "format_class"):
46
+ data["format_class"] = field.format_class.__name__
47
+
58
48
  if field.help_text:
59
49
  data["help_text"] = field.help_text
60
50
 
61
51
  if is_jsonable(field.default):
62
52
  data["default"] = field.default
63
53
 
64
- if widget:
65
- data["widget"] = widget
66
-
67
54
  if not field.editable:
68
55
  data["readonly"] = True
69
56
 
@@ -71,16 +58,16 @@ class ModelSerializer:
71
58
  data["primary_key"] = True
72
59
  data["readonly"] = True
73
60
 
61
+ if field.is_relation:
62
+ data["related_model"] = field.related_model._meta.label_lower
63
+
74
64
  if getattr(field, "choices", None) is not None:
75
65
  data["choices"] = field.choices
76
66
 
77
67
  if getattr(field, "max_length", None) is not None:
78
68
  data["max_length"] = field.max_length
79
69
 
80
- return data
70
+ if getattr(field, "cs_get_field_attributes", None):
71
+ data.update(field.cs_get_field_attributes())
81
72
 
82
- def get_widget(self, field):
83
- try:
84
- return self.widgets[field.__class__].__name__
85
- except KeyError:
86
- return None
73
+ return data
@@ -0,0 +1,20 @@
1
+ from rest_framework.pagination import PageNumberPagination
2
+ from rest_framework.response import Response
3
+
4
+
5
+ class ContentPagination(PageNumberPagination):
6
+ page_size = 20
7
+ page_size_query_param = "limit"
8
+ max_page_size = 100
9
+
10
+ def get_paginated_response(self, data):
11
+ return Response(
12
+ {
13
+ "pagination": {
14
+ "count": self.page.paginator.count,
15
+ "current": self.page.number,
16
+ "pages": self.page.paginator.num_pages,
17
+ },
18
+ "results": data,
19
+ }
20
+ )
@@ -1,16 +1,118 @@
1
+ import inspect
2
+
1
3
  from django.contrib.auth import get_user_model
2
4
  from rest_framework import serializers
5
+ from rest_framework.relations import RelatedField
3
6
 
4
7
  user_model = get_user_model()
5
8
 
6
9
 
7
- class CurrentUserSerializer(serializers.ModelSerializer):
10
+ class ContentRelatedField(RelatedField):
11
+
12
+ def to_representation(self, value):
13
+ from content_studio.settings import cs_settings
14
+
15
+ admin_site = cs_settings.ADMIN_SITE
16
+ data = {"id": value.id, "__str__": str(value)}
17
+
18
+ # Add file URL and media type if the model is a media library model.
19
+ if value.__class__ is cs_settings.MEDIA_LIBRARY_MODEL:
20
+ data["file"] = value.file.url
21
+ data["type"] = value.type
22
+ data["thumbnail"] = admin_site.get_thumbnail(value)
23
+
24
+ return data
25
+
26
+ def to_internal_value(self, data):
27
+ return self.get_queryset().get(id=data["id"])
28
+
29
+
30
+ class ContentSerializer(serializers.ModelSerializer):
31
+ __str__ = serializers.CharField(read_only=True)
32
+ serializer_related_field = ContentRelatedField
33
+
34
+ def get_field_names(self, declared_fields, info):
35
+ """
36
+ Override to automatically add model properties to the fields list.
37
+ """
38
+ # Get the default fields from parent class
39
+ fields = super().get_field_names(declared_fields, info)
40
+
41
+ # If fields is '__all__', we need to expand it
42
+ if fields == "__all__":
43
+ fields = list(super().get_field_names(declared_fields, info))
44
+ else:
45
+ fields = list(fields)
46
+
47
+ # Get all properties from the model
48
+ model_properties = self._get_model_properties()
49
+
50
+ # Add properties that aren't already in fields
51
+ for prop_name in model_properties:
52
+ if prop_name not in fields and prop_name not in declared_fields:
53
+ fields.append(prop_name)
54
+
55
+ return fields
56
+
57
+ def _get_model_properties(self):
58
+ """
59
+ Extract all @property attributes from the model class.
60
+ """
61
+ model = self.Meta.model
62
+ properties = []
63
+
64
+ # Inspect the model class and its parent classes
65
+ for klass in inspect.getmro(model):
66
+ if klass == object:
67
+ break
68
+
69
+ for name, obj in inspect.getmembers(klass):
70
+ # Check if it's a property and not private/protected
71
+ if isinstance(obj, property) and not name.startswith("_"):
72
+ if name not in properties:
73
+ properties.append(name)
74
+
75
+ return properties
76
+
77
+ def build_property_field(self, field_name, model_class):
78
+ """
79
+ Create a ReadOnlyField for property attributes.
80
+ """
81
+ return serializers.ReadOnlyField, {}
82
+
83
+ def build_field(self, field_name, info, model_class, nested_depth):
84
+ """
85
+ Override to handle property fields.
86
+ """
87
+ # Check if this is a property
88
+ if hasattr(model_class, field_name):
89
+ attr = getattr(model_class, field_name)
90
+ if isinstance(attr, property):
91
+ return self.build_property_field(field_name, model_class)
92
+
93
+ # Fall back to default behavior for regular fields
94
+ return super().build_field(field_name, info, model_class, nested_depth)
95
+
96
+
97
+ class SessionUserSerializer(serializers.ModelSerializer):
98
+ username = serializers.CharField(source=user_model.USERNAME_FIELD)
99
+
100
+ class Meta:
101
+ model = user_model
102
+ fields = (
103
+ "id",
104
+ "username",
105
+ "first_name",
106
+ "last_name",
107
+ )
108
+
109
+
110
+ class UserSerializer(serializers.ModelSerializer):
111
+
8
112
  class Meta:
9
113
  model = user_model
10
114
  fields = (
11
115
  "id",
12
- user_model.USERNAME_FIELD,
13
- user_model.EMAIL_FIELD,
14
116
  "first_name",
15
117
  "last_name",
16
118
  )
@@ -21,19 +21,25 @@ from django.utils.module_loading import import_string
21
21
  SETTINGS_NAMESPACE = "CONTENT_STUDIO"
22
22
 
23
23
  DEFAULTS = {
24
+ "ADMIN_SITE": "content_studio.admin.admin_site",
24
25
  "LOGIN_BACKENDS": [
25
26
  "content_studio.login_backends.UsernamePasswordBackend",
26
27
  ],
27
- "EDITED__BY_ATTR": "edited_by",
28
+ "EDITED_BY_ATTR": "edited_by",
28
29
  "EDITED_AT_ATTR": "edited_at",
29
30
  "CREATED_BY_ATTR": "created_by",
30
31
  "CREATED_AT_ATTR": "created_at",
32
+ "MEDIA_LIBRARY_MODEL": None,
33
+ "MEDIA_LIBRARY_FOLDER_MODEL": None,
31
34
  }
32
35
 
33
36
 
34
37
  # List of settings that may be in string import notation.
35
38
  IMPORT_STRINGS = [
39
+ "ADMIN_SITE",
36
40
  "LOGIN_BACKENDS",
41
+ "MEDIA_LIBRARY_MODEL",
42
+ "MEDIA_LIBRARY_FOLDER_MODEL",
37
43
  ]
38
44
 
39
45
 
@@ -8,8 +8,7 @@
8
8
  <title>Django Content Studio</title>
9
9
  <script>
10
10
  window.DCS_STATIC_PREFIX = "{% static '' %}content_studio/";
11
- window.DCS_BASENAME = "{% url 'content_studio' %}";
12
- window.DCS_API_URL = "{% url 'headless_rest' %}";
11
+ window.DCS_BASENAME = "{% url 'content_studio_web' %}";
13
12
  </script>
14
13
  <script type="module" crossorigin src="{% static 'content_studio/assets/index.js' %}"></script>
15
14
  <link rel="stylesheet" crossorigin href="{% static 'content_studio/assets/index.css' %}">
@@ -1,6 +1,10 @@
1
1
  from rest_framework.decorators import action
2
2
  from rest_framework.response import Response
3
3
  from rest_framework.viewsets import ViewSet
4
+ from rest_framework_simplejwt.authentication import JWTAuthentication
5
+ from rest_framework_simplejwt.settings import api_settings as simplejwt_settings
6
+ from rest_framework_simplejwt.tokens import RefreshToken
7
+ from rest_framework_simplejwt.views import TokenRefreshView
4
8
 
5
9
 
6
10
  class SimpleJwtViewSet(ViewSet):
@@ -8,7 +12,6 @@ class SimpleJwtViewSet(ViewSet):
8
12
  detail=False, methods=["post"], permission_classes=[], authentication_classes=[]
9
13
  )
10
14
  def refresh(self, request):
11
- from rest_framework_simplejwt.views import TokenRefreshView
12
15
 
13
16
  view_instance = TokenRefreshView()
14
17
  view_instance.request = request
@@ -18,22 +21,15 @@ class SimpleJwtViewSet(ViewSet):
18
21
 
19
22
  class SimpleJwtBackend:
20
23
  name = "Simple JWT"
21
- authentication_class = None
24
+ authentication_class = JWTAuthentication
22
25
  view_set = SimpleJwtViewSet
23
26
 
24
- def __init__(self):
25
- from rest_framework_simplejwt.authentication import JWTAuthentication
26
-
27
- self.authentication_class = JWTAuthentication
28
-
29
27
  @classmethod
30
28
  def get_info(cls):
31
29
 
32
- from rest_framework_simplejwt.settings import api_settings as simplejwt_settings
33
-
34
30
  return {
35
31
  "type": cls.__name__,
36
- "settings": {
32
+ "config": {
37
33
  "ACCESS_TOKEN_LIFETIME": simplejwt_settings.ACCESS_TOKEN_LIFETIME.total_seconds(),
38
34
  },
39
35
  }
@@ -49,7 +45,6 @@ class SimpleJwtBackend:
49
45
 
50
46
  @classmethod
51
47
  def get_response_for_user(cls, user):
52
- from rest_framework_simplejwt.tokens import RefreshToken
53
48
 
54
49
  refresh = RefreshToken.for_user(user)
55
50
 
content_studio/urls.py CHANGED
@@ -1,12 +1,21 @@
1
1
  from django.urls import re_path
2
2
 
3
+ from .media_library.viewsets import MediaLibraryViewSet, MediaFolderViewSet
3
4
  from .router import content_studio_router
4
5
  from .views import ContentStudioWebAppView, AdminApiViewSet
5
6
 
6
7
  content_studio_router.register("api", AdminApiViewSet, "content_studio_admin")
8
+ content_studio_router.register(
9
+ "api/media-library/items", MediaLibraryViewSet, "content_studio_media_library_items"
10
+ )
11
+ content_studio_router.register(
12
+ "api/media-library/folders",
13
+ MediaFolderViewSet,
14
+ "content_studio_media_library_folders",
15
+ )
7
16
 
8
- urlpatterns = content_studio_router.urls + [
17
+ urlpatterns = [
9
18
  re_path(
10
19
  "^(?!api).*$", ContentStudioWebAppView.as_view(), name="content_studio_web"
11
20
  ),
12
- ]
21
+ ] + content_studio_router.urls
content_studio/utils.py CHANGED
@@ -10,6 +10,10 @@ def log(*args, **kwargs):
10
10
  console.print(*args, **kwargs)
11
11
 
12
12
 
13
+ def flatten(xss):
14
+ return [x for xs in xss for x in xs]
15
+
16
+
13
17
  def is_runserver():
14
18
  """
15
19
  Checks if the Django application is started as a server.
@@ -28,3 +32,31 @@ def is_jsonable(x):
28
32
  return True
29
33
  except (TypeError, OverflowError):
30
34
  return False
35
+
36
+
37
+ def get_related_field_name(inline, parent_model):
38
+ """
39
+ Get the name of the foreign key field in the inline model.
40
+ """
41
+ if inline.fk_name:
42
+ return inline.fk_name
43
+
44
+ # Let Django figure it out
45
+
46
+ opts = inline.model._meta
47
+
48
+ # Find all foreign keys pointing to parent model
49
+ fks = [
50
+ f
51
+ for f in opts.get_fields()
52
+ if f.many_to_one and f.remote_field.model == parent_model
53
+ ]
54
+
55
+ if len(fks) == 1:
56
+ return fks[0].name
57
+ elif len(fks) == 0:
58
+ raise ValueError(
59
+ f"No foreign key found in {inline.model} pointing to {parent_model}"
60
+ )
61
+ else:
62
+ raise ValueError(f"Multiple foreign keys found. Specify fk_name on the inline.")