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.
- content_studio/__init__.py +7 -0
- content_studio/admin.py +154 -0
- content_studio/apps.py +59 -0
- content_studio/dashboard.py +7 -0
- content_studio/form.py +76 -0
- content_studio/login_backends/__init__.py +21 -0
- content_studio/login_backends/username_password.py +79 -0
- content_studio/models.py +86 -0
- content_studio/router.py +14 -0
- content_studio/serializers.py +16 -0
- content_studio/settings.py +146 -0
- content_studio/templates/content_studio/index.html +20 -0
- content_studio/token_backends/__init__.py +39 -0
- content_studio/token_backends/jwt.py +61 -0
- content_studio/urls.py +12 -0
- content_studio/utils.py +30 -0
- content_studio/views.py +94 -0
- content_studio/viewsets.py +128 -0
- content_studio/widgets.py +30 -0
- django_content_studio-1.0.0a1.dist-info/LICENSE +21 -0
- django_content_studio-1.0.0a1.dist-info/METADATA +88 -0
- django_content_studio-1.0.0a1.dist-info/RECORD +23 -0
- django_content_studio-1.0.0a1.dist-info/WHEEL +4 -0
content_studio/admin.py
ADDED
|
@@ -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
|
+
)
|
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
|
+
)
|
content_studio/models.py
ADDED
|
@@ -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
|
content_studio/router.py
ADDED
|
@@ -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
|
+
]
|
content_studio/utils.py
ADDED
|
@@ -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
|
content_studio/views.py
ADDED
|
@@ -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
|
+
[](https://badge.fury.io/py/django-content-studio)
|
|
20
|
+
[](https://pypi.org/project/django-content-studio/)
|
|
21
|
+
[](https://www.djangoproject.com/)
|
|
22
|
+
[](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,,
|