django-content-studio 1.0.0a1__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,7 @@
1
+ __title__ = "Django Content Studio"
2
+ __version__ = "1.0.0-canary.1"
3
+ __author__ = "Leon van der Grient"
4
+ __license__ = "BSD 3-Clause"
5
+
6
+ # Version synonym
7
+ VERSION = __version__
@@ -0,0 +1,154 @@
1
+ from django.contrib.admin import *
2
+ from rest_framework.request import HttpRequest
3
+
4
+ from .dashboard import Dashboard
5
+ from .form import FormSet, FormSetGroup
6
+ from .login_backends import LoginBackendManager
7
+ from .token_backends import TokenBackendManager
8
+
9
+
10
+ class AdminSite(AdminSite):
11
+ """
12
+ Enhanced admin site for Django Content Studio and integration with
13
+ Django Content Framework.
14
+ """
15
+
16
+ token_backend = TokenBackendManager()
17
+
18
+ login_backend = LoginBackendManager()
19
+
20
+ dashboard = Dashboard()
21
+
22
+ def __init__(self, *args, **kwargs):
23
+ super().__init__(*args, **kwargs)
24
+ # Add token backend's view set to the
25
+ # Content Studio router.
26
+ self.token_backend.set_up_router()
27
+ # Add login backend's view set to the
28
+ # Content Studio router.
29
+ self.login_backend.set_up_router()
30
+
31
+
32
+ admin_site = AdminSite()
33
+
34
+
35
+ class ModelAdmin(ModelAdmin):
36
+ """
37
+ Enhanced model admin for Django Content Studio and integration with
38
+ Django Content Framework. Although it's relatively backwards compatible,
39
+ some default behavior has been changed.
40
+ """
41
+
42
+ # We set a lower limit than Django's default of 100
43
+ list_per_page = 20
44
+
45
+ # Configure the main section in the edit-view.
46
+ edit_main: list[type[FormSetGroup | FormSet | str]] = []
47
+
48
+ # Configure the sidebar in the edit-view.
49
+ edit_sidebar: list[type[FormSet | str]] = []
50
+
51
+ def save_model(self, request, obj, form, change):
52
+ if hasattr(obj, "edited_by"):
53
+ obj.edited_by = request.user
54
+ super().save_model(request, obj, form, change)
55
+
56
+ def has_add_permission(self, request):
57
+ is_singleton = getattr(self.model, "is_singleton", False)
58
+
59
+ # Don't allow to add more than one singleton object.
60
+ if is_singleton and self.model.objects.get():
61
+ return False
62
+
63
+ return super().has_add_permission(request)
64
+
65
+ def has_delete_permission(self, request, obj=None):
66
+ is_singleton = getattr(self.model, "is_singleton", False)
67
+
68
+ if is_singleton:
69
+ return False
70
+
71
+ return super().has_delete_permission(request, obj)
72
+
73
+ def render_change_form(self, request, context, *args, **kwargs):
74
+ is_singleton = getattr(self.model, "is_singleton", False)
75
+
76
+ context["show_save_and_add_another"] = not is_singleton
77
+
78
+ return super().render_change_form(request, context, *args, **kwargs)
79
+
80
+
81
+ class AdminSerializer:
82
+ """
83
+ Class for serializing Django admin classes.
84
+ """
85
+
86
+ def __init__(self, admin_class: ModelAdmin):
87
+ self.admin_class = admin_class
88
+
89
+ def serialize(self, request: HttpRequest):
90
+ admin_class = self.admin_class
91
+
92
+ return {
93
+ "edit": {
94
+ "main": self.serialize_edit_main(request),
95
+ "sidebar": self.serialize_edit_sidebar(request),
96
+ },
97
+ "list": {
98
+ "per_page": admin_class.list_per_page,
99
+ },
100
+ "permissions": {
101
+ "add_permission": admin_class.has_add_permission(request),
102
+ "delete_permission": admin_class.has_delete_permission(request),
103
+ "change_permission": admin_class.has_change_permission(request),
104
+ "view_permission": admin_class.has_view_permission(request),
105
+ },
106
+ }
107
+
108
+ def serialize_edit_main(self, request):
109
+ admin_class = self.admin_class
110
+
111
+ return [
112
+ i.serialize()
113
+ for i in self.get_edit_main(
114
+ getattr(admin_class, "edit_main", admin_class.get_fields(request))
115
+ )
116
+ ]
117
+
118
+ def serialize_edit_sidebar(self, request):
119
+ admin_class = self.admin_class
120
+
121
+ return [
122
+ i.serialize()
123
+ for i in self.get_edit_sidebar(getattr(admin_class, "edit_sidebar", None))
124
+ ]
125
+
126
+ def get_edit_main(self, edit_main):
127
+ """
128
+ Returns a normalized list of form set groups.
129
+
130
+ Form sets will be wrapped in a form set group. If the edit_main attribute is a list of fields,
131
+ they are wrapped in a form set and a form set group.
132
+ """
133
+ if not edit_main:
134
+ return []
135
+ if isinstance(edit_main[0], FormSetGroup):
136
+ return edit_main
137
+ if isinstance(edit_main[0], FormSet):
138
+ return [FormSetGroup(formsets=edit_main)]
139
+
140
+ return [FormSetGroup(formsets=[FormSet(fields=edit_main)])]
141
+
142
+ def get_edit_sidebar(self, edit_sidebar):
143
+ """
144
+ Returns a normalized list of form sets for the edit_sidebar.
145
+
146
+ If the edit_sidebar attribute is a list of fields,
147
+ they are wrapped in a form set.
148
+ """
149
+ if not edit_sidebar:
150
+ return []
151
+ if isinstance(edit_sidebar[0], FormSet):
152
+ return edit_sidebar
153
+
154
+ return [FormSet(fields=edit_sidebar)]
content_studio/apps.py ADDED
@@ -0,0 +1,59 @@
1
+ from django.apps import AppConfig
2
+ from django.contrib import admin
3
+ from rest_framework import serializers
4
+
5
+ from headless.utils import is_runserver
6
+ from . import VERSION
7
+
8
+
9
+ class DjangoContentStudioConfig(AppConfig):
10
+ name = "content_studio"
11
+ label = "content_studio"
12
+ initialized = False
13
+
14
+ def ready(self):
15
+ from .utils import log
16
+
17
+ if is_runserver() and not self.initialized:
18
+ self.initialized = True
19
+
20
+ log("\n")
21
+ log("----------------------------------------")
22
+ log("Django Content Studio")
23
+ log(f"Version {VERSION}")
24
+ log("----------------------------------------")
25
+ log(":rocket:", "Starting Django Content Studio")
26
+ log(":mag:", "Discovering admin models...")
27
+ registered_models = len(admin.site._registry)
28
+ log(
29
+ ":white_check_mark:",
30
+ f"[green]Found {registered_models} admin models[/green]",
31
+ )
32
+ self._create_crud_api()
33
+ log("\n")
34
+
35
+ def _create_crud_api(self):
36
+ from .viewsets import BaseModelViewSet
37
+ from .router import content_studio_router
38
+ from .utils import log
39
+
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__"
46
+
47
+ class ViewSet(BaseModelViewSet):
48
+ serializer_class = Serializer
49
+ queryset = _model.objects.all()
50
+
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
+ log(
57
+ ":white_check_mark:",
58
+ f"[green]Created CRUD API[/green]",
59
+ )
@@ -0,0 +1,7 @@
1
+ class Dashboard:
2
+ """
3
+ The Dashboard class is used to define the structure of the dashboard
4
+ in Django Content Studio.
5
+ """
6
+
7
+ pass
content_studio/form.py ADDED
@@ -0,0 +1,76 @@
1
+ class Field:
2
+ """
3
+ Field class for configuring the fields in content edit views in Django Content Studio.
4
+ """
5
+
6
+ def __init__(self, name: str, col_span: int = 1):
7
+ self.name = name
8
+ self.col_span = col_span
9
+
10
+ def serialize(self):
11
+ return {
12
+ "name": self.name,
13
+ "col_span": self.col_span,
14
+ }
15
+
16
+
17
+ class FieldLayout:
18
+ """
19
+ Field layout class for configuring the layout of fields in content edit views in Django Content Studio.
20
+ """
21
+
22
+ def __init__(self, fields: list[str | Field] = None, columns: int = 1):
23
+ self.fields = fields or []
24
+ self.columns = columns
25
+
26
+ def serialize(self):
27
+ return {
28
+ "fields": [
29
+ field.serialize() if isinstance(field, Field) else field
30
+ for field in self.fields
31
+ ],
32
+ "columns": self.columns,
33
+ }
34
+
35
+
36
+ class FormSet:
37
+ """
38
+ Formset class for configuring the blocks of fields in content edit views
39
+ in Django Content Studio.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ title: str = "",
45
+ description: str = "",
46
+ fields: list[str | Field | FieldLayout] = None,
47
+ ):
48
+ self.title = title
49
+ self.description = description
50
+ self.fields = fields or []
51
+
52
+ def serialize(self):
53
+ return {
54
+ "title": self.title,
55
+ "description": self.description,
56
+ "fields": [
57
+ field.serialize() if isinstance(field, (Field, FieldLayout)) else field
58
+ for field in self.fields
59
+ ],
60
+ }
61
+
62
+
63
+ class FormSetGroup:
64
+ """
65
+ Formset group class for configuring the groups of form sets in content edit views.
66
+ """
67
+
68
+ def __init__(self, label: str = "", formsets: list[FormSet] = None):
69
+ self.label = label
70
+ self.formsets = formsets or []
71
+
72
+ def serialize(self):
73
+ return {
74
+ "label": self.label,
75
+ "formsets": [formset.serialize() for formset in self.formsets],
76
+ }
@@ -0,0 +1,21 @@
1
+ from .username_password import UsernamePasswordBackend
2
+ from ..router import content_studio_router
3
+ from ..settings import cs_settings
4
+
5
+
6
+ class LoginBackendManager:
7
+ """
8
+ Manages different login backends for use by
9
+ Content Studio.
10
+ """
11
+
12
+ def __init__(self, **kwargs):
13
+ self.active_backends = cs_settings.LOGIN_BACKENDS
14
+
15
+ def set_up_router(self):
16
+ for backend in self.active_backends:
17
+ content_studio_router.register(
18
+ f"api/login/{backend.__name__.lower().replace('backend', '')}",
19
+ backend.view_set,
20
+ basename="content_studio_login_backend",
21
+ )
@@ -0,0 +1,79 @@
1
+ from django.contrib.auth import get_user_model, authenticate
2
+ from rest_framework import serializers
3
+ from rest_framework.exceptions import PermissionDenied
4
+ from rest_framework.permissions import AllowAny
5
+ from rest_framework.renderers import JSONRenderer
6
+ from rest_framework.viewsets import ViewSet
7
+
8
+
9
+ class UsernamePasswordSerializer(serializers.Serializer):
10
+ username = serializers.CharField(required=True)
11
+ password = serializers.CharField(required=True)
12
+
13
+
14
+ class UsernamePasswordViewSet(ViewSet):
15
+ """
16
+ View set for username and password endpoints.
17
+ """
18
+
19
+ permission_classes = [AllowAny]
20
+ renderer_classes = [JSONRenderer]
21
+
22
+ def create(self, request):
23
+ serializer = UsernamePasswordSerializer(data=request.data)
24
+
25
+ serializer.is_valid(raise_exception=True)
26
+
27
+ user = UsernamePasswordBackend.login(
28
+ username=serializer.validated_data["username"],
29
+ password=serializer.validated_data["password"],
30
+ )
31
+
32
+ if user is None or not user.is_active:
33
+ raise PermissionDenied()
34
+
35
+ from ..admin import admin_site
36
+
37
+ return admin_site.auth_backend.active_backend.get_response_for_user(user)
38
+
39
+
40
+ class UsernamePasswordBackend:
41
+ name = "Username password"
42
+ view_set = UsernamePasswordViewSet
43
+
44
+ @classmethod
45
+ def get_info(cls):
46
+ """
47
+ Returns information about the backend.
48
+ """
49
+ user_model = get_user_model()
50
+ username_field = getattr(user_model, user_model.USERNAME_FIELD, None)
51
+
52
+ return {
53
+ "type": cls.__name__,
54
+ "config": {"username_field_type": username_field.field.__class__.__name__},
55
+ }
56
+
57
+ @classmethod
58
+ def login(cls, username, password):
59
+ """
60
+ Authenticates user using username and password.
61
+ Returns the user if successful, None otherwise.
62
+ """
63
+ return authenticate(username=username, password=password)
64
+
65
+ def request_password_reset(self, username):
66
+ """
67
+ Sends a password reset email.
68
+ """
69
+ raise NotImplemented(
70
+ "You need to implement a method for sending a password reset token."
71
+ )
72
+
73
+ def complete_password_reset(self, reset_token, new_password):
74
+ """
75
+ Sets the new password based on the reset token.
76
+ """
77
+ raise NotImplemented(
78
+ "You need to implement a method for validating a reset token and setting a new password."
79
+ )
@@ -0,0 +1,86 @@
1
+ from django.db import models
2
+
3
+ from content_framework import fields as cf_fields
4
+ from . import widgets
5
+ from .utils import is_jsonable
6
+
7
+
8
+ class ModelSerializer:
9
+ def __init__(self, model: type[models.Model]):
10
+ self.model = model
11
+
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
+ def serialize(self):
33
+ model = self.model
34
+
35
+ return {
36
+ "label": model._meta.label,
37
+ "verbose_name": model._meta.verbose_name,
38
+ "verbose_name_plural": model._meta.verbose_name_plural,
39
+ "fields": self.get_fields(),
40
+ }
41
+
42
+ def get_fields(self):
43
+ fields = {}
44
+
45
+ for field in self.model._meta.fields:
46
+ fields[field.name] = self.get_field(field)
47
+
48
+ return fields
49
+
50
+ def get_field(self, field):
51
+ widget = self.get_widget(field)
52
+
53
+ data = {
54
+ "verbose_name": field.verbose_name,
55
+ "required": not field.null or not field.blank,
56
+ }
57
+
58
+ if field.help_text:
59
+ data["help_text"] = field.help_text
60
+
61
+ if is_jsonable(field.default):
62
+ data["default"] = field.default
63
+
64
+ if widget:
65
+ data["widget"] = widget
66
+
67
+ if not field.editable:
68
+ data["readonly"] = True
69
+
70
+ if field.primary_key:
71
+ data["primary_key"] = True
72
+ data["readonly"] = True
73
+
74
+ if getattr(field, "choices", None) is not None:
75
+ data["choices"] = field.choices
76
+
77
+ if getattr(field, "max_length", None) is not None:
78
+ data["max_length"] = field.max_length
79
+
80
+ return data
81
+
82
+ def get_widget(self, field):
83
+ try:
84
+ return self.widgets[field.__class__].__name__
85
+ except KeyError:
86
+ return None
@@ -0,0 +1,14 @@
1
+ from rest_framework.routers import DefaultRouter
2
+
3
+
4
+ class ExtendedRouter(DefaultRouter):
5
+ def get_method_map(self, viewset, method_map):
6
+ _method_map = super().get_method_map(viewset, method_map)
7
+
8
+ if getattr(viewset, "is_singleton", False):
9
+ _method_map["patch"] = "update"
10
+
11
+ return _method_map
12
+
13
+
14
+ content_studio_router = ExtendedRouter(trailing_slash=False)
@@ -0,0 +1,16 @@
1
+ from django.contrib.auth import get_user_model
2
+ from rest_framework import serializers
3
+
4
+ user_model = get_user_model()
5
+
6
+
7
+ class CurrentUserSerializer(serializers.ModelSerializer):
8
+ class Meta:
9
+ model = user_model
10
+ fields = (
11
+ "id",
12
+ user_model.USERNAME_FIELD,
13
+ user_model.EMAIL_FIELD,
14
+ "first_name",
15
+ "last_name",
16
+ )
@@ -0,0 +1,146 @@
1
+ """
2
+ Settings for Django Content Studio are all namespaced in the CONTENT_STUDIO setting.
3
+ For example your project's `settings.py` file might look like this:
4
+
5
+ CONTENT_STUDIO = {
6
+ 'AUTH_STRATEGIES': [
7
+ 'content_studio.strategies.UsernamePasswordStrategy',
8
+ ],
9
+ }
10
+
11
+ This module provides the `api_setting` object, that is used to access
12
+ Django Content Studio settings, checking for user settings first, then falling
13
+ back to the defaults.
14
+ """
15
+
16
+ from django.conf import settings
17
+
18
+ from django.core.signals import setting_changed
19
+ from django.utils.module_loading import import_string
20
+
21
+ SETTINGS_NAMESPACE = "CONTENT_STUDIO"
22
+
23
+ DEFAULTS = {
24
+ "LOGIN_BACKENDS": [
25
+ "content_studio.login_backends.UsernamePasswordBackend",
26
+ ],
27
+ "EDITED__BY_ATTR": "edited_by",
28
+ "EDITED_AT_ATTR": "edited_at",
29
+ "CREATED_BY_ATTR": "created_by",
30
+ "CREATED_AT_ATTR": "created_at",
31
+ }
32
+
33
+
34
+ # List of settings that may be in string import notation.
35
+ IMPORT_STRINGS = [
36
+ "LOGIN_BACKENDS",
37
+ ]
38
+
39
+
40
+ # List of settings that have been removed
41
+ REMOVED_SETTINGS = []
42
+
43
+
44
+ def perform_import(val, setting_name):
45
+ """
46
+ If the given setting is a string import notation,
47
+ then perform the necessary import or imports.
48
+ """
49
+ if val is None:
50
+ return None
51
+ elif isinstance(val, str):
52
+ return import_from_string(val, setting_name)
53
+ elif isinstance(val, (list, tuple)):
54
+ return [import_from_string(item, setting_name) for item in val]
55
+ return val
56
+
57
+
58
+ def import_from_string(val, setting_name):
59
+ """
60
+ Attempt to import a class from a string representation.
61
+ """
62
+ try:
63
+ return import_string(val)
64
+ except ImportError as e:
65
+ msg = "Could not import '%s' for Content Studio setting '%s'. %s: %s." % (
66
+ val,
67
+ setting_name,
68
+ e.__class__.__name__,
69
+ e,
70
+ )
71
+ raise ImportError(msg)
72
+
73
+
74
+ class ContentStudioSettings:
75
+ """
76
+ A settings object that allows Django Content Studio settings to be accessed as
77
+ properties. For example:
78
+
79
+ from content_studio.settings import api_settings
80
+ print(api_settings.AUTH_STRATEGIES)
81
+
82
+ Any setting with string import paths will be automatically resolved
83
+ and return the class, rather than the string literal.
84
+ """
85
+
86
+ def __init__(self, user_settings=None, defaults=None, import_strings=None):
87
+ if user_settings:
88
+ self._user_settings = self.__check_user_settings(user_settings)
89
+ self.defaults = defaults or DEFAULTS
90
+ self.import_strings = import_strings or IMPORT_STRINGS
91
+ self._cached_attrs = set()
92
+
93
+ @property
94
+ def user_settings(self):
95
+ if not hasattr(self, "_user_settings"):
96
+ self._user_settings = getattr(settings, SETTINGS_NAMESPACE, {})
97
+ return self._user_settings
98
+
99
+ def __getattr__(self, attr):
100
+ if attr not in self.defaults:
101
+ raise AttributeError("Invalid Content Studio setting: '%s'" % attr)
102
+
103
+ try:
104
+ # Check if present in user settings
105
+ val = self.user_settings[attr]
106
+ except KeyError:
107
+ # Fall back to defaults
108
+ val = self.defaults[attr]
109
+
110
+ # Coerce import strings into classes
111
+ if attr in self.import_strings:
112
+ val = perform_import(val, attr)
113
+
114
+ # Cache the result
115
+ self._cached_attrs.add(attr)
116
+ setattr(self, attr, val)
117
+ return val
118
+
119
+ def __check_user_settings(self, user_settings):
120
+ SETTINGS_DOC = "https://www.django-content-studio.org/settings/"
121
+ for setting in REMOVED_SETTINGS:
122
+ if setting in user_settings:
123
+ raise RuntimeError(
124
+ "The '%s' setting has been removed. Please refer to '%s' for available settings."
125
+ % (setting, SETTINGS_DOC)
126
+ )
127
+ return user_settings
128
+
129
+ def reload(self):
130
+ for attr in self._cached_attrs:
131
+ delattr(self, attr)
132
+ self._cached_attrs.clear()
133
+ if hasattr(self, "_user_settings"):
134
+ delattr(self, "_user_settings")
135
+
136
+
137
+ cs_settings = ContentStudioSettings(None, DEFAULTS, IMPORT_STRINGS)
138
+
139
+
140
+ def reload_settings(*args, **kwargs):
141
+ setting = kwargs["setting"]
142
+ if setting == SETTINGS_NAMESPACE:
143
+ cs_settings.reload()
144
+
145
+
146
+ setting_changed.connect(reload_settings)
@@ -0,0 +1,20 @@
1
+ {% load static %}
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link rel="icon" href={% static 'content_studio/icon.png' %}>
8
+ <title>Django Content Studio</title>
9
+ <script>
10
+ window.DCS_STATIC_PREFIX = "{% static '' %}content_studio/";
11
+ window.DCS_BASENAME = "{% url 'content_studio' %}";
12
+ window.DCS_API_URL = "{% url 'headless_rest' %}";
13
+ </script>
14
+ <script type="module" crossorigin src="{% static 'content_studio/assets/index.js' %}"></script>
15
+ <link rel="stylesheet" crossorigin href="{% static 'content_studio/assets/index.css' %}">
16
+ </head>
17
+ <body>
18
+ <div id="root"></div>
19
+ </body>
20
+ </html>
@@ -0,0 +1,39 @@
1
+ from django.core.exceptions import ImproperlyConfigured
2
+
3
+ from .jwt import SimpleJwtBackend
4
+ from ..router import content_studio_router
5
+
6
+
7
+ class TokenBackendManager:
8
+ """
9
+ Manages different token authentication backends for use by
10
+ Content Studio.
11
+
12
+ While login backends are used to identify a user,
13
+ token backends determine are used to authenticate
14
+ communication between Content Studio and the admin API.
15
+ """
16
+
17
+ available_backends = [SimpleJwtBackend]
18
+ _active_backend = None
19
+
20
+ @property
21
+ def active_backend(self):
22
+ if self._active_backend:
23
+ return self._active_backend
24
+
25
+ for backend in self.available_backends:
26
+ if backend.is_available:
27
+ self._active_backend = backend
28
+ return backend
29
+
30
+ raise ImproperlyConfigured(
31
+ "You need to install at least one of the support authentication backends."
32
+ )
33
+
34
+ def set_up_router(self):
35
+ content_studio_router.register(
36
+ f"api/tokens/{self.active_backend.__name__.lower().replace('backend', '')}",
37
+ self.active_backend.view_set,
38
+ basename="content_studio_token_backend",
39
+ )
@@ -0,0 +1,61 @@
1
+ from rest_framework.decorators import action
2
+ from rest_framework.response import Response
3
+ from rest_framework.viewsets import ViewSet
4
+
5
+
6
+ class SimpleJwtViewSet(ViewSet):
7
+ @action(
8
+ detail=False, methods=["post"], permission_classes=[], authentication_classes=[]
9
+ )
10
+ def refresh(self, request):
11
+ from rest_framework_simplejwt.views import TokenRefreshView
12
+
13
+ view_instance = TokenRefreshView()
14
+ view_instance.request = request
15
+ view_instance.format_kwarg = None
16
+ return view_instance.post(request)
17
+
18
+
19
+ class SimpleJwtBackend:
20
+ name = "Simple JWT"
21
+ authentication_class = None
22
+ view_set = SimpleJwtViewSet
23
+
24
+ def __init__(self):
25
+ from rest_framework_simplejwt.authentication import JWTAuthentication
26
+
27
+ self.authentication_class = JWTAuthentication
28
+
29
+ @classmethod
30
+ def get_info(cls):
31
+
32
+ from rest_framework_simplejwt.settings import api_settings as simplejwt_settings
33
+
34
+ return {
35
+ "type": cls.__name__,
36
+ "settings": {
37
+ "ACCESS_TOKEN_LIFETIME": simplejwt_settings.ACCESS_TOKEN_LIFETIME.total_seconds(),
38
+ },
39
+ }
40
+
41
+ @property
42
+ def is_available(self) -> bool:
43
+ try:
44
+ import rest_framework_simplejwt
45
+
46
+ return True
47
+ except ImportError:
48
+ return False
49
+
50
+ @classmethod
51
+ def get_response_for_user(cls, user):
52
+ from rest_framework_simplejwt.tokens import RefreshToken
53
+
54
+ refresh = RefreshToken.for_user(user)
55
+
56
+ return Response(
57
+ {
58
+ "refresh": str(refresh),
59
+ "access": str(refresh.access_token),
60
+ }
61
+ )
content_studio/urls.py ADDED
@@ -0,0 +1,12 @@
1
+ from django.urls import re_path
2
+
3
+ from .router import content_studio_router
4
+ from .views import ContentStudioWebAppView, AdminApiViewSet
5
+
6
+ content_studio_router.register("api", AdminApiViewSet, "content_studio_admin")
7
+
8
+ urlpatterns = content_studio_router.urls + [
9
+ re_path(
10
+ "^(?!api).*$", ContentStudioWebAppView.as_view(), name="content_studio_web"
11
+ ),
12
+ ]
@@ -0,0 +1,30 @@
1
+ import json
2
+ import sys
3
+
4
+ from rich.console import Console
5
+
6
+ console = Console()
7
+
8
+
9
+ def log(*args, **kwargs):
10
+ console.print(*args, **kwargs)
11
+
12
+
13
+ def is_runserver():
14
+ """
15
+ Checks if the Django application is started as a server.
16
+ We'll also assume it started if manage.py is not used (e.g. when Django is started using wsgi/asgi).
17
+ The main purpose of this check is to not run certain code on other management commands such
18
+ as `migrate`.
19
+ """
20
+ is_manage_cmd = sys.argv[0].endswith("/manage.py")
21
+
22
+ return not is_manage_cmd or sys.argv[1] == "runserver"
23
+
24
+
25
+ def is_jsonable(x):
26
+ try:
27
+ json.dumps(x)
28
+ return True
29
+ except (TypeError, OverflowError):
30
+ return False
@@ -0,0 +1,94 @@
1
+ from django.contrib import admin
2
+ from django.urls import reverse, NoReverseMatch
3
+ from django.views.generic import TemplateView
4
+ from rest_framework.decorators import action
5
+ from rest_framework.permissions import IsAdminUser, AllowAny
6
+ from rest_framework.renderers import JSONRenderer
7
+ from rest_framework.response import Response
8
+ from rest_framework.viewsets import ViewSet
9
+
10
+ from .admin import AdminSerializer, admin_site
11
+ from .models import ModelSerializer
12
+ from .serializers import CurrentUserSerializer
13
+
14
+
15
+ class ContentStudioWebAppView(TemplateView):
16
+ """
17
+ View for rendering the content studio web app.
18
+ """
19
+
20
+ template_name = "content_studio/index.html"
21
+
22
+
23
+ class AdminApiViewSet(ViewSet):
24
+ """
25
+ Viewset for special admin endpoints.
26
+ """
27
+
28
+ permission_classes = [IsAdminUser]
29
+ renderer_classes = [JSONRenderer]
30
+
31
+ @action(
32
+ methods=["get"],
33
+ detail=False,
34
+ url_path="info",
35
+ permission_classes=[AllowAny],
36
+ )
37
+ def info(self, request):
38
+ """
39
+ Returns public information about the Content Studio admin.
40
+ """
41
+
42
+ data = {
43
+ "site_header": admin_site.site_header,
44
+ "site_title": admin_site.site_title,
45
+ "index_title": admin_site.index_title,
46
+ "site_url": admin_site.site_url,
47
+ "health_check": get_health_check_path(),
48
+ "login_backends": [
49
+ backend.get_info()
50
+ for backend in admin_site.login_backend.active_backends
51
+ ],
52
+ "token_backend": admin_site.token_backend.active_backend.get_info(),
53
+ }
54
+
55
+ return Response(data=data)
56
+
57
+ @action(
58
+ methods=["get"],
59
+ detail=False,
60
+ url_path="discover",
61
+ )
62
+ def discover(
63
+ self,
64
+ request,
65
+ ):
66
+ """
67
+ Returns information about the Django app (models, admin models, admin site, settings, etc.).
68
+ """
69
+ data = {"models": []}
70
+ registered_models = admin.site._registry
71
+
72
+ for model, admin_class in registered_models.items():
73
+ data["models"].append(
74
+ {
75
+ **ModelSerializer(model).serialize(),
76
+ "admin": AdminSerializer(admin_class).serialize(request),
77
+ }
78
+ )
79
+
80
+ return Response(data=data)
81
+
82
+ @action(methods=["get"], detail=False, url_path="me")
83
+ def me(self, request):
84
+ """
85
+ Returns information about the current user.
86
+ """
87
+ return Response(CurrentUserSerializer(request.user).data)
88
+
89
+
90
+ def get_health_check_path():
91
+ try:
92
+ return reverse("healthcheck")
93
+ except NoReverseMatch:
94
+ return None
@@ -0,0 +1,128 @@
1
+ from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION
2
+ from django.contrib.contenttypes.models import ContentType
3
+ from rest_framework.exceptions import MethodNotAllowed, NotFound
4
+ from rest_framework.filters import SearchFilter, OrderingFilter
5
+ from rest_framework.pagination import PageNumberPagination
6
+ from rest_framework.parsers import JSONParser
7
+ from rest_framework.permissions import DjangoModelPermissions
8
+ from rest_framework.renderers import JSONRenderer
9
+ from rest_framework.viewsets import ModelViewSet
10
+
11
+ from .admin import admin_site
12
+ from .settings import cs_settings
13
+
14
+
15
+ class BaseModelViewSet(ModelViewSet):
16
+ lookup_field = "id"
17
+ parser_classes = [JSONParser]
18
+ renderer_classes = [JSONRenderer]
19
+ permission_classes = [DjangoModelPermissions]
20
+ pagination_class = [PageNumberPagination]
21
+ filter_backends = [SearchFilter, OrderingFilter]
22
+
23
+ def __init__(self):
24
+ super(BaseModelViewSet, self).__init__()
25
+
26
+ self.authentication_classes = [
27
+ admin_site.auth_backend.active_backend.authentication_class
28
+ ]
29
+
30
+ def retrieve(self, request, *args, **kwargs):
31
+ """
32
+ Disable this endpoint for singletons. They should use
33
+ the list endpoint instead.
34
+ """
35
+ if self.is_singleton:
36
+ raise MethodNotAllowed(
37
+ method="GET",
38
+ detail="Singleton objects do not support the retrieve endpoint.",
39
+ )
40
+
41
+ return super().retrieve(request, *args, **kwargs)
42
+
43
+ def update(self, request, *args, **kwargs):
44
+ """
45
+ We overwrite the update method to support singletons. If a singleton
46
+ doesn't exist it will be created.
47
+ """
48
+ if self.is_singleton:
49
+ try:
50
+ super().update(request, *args, **kwargs)
51
+ except NotFound:
52
+ self.create(request, *args, **kwargs)
53
+
54
+ return super().update(request, *args, **kwargs)
55
+
56
+ def list(self, request, *args, **kwargs):
57
+ """
58
+ We overwrite the list method to support singletons. If a singleton
59
+ doesn't exist this will raise a NotFound exception.
60
+ """
61
+ if self.is_singleton:
62
+ return super().retrieve(request, *args, **kwargs)
63
+
64
+ return super().list(request, *args, **kwargs)
65
+
66
+ def perform_create(self, serializer):
67
+ instance = serializer.save()
68
+
69
+ if hasattr(instance, cs_settings.CREATED_BY):
70
+ setattr(instance, cs_settings.CREATED_BY, self.request.user)
71
+ instance.save()
72
+
73
+ content_type = ContentType.objects.get_for_model(instance)
74
+ LogEntry.objects.create(
75
+ user=self.request.user,
76
+ action_flag=ADDITION,
77
+ content_type=content_type,
78
+ object_id=instance.id,
79
+ object_repr=str(instance)[:200],
80
+ change_message="",
81
+ )
82
+
83
+ def perform_update(self, serializer):
84
+ instance = serializer.save()
85
+
86
+ if hasattr(instance, cs_settings.EDITED_BY):
87
+ setattr(instance, cs_settings.EDITED_BY, self.request.user)
88
+ instance.save()
89
+
90
+ content_type = ContentType.objects.get_for_model(instance)
91
+ LogEntry.objects.create(
92
+ user=self.request.user,
93
+ action_flag=CHANGE,
94
+ content_type=content_type,
95
+ object_id=instance.id,
96
+ object_repr=str(instance)[:200],
97
+ change_message="",
98
+ )
99
+
100
+ def perform_destroy(self, instance):
101
+ content_type = ContentType.objects.get_for_model(instance)
102
+ LogEntry.objects.create(
103
+ user=self.request.user,
104
+ action_flag=DELETION,
105
+ content_type=content_type,
106
+ object_id=instance.id,
107
+ object_repr=str(instance)[:200],
108
+ change_message="",
109
+ )
110
+
111
+ instance.delete()
112
+
113
+ def get_object(self):
114
+ """
115
+ We overwrite this method to add support for singletons.
116
+ If a singleton doesn't exist it will raise a NotFound exception.
117
+ """
118
+ if self.is_singleton:
119
+ try:
120
+ return self.get_queryset().get()
121
+ except self.queryset.model.DoesNotExist:
122
+ raise NotFound()
123
+
124
+ return super().get_object()
125
+
126
+ @property
127
+ def is_singleton(self):
128
+ return getattr(self.get_queryset().model, "is_singleton", False)
@@ -0,0 +1,30 @@
1
+ class InputWidget:
2
+ pass
3
+
4
+
5
+ class TextAreaWidget:
6
+ pass
7
+
8
+
9
+ class BooleanWidget:
10
+ pass
11
+
12
+
13
+ class TagWidget:
14
+ pass
15
+
16
+
17
+ class RichTextWidget:
18
+ pass
19
+
20
+
21
+ class MultipleChoiceWidget:
22
+ pass
23
+
24
+
25
+ class URLPathWidget:
26
+ pass
27
+
28
+
29
+ class SlugWidget:
30
+ pass
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Leon van der Grient
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.3
2
+ Name: django-content-studio
3
+ Version: 1.0.0a1
4
+ Summary: Modern & flexible Django admin
5
+ License: MIT
6
+ Author: Leon van der Grient
7
+ Author-email: leon@devtastic.io
8
+ Requires-Python: >=3.10
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Django Content Studio
18
+
19
+ [![PyPI version](https://badge.fury.io/py/django-content-studio.svg)](https://badge.fury.io/py/django-content-studio)
20
+ [![Python versions](https://img.shields.io/pypi/pyversions/django-content-studio.svg)](https://pypi.org/project/django-content-studio/)
21
+ [![Django versions](https://img.shields.io/badge/django-5.0%2B-blue.svg)](https://www.djangoproject.com/)
22
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
23
+
24
+ Django Content Studio is a modern, flexible alternative to the Django admin.
25
+
26
+ > This package is still under development
27
+
28
+ ## 🚀 Quick Start
29
+
30
+ ### Installation
31
+
32
+ ☝️ Django Content Studio depends on Django and Django Rest Framework.
33
+
34
+ ```bash
35
+ pip install django-content-studio
36
+ ```
37
+
38
+ ### Add to Django Settings
39
+
40
+ ```python
41
+ # settings.py
42
+ INSTALLED_APPS = [
43
+ 'django.contrib.admin',
44
+ 'django.contrib.auth',
45
+ 'django.contrib.contenttypes',
46
+ 'django.contrib.sessions',
47
+ 'django.contrib.messages',
48
+ 'django.contrib.staticfiles',
49
+ 'rest_framework',
50
+ 'content_studio', # Add this
51
+ # ... your apps
52
+ ]
53
+ ```
54
+ ### Add URLs
55
+
56
+ ```python
57
+ # urls.py
58
+ urlpatterns = [
59
+ path("admin/", include("content_studio.urls")),
60
+ # ... your urls
61
+ ]
62
+ ```
63
+
64
+ ## 🐛 Issues & Support
65
+
66
+ - 🐛 **Bug Reports**: [GitHub Issues](https://github.com/StructuralRealist/django-content-studio/issues)
67
+ - 💬 **Discussions**: [GitHub Discussions](https://github.com/StructuralRealist/django-content-studio/discussions)
68
+ - 📧 **Email**: leon@devtastic.io
69
+
70
+ ## 📄 License
71
+
72
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
73
+
74
+ ## 🙏 Acknowledgments
75
+
76
+ - Built with React and Tailwind CSS
77
+ - Inspired by the original Django admin
78
+ - Thanks to all contributors and the Django community
79
+
80
+ ## 🔗 Links
81
+
82
+ - [PyPI Package](https://pypi.org/project/django-content-studio/)
83
+ - [GitHub Repository](https://github.com/StructuralRealist/django-content-studio)
84
+ - [Changelog](CHANGELOG.md)
85
+
86
+ ---
87
+
88
+ Made in Europe 🇪🇺 with 💚 for Django
@@ -0,0 +1,23 @@
1
+ content_studio/__init__.py,sha256=9XVz23GA6n6RDutB7K90md10AoDlSqDnilzAo1VCvhc,172
2
+ content_studio/admin.py,sha256=H5gx6RKljSITL9UtE3yech97o2ZZhALgS9Mf0yWechA,4812
3
+ content_studio/apps.py,sha256=OmOcBLnzUnP5T8oOd1iTrNNnrDRHUsX6WMQQV1IEc00,1880
4
+ content_studio/dashboard.py,sha256=ptQ-cYp9ltsWeO3YK7Q_7WGgYwwzuM6zU0rxcuybjw4,146
5
+ content_studio/form.py,sha256=-SZZ_OBCgn023GWMJEQqVdyZLA2hqDtDCdrqVLB4mNE,2006
6
+ content_studio/login_backends/__init__.py,sha256=hgPJh9hFobubImfhynaeZ_dku3sjqNQvN5B43TVg7Gw,643
7
+ content_studio/login_backends/username_password.py,sha256=QRkilK5E2S2o6xfK_jK64OdJ93Ima3lRYnvjncnVaH8,2441
8
+ content_studio/models.py,sha256=Kk4Hw3T3impuWZ5Pfq1259KILq3wyqIBsXfA0MyuA3A,2613
9
+ content_studio/router.py,sha256=7Up_sipGaUDoY6ElJNRf85ADaYfJCWV4To523L4LGuw,393
10
+ content_studio/serializers.py,sha256=uOUXe7aDpgYN6B5rlZsN1_pFSPspV5exg3L7RxsTEhg,393
11
+ content_studio/settings.py,sha256=wbwNN_RAk6Sv8hlB02dOveqUCzc-TVThau5rO47ANS0,4306
12
+ content_studio/templates/content_studio/index.html,sha256=nzCbBJGhZijNYszIMB-NvCIqCP7KzDbpleKH1dVOw-c,729
13
+ content_studio/token_backends/__init__.py,sha256=dO3aWIHXX8He399ZEvlS4fNgyL84OY9TEtkva9b5N5Q,1183
14
+ content_studio/token_backends/jwt.py,sha256=Gd0IuhS6rjCzjTqkHWFMSpIt-x-07Jgr59moAVympBw,1667
15
+ content_studio/urls.py,sha256=Rkb6QuyAGtn8MqkK7GmyVJXAq-q0mQT3CedhBSaCOr0,367
16
+ content_studio/utils.py,sha256=8MibAxBgSZASZjPbPPpW9KplCUSHlM4TbUMkCNh5SDs,707
17
+ content_studio/views.py,sha256=FR9CxuYJ-YiSVnxiFEwZlfpWfFSKv1hKtbUB6PC5qDE,2662
18
+ content_studio/viewsets.py,sha256=sWbPRXm6UEkKUYPCjALZACWbixwI68BO_WB6Z2pj0Pg,4342
19
+ content_studio/widgets.py,sha256=cBGvFrklzDkbwa39jhXc4Jp-kvykahpA01yk307VRXw,254
20
+ django_content_studio-1.0.0a1.dist-info/LICENSE,sha256=Wnx2EJhtSNnXE5Qs80i1HTBNFZTi8acEtC5TYqtFlnQ,1075
21
+ django_content_studio-1.0.0a1.dist-info/METADATA,sha256=GQq4ncwdxjf3FW2dW_jJUsaie0Lv0EVq0l2qhmNj8e4,2555
22
+ django_content_studio-1.0.0a1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
23
+ django_content_studio-1.0.0a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any