edx-rbac 3.1.0__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.
- edx_rbac/__init__.py +6 -0
- edx_rbac/admin/__init__.py +28 -0
- edx_rbac/admin/forms.py +29 -0
- edx_rbac/apps.py +13 -0
- edx_rbac/constants.py +16 -0
- edx_rbac/decorators.py +43 -0
- edx_rbac/fields.py +29 -0
- edx_rbac/mixins.py +183 -0
- edx_rbac/models.py +113 -0
- edx_rbac/templates/edx_rbac/base.html +22 -0
- edx_rbac/urls.py +10 -0
- edx_rbac/utils.py +251 -0
- edx_rbac-3.1.0.dist-info/METADATA +106 -0
- edx_rbac-3.1.0.dist-info/RECORD +19 -0
- edx_rbac-3.1.0.dist-info/WHEEL +5 -0
- edx_rbac-3.1.0.dist-info/licenses/AUTHORS +4 -0
- edx_rbac-3.1.0.dist-info/licenses/LICENSE +661 -0
- edx_rbac-3.1.0.dist-info/licenses/LICENSE.txt +672 -0
- edx_rbac-3.1.0.dist-info/top_level.txt +1 -0
edx_rbac/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django admin integration for djangoapps using edx-rbac.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django.contrib import admin
|
|
6
|
+
|
|
7
|
+
from edx_rbac.admin.forms import UserRoleAssignmentAdminForm
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UserRoleAssignmentAdmin(admin.ModelAdmin):
|
|
11
|
+
"""
|
|
12
|
+
Django admin for UserRoleAssignment.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
class Meta:
|
|
16
|
+
"""
|
|
17
|
+
Meta class for UserRoleAssignmentAdmin.
|
|
18
|
+
"""
|
|
19
|
+
abstract = True
|
|
20
|
+
|
|
21
|
+
list_display = (
|
|
22
|
+
'user', 'role'
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
list_filter = ('role',)
|
|
26
|
+
search_fields = ('user__email', 'role__name')
|
|
27
|
+
fields = ('user', 'role',)
|
|
28
|
+
form = UserRoleAssignmentAdminForm
|
edx_rbac/admin/forms.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Forms to be used for djangoapps extending edx_rbac.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django import forms
|
|
6
|
+
from django.utils.translation import gettext as _
|
|
7
|
+
|
|
8
|
+
from edx_rbac.fields import UserFromEmailField
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UserRoleAssignmentAdminForm(forms.ModelForm):
|
|
12
|
+
"""
|
|
13
|
+
Custom Form for CRUD operations on models extending UserRoleAssignment.
|
|
14
|
+
"""
|
|
15
|
+
user = UserFromEmailField(
|
|
16
|
+
label=_('User Email'),
|
|
17
|
+
required=True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
class Meta:
|
|
21
|
+
abstract = True
|
|
22
|
+
|
|
23
|
+
def __init__(self, *args, **kwargs):
|
|
24
|
+
instance = kwargs.get('instance')
|
|
25
|
+
initial = kwargs.get('initial', {})
|
|
26
|
+
if instance:
|
|
27
|
+
initial['user'] = instance.user.email
|
|
28
|
+
kwargs['initial'] = initial
|
|
29
|
+
super().__init__(*args, **kwargs)
|
edx_rbac/apps.py
ADDED
edx_rbac/constants.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
All constants pertaining to the edx_rbac module.
|
|
3
|
+
"""
|
|
4
|
+
# The string denoting that a given role is applicable across all contexts
|
|
5
|
+
ALL_ACCESS_CONTEXT = '*'
|
|
6
|
+
|
|
7
|
+
# .. toggle_name: RBAC_IGNORE_INVALID_JWT_COOKIE
|
|
8
|
+
# .. toggle_implementation: DjangoSetting
|
|
9
|
+
# .. toggle_default: False
|
|
10
|
+
# .. toggle_description: When true, causes instances of `jwt.exceptions.InvalidTokenError`
|
|
11
|
+
# to be ignored instead of raised in the `get_decoded_jwt()` utility function.
|
|
12
|
+
# Defaults to False for backward-compatibility purposes, but it's recommended
|
|
13
|
+
# to be set to True in dependent applications.
|
|
14
|
+
# .. toggle_use_cases: opt_in
|
|
15
|
+
# .. toggle_creation_date: 2024-08-19
|
|
16
|
+
IGNORE_INVALID_JWT_COOKIE_SETTING = 'RBAC_IGNORE_INVALID_JWT_COOKIE'
|
edx_rbac/decorators.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Taken from https://github.com/escodebar/django-rest-framework-rules/blob/master/rest_framework_rules/decorators.py.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
|
|
7
|
+
import crum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def permission_required(*permissions, **decorator_kwargs):
|
|
11
|
+
"""
|
|
12
|
+
Verify permissions for access to the api.
|
|
13
|
+
|
|
14
|
+
:param permissions: Permissions added via django_rules add_perm
|
|
15
|
+
:param decorator_kwargs: Arguments for permission checks
|
|
16
|
+
:return: decorator
|
|
17
|
+
"""
|
|
18
|
+
def decorator(view):
|
|
19
|
+
"""Verify permissions decorator."""
|
|
20
|
+
@functools.wraps(view)
|
|
21
|
+
def wrapped_view(self, request, *args, **kwargs):
|
|
22
|
+
"""Wrap for the view function."""
|
|
23
|
+
fn = decorator_kwargs.get('fn', None)
|
|
24
|
+
if callable(fn):
|
|
25
|
+
obj = fn(request, *args, **kwargs)
|
|
26
|
+
else:
|
|
27
|
+
obj = fn
|
|
28
|
+
|
|
29
|
+
crum.set_current_request(request)
|
|
30
|
+
|
|
31
|
+
missing_permissions = [perm for perm in permissions
|
|
32
|
+
if not request.user.has_perm(perm, obj)]
|
|
33
|
+
if any(missing_permissions):
|
|
34
|
+
# raises a permission denied exception causing a 403 response
|
|
35
|
+
self.permission_denied(
|
|
36
|
+
request,
|
|
37
|
+
message=f"Missing: {', '.join(missing_permissions)}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return view(self, request, *args, **kwargs)
|
|
41
|
+
return wrapped_view
|
|
42
|
+
|
|
43
|
+
return decorator
|
edx_rbac/fields.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fields to be used for djangoapps extending edx_rbac.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django import forms
|
|
6
|
+
from django.apps import apps
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.core.exceptions import ValidationError
|
|
9
|
+
|
|
10
|
+
USER_MODEL = apps.get_model(settings.AUTH_USER_MODEL)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserFromEmailField(forms.EmailField):
|
|
14
|
+
"""
|
|
15
|
+
Custom Form Field class for selecting users by entering an email.
|
|
16
|
+
|
|
17
|
+
Meant to be used for models with foreign keys to the user table.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def clean(self, value):
|
|
21
|
+
"""
|
|
22
|
+
Override for UserFromEmailField clean method.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
user = USER_MODEL.objects.get(email=value)
|
|
26
|
+
except USER_MODEL.DoesNotExist as error:
|
|
27
|
+
raise ValidationError(f'User with email {value} does not exist') from error
|
|
28
|
+
|
|
29
|
+
return user
|
edx_rbac/mixins.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mixin adapted from https://github.com/escodebar/django-rest-framework-rules/blob/master/rest_framework_rules/mixins.py.
|
|
3
|
+
|
|
4
|
+
Keeps py2 backward compatibility and only holds on to the necessary bits of the mixin needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import crum
|
|
8
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
9
|
+
from django.utils.functional import cached_property
|
|
10
|
+
|
|
11
|
+
from edx_rbac import utils
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PermissionRequiredMixin:
|
|
15
|
+
"""
|
|
16
|
+
Mixin for checking permissions with django rules.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
object_permission_required = None
|
|
20
|
+
permission_required = None
|
|
21
|
+
|
|
22
|
+
def get_permission_required(self):
|
|
23
|
+
"""
|
|
24
|
+
Return permissions required for the view it is mixed into.
|
|
25
|
+
"""
|
|
26
|
+
if self.permission_required is None:
|
|
27
|
+
# This prevents a misconfiguration of the view into which the mixin
|
|
28
|
+
# is mixed. If the mixin is used, at least one permission should be
|
|
29
|
+
# required.
|
|
30
|
+
raise ImproperlyConfigured(
|
|
31
|
+
f'{self.__class__.__name__} is missing the permission_required attribute. Define '
|
|
32
|
+
f'{self.__class__.__name__}.permission_required, or override '
|
|
33
|
+
f'{self.__class__.__name__}.get_permission_required().'
|
|
34
|
+
)
|
|
35
|
+
if isinstance(self.permission_required, str):
|
|
36
|
+
perms = (self.permission_required, )
|
|
37
|
+
else:
|
|
38
|
+
perms = self.permission_required
|
|
39
|
+
return perms
|
|
40
|
+
|
|
41
|
+
def check_permissions(self, request):
|
|
42
|
+
"""
|
|
43
|
+
Check through permissions required and throws a permission_denied if missing any.
|
|
44
|
+
|
|
45
|
+
If `get_permission_object` is implemented, it will be called and should return the object
|
|
46
|
+
for which the @rules predicate checks against.
|
|
47
|
+
"""
|
|
48
|
+
crum.set_current_request(request)
|
|
49
|
+
user = request.user
|
|
50
|
+
|
|
51
|
+
if hasattr(self, 'get_permission_object') and callable(self.get_permission_object):
|
|
52
|
+
obj = self.get_permission_object()
|
|
53
|
+
else:
|
|
54
|
+
obj = None
|
|
55
|
+
|
|
56
|
+
missing_permissions = [perm for perm in self.get_permission_required()
|
|
57
|
+
if not user.has_perm(perm, obj)]
|
|
58
|
+
|
|
59
|
+
if any(missing_permissions):
|
|
60
|
+
self.permission_denied(
|
|
61
|
+
request,
|
|
62
|
+
message=f"MISSING: {', '.join(missing_permissions)}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PermissionRequiredForListingMixin(PermissionRequiredMixin):
|
|
67
|
+
"""
|
|
68
|
+
Mixin for checking role-based access against multiple objects, specifically
|
|
69
|
+
using the "list" action of a DRF ViewSet. The brunt of the work is done in
|
|
70
|
+
the `accessible_contexts` cached property - this property determines the set
|
|
71
|
+
of "contexts" (probably identifiers of some model) to which the requesting
|
|
72
|
+
user has access under any of the `allowed_roles`. It's pretty opinionated and
|
|
73
|
+
you _must_ define the following fields:
|
|
74
|
+
|
|
75
|
+
`list_lookup_field` (string) - The field of the model you want to list instances of
|
|
76
|
+
that holds values defined by `accessible_contexts`.
|
|
77
|
+
|
|
78
|
+
`allowed_roles` (list) - A list of one or more roles against which access is checked. It's disjunctive,
|
|
79
|
+
which means that if "role_1" grants access to resources "A" and "B", and
|
|
80
|
+
"role_2" grants access to resource "C", defining `allowed_roles = ['role_1', 'role_2']`
|
|
81
|
+
will cause resources "A", "B", and "C" to be returned by a listing operation
|
|
82
|
+
for a user who is granted both "role_1" and "role_2".
|
|
83
|
+
|
|
84
|
+
`role_assignment_class` (class) - The RoleAssignmentClass against which DB-defined access is checked.
|
|
85
|
+
|
|
86
|
+
`base_queryset` (property) - A queryset which acts as the "base case". It should generally return
|
|
87
|
+
all accessible instances for a user who has access to anything within this viewset (like a superuser
|
|
88
|
+
or admin).
|
|
89
|
+
"""
|
|
90
|
+
# This flag indicates whether requesting users with `is_staff = True`
|
|
91
|
+
# should ever encounter a PermissionDenied exception.
|
|
92
|
+
staff_are_never_forbidden = True
|
|
93
|
+
|
|
94
|
+
# This flag indicates whether requesting users with `is_superuser = True`
|
|
95
|
+
# should always have the `ALL_ACCESS_CONTEXT` included in their set of accessible contexts.
|
|
96
|
+
superusers_can_access_anything = True
|
|
97
|
+
|
|
98
|
+
# The field (as a string) of the model you want to list instances of.
|
|
99
|
+
list_lookup_field = None
|
|
100
|
+
|
|
101
|
+
# A list of one or more roles against which access is checked.
|
|
102
|
+
allowed_roles = []
|
|
103
|
+
|
|
104
|
+
# The RoleAssignmentClass against which DB-defined access is checked
|
|
105
|
+
role_assignment_class = None
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def base_queryset(self):
|
|
109
|
+
"""
|
|
110
|
+
Should return a queryset that acts as the "base case".
|
|
111
|
+
It should generally return all accessible instances for a user who has access
|
|
112
|
+
to everything within this viewset (like a superuser or admin).
|
|
113
|
+
"""
|
|
114
|
+
raise NotImplementedError
|
|
115
|
+
|
|
116
|
+
@cached_property
|
|
117
|
+
def request_action(self):
|
|
118
|
+
"""
|
|
119
|
+
Returns `self.action` if it exists, None otherwise.
|
|
120
|
+
"""
|
|
121
|
+
return getattr(self, 'action', None)
|
|
122
|
+
|
|
123
|
+
@cached_property
|
|
124
|
+
def accessible_contexts(self):
|
|
125
|
+
"""
|
|
126
|
+
Cached set of contexts (usually model identifiers) the requesting user has access to.
|
|
127
|
+
Returns a set that contains the `ALL_ACCESS_CONTEXT` identifier
|
|
128
|
+
if the requesting user is a superuser.
|
|
129
|
+
"""
|
|
130
|
+
contexts_via_jwt = utils.contexts_accessible_from_request(self.request, self.allowed_roles)
|
|
131
|
+
contexts_via_db = set()
|
|
132
|
+
|
|
133
|
+
if self.role_assignment_class:
|
|
134
|
+
contexts_via_db = utils.contexts_accessible_from_database(
|
|
135
|
+
self.request.user, self.allowed_roles, self.role_assignment_class
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if self.request.user.is_superuser and self.superusers_can_access_anything:
|
|
139
|
+
contexts_via_db.add(utils.ALL_ACCESS_CONTEXT)
|
|
140
|
+
|
|
141
|
+
return contexts_via_jwt | contexts_via_db
|
|
142
|
+
|
|
143
|
+
def check_permissions(self, request):
|
|
144
|
+
"""
|
|
145
|
+
If dealing with a "list" action, goes through some customized
|
|
146
|
+
logic to check which contexts are accessible by the requesting
|
|
147
|
+
user. If none are, and the user is not staff/super, raise
|
|
148
|
+
a `PermissionDenied` exception. Uses the parent class's `check_permissions()`
|
|
149
|
+
method if the request action is not "list".
|
|
150
|
+
"""
|
|
151
|
+
if self.request_action == 'list':
|
|
152
|
+
# Super-users and staff won't get Forbidden responses,
|
|
153
|
+
# but depending on their assigned roles, staff may
|
|
154
|
+
# get an empty result set.
|
|
155
|
+
if request.user.is_superuser:
|
|
156
|
+
return
|
|
157
|
+
if request.user.is_staff and self.staff_are_never_forbidden:
|
|
158
|
+
return
|
|
159
|
+
if not self.accessible_contexts:
|
|
160
|
+
self.permission_denied(request)
|
|
161
|
+
else:
|
|
162
|
+
super().check_permissions(request)
|
|
163
|
+
|
|
164
|
+
def get_queryset(self):
|
|
165
|
+
"""
|
|
166
|
+
Expects `self.base_queryset` to be explicitly defined as the "base case"
|
|
167
|
+
and `self.list_lookup_field` to be defined.
|
|
168
|
+
You'll most likely want it to be `MyModel.objects.all().order_by('some_field')`.
|
|
169
|
+
"""
|
|
170
|
+
if getattr(self, 'base_queryset', None) is None:
|
|
171
|
+
raise Exception(f'{self.__class__} must have a non-null "base_queryset" field.')
|
|
172
|
+
if not getattr(self, 'list_lookup_field', None):
|
|
173
|
+
raise Exception(f'{self.__class__} must have a truthy "list_lookup_field" field.')
|
|
174
|
+
|
|
175
|
+
if self.request_action == 'list':
|
|
176
|
+
if not self.accessible_contexts:
|
|
177
|
+
return self.base_queryset.none()
|
|
178
|
+
if utils.has_access_to_all(self.accessible_contexts):
|
|
179
|
+
return self.base_queryset
|
|
180
|
+
kwargs = {self.list_lookup_field + '__in': self.accessible_contexts}
|
|
181
|
+
return self.base_queryset.filter(**kwargs)
|
|
182
|
+
|
|
183
|
+
return self.base_queryset
|
edx_rbac/models.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database models for edx_rbac.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
7
|
+
from django.db import models
|
|
8
|
+
from django.db.models.base import ModelBase
|
|
9
|
+
from django.utils.translation import gettext_lazy as _
|
|
10
|
+
from model_utils.models import TimeStampedModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserRoleAssignmentCreator(ModelBase):
|
|
14
|
+
"""
|
|
15
|
+
The model extending UserRoleAssignment should get a foreign key to a model that is a subclass of UserRole.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __new__(mcs, name, bases, attrs):
|
|
19
|
+
"""
|
|
20
|
+
Override to dynamically create foreign key for objects begotten from abstract class.
|
|
21
|
+
"""
|
|
22
|
+
model = super().__new__(mcs, name, bases, attrs)
|
|
23
|
+
if model.__name__ != 'UserRoleAssignment' and 'UserRoleAssignment' in [b.__name__ for b in bases]:
|
|
24
|
+
try:
|
|
25
|
+
model._meta.get_field('role')
|
|
26
|
+
except FieldDoesNotExist as error:
|
|
27
|
+
if model.role_class and issubclass(model.role_class, UserRole):
|
|
28
|
+
model.add_to_class(
|
|
29
|
+
'role',
|
|
30
|
+
models.ForeignKey(model.role_class, db_index=True, on_delete=models.CASCADE),
|
|
31
|
+
)
|
|
32
|
+
else:
|
|
33
|
+
raise Exception('role_class must be defined for any subclass of UserRole!') from error
|
|
34
|
+
return model
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UserRole(TimeStampedModel):
|
|
38
|
+
"""
|
|
39
|
+
Model defining a role a user can have.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
name = models.CharField(unique=True, max_length=255, db_index=True)
|
|
43
|
+
description = models.TextField(null=True, blank=True)
|
|
44
|
+
|
|
45
|
+
class Meta:
|
|
46
|
+
"""
|
|
47
|
+
Meta class for UserRole.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
abstract = True
|
|
51
|
+
|
|
52
|
+
def __str__(self):
|
|
53
|
+
"""
|
|
54
|
+
Return human-readable string representation.
|
|
55
|
+
"""
|
|
56
|
+
return self.name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class UserRoleAssignment(TimeStampedModel, metaclass=UserRoleAssignmentCreator):
|
|
60
|
+
"""
|
|
61
|
+
Model for mapping users and their roles.
|
|
62
|
+
"""
|
|
63
|
+
role_class = None
|
|
64
|
+
|
|
65
|
+
user = models.ForeignKey(settings.AUTH_USER_MODEL, db_index=True, on_delete=models.CASCADE)
|
|
66
|
+
|
|
67
|
+
applies_to_all_contexts = models.BooleanField(
|
|
68
|
+
default=False,
|
|
69
|
+
null=False,
|
|
70
|
+
help_text=_(
|
|
71
|
+
'If true, indicates that the user is effectively assigned their role for any and all contexts. '
|
|
72
|
+
'Defaults to False.'
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
class Meta:
|
|
77
|
+
"""
|
|
78
|
+
Meta class for UserRoleAssignment.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
abstract = True
|
|
82
|
+
|
|
83
|
+
def get_context(self):
|
|
84
|
+
"""
|
|
85
|
+
Return relevant context data related to this role assignment. Defaults to returning None.
|
|
86
|
+
"""
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def get_assignments(cls, user, role_names=None):
|
|
91
|
+
"""
|
|
92
|
+
Return iterator of (rolename, context).
|
|
93
|
+
"""
|
|
94
|
+
if not user.is_anonymous:
|
|
95
|
+
kwargs = {'user': user}
|
|
96
|
+
if role_names:
|
|
97
|
+
kwargs['role__name__in'] = role_names
|
|
98
|
+
|
|
99
|
+
for assignment in cls.objects.filter(**kwargs).select_related('role'):
|
|
100
|
+
yield assignment.role.name, assignment.get_context()
|
|
101
|
+
|
|
102
|
+
def __str__(self):
|
|
103
|
+
"""
|
|
104
|
+
Return human-readable string representation.
|
|
105
|
+
"""
|
|
106
|
+
# pylint: disable=no-member
|
|
107
|
+
return f'{self.user.id}:{self.role.name}'
|
|
108
|
+
|
|
109
|
+
def __repr__(self):
|
|
110
|
+
"""
|
|
111
|
+
Return uniquely identifying string representation.
|
|
112
|
+
"""
|
|
113
|
+
return self.__str__()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
{% comment %}
|
|
3
|
+
As the developer of this package, don't place anything here if you can help it
|
|
4
|
+
since this allows developers to have interoperability between your template
|
|
5
|
+
structure and their own.
|
|
6
|
+
|
|
7
|
+
Example: Developer melding the 2SoD pattern to fit inside with another pattern::
|
|
8
|
+
|
|
9
|
+
{% extends "base.html" %}
|
|
10
|
+
{% load static %}
|
|
11
|
+
|
|
12
|
+
<!-- Their site uses old school block layout -->
|
|
13
|
+
{% block extra_js %}
|
|
14
|
+
|
|
15
|
+
<!-- Your package using 2SoD block layout -->
|
|
16
|
+
{% block javascript %}
|
|
17
|
+
<script src="{% static 'js/ninja.js' %}" type="text/javascript"></script>
|
|
18
|
+
{% endblock javascript %}
|
|
19
|
+
|
|
20
|
+
{% endblock extra_js %}
|
|
21
|
+
{% endcomment %}
|
|
22
|
+
|