django-minosse 1.0.0__tar.gz
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.
- django_minosse-1.0.0/PKG-INFO +12 -0
- django_minosse-1.0.0/README.md +4 -0
- django_minosse-1.0.0/pyproject.toml +145 -0
- django_minosse-1.0.0/setup.cfg +4 -0
- django_minosse-1.0.0/src/django_minosse.egg-info/PKG-INFO +12 -0
- django_minosse-1.0.0/src/django_minosse.egg-info/SOURCES.txt +17 -0
- django_minosse-1.0.0/src/django_minosse.egg-info/dependency_links.txt +1 -0
- django_minosse-1.0.0/src/django_minosse.egg-info/requires.txt +1 -0
- django_minosse-1.0.0/src/django_minosse.egg-info/top_level.txt +1 -0
- django_minosse-1.0.0/src/minosse/__init__.py +0 -0
- django_minosse-1.0.0/src/minosse/decorator.py +30 -0
- django_minosse-1.0.0/src/minosse/management/__init__.py +0 -0
- django_minosse-1.0.0/src/minosse/management/commands/__init__.py +0 -0
- django_minosse-1.0.0/src/minosse/management/commands/sync_roles.py +66 -0
- django_minosse-1.0.0/src/minosse/mixin.py +30 -0
- django_minosse-1.0.0/src/minosse/roles.py +99 -0
- django_minosse-1.0.0/tests/test_auth.py +183 -0
- django_minosse-1.0.0/tests/test_roles.py +183 -0
- django_minosse-1.0.0/tests/test_sync_roles.py +124 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-minosse
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Role and permission management for Django projects.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: django>=5.0.0
|
|
8
|
+
|
|
9
|
+
# django-template
|
|
10
|
+
My personal Django template project.
|
|
11
|
+
|
|
12
|
+
This a blank django template for my project. Build around [this post](https://victoria.dev/blog/my-django-project-best-practices-for-happy-developers/) and other find online.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-minosse"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Role and permission management for Django projects."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = ["django>=5.0.0"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
[dependency-groups]
|
|
11
|
+
dev = [
|
|
12
|
+
"django-stubs>=6.0.5",
|
|
13
|
+
"git-cliff>=2.12.0",
|
|
14
|
+
"pre-commit>=4.3.0",
|
|
15
|
+
"pylint>=3.3.8",
|
|
16
|
+
"pylint-django>=2.6.1",
|
|
17
|
+
"pytest>=8.4.1",
|
|
18
|
+
"pytest-django>=4.11.1",
|
|
19
|
+
"zensical>=0.0.45",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
25
|
+
pythonpath = ["src", "."]
|
|
26
|
+
|
|
27
|
+
[tool.black]
|
|
28
|
+
line-length = 79
|
|
29
|
+
include = '\.pyi?$'
|
|
30
|
+
exclude = '''
|
|
31
|
+
/(
|
|
32
|
+
\.git
|
|
33
|
+
| \.hg
|
|
34
|
+
| \.mypy_cache
|
|
35
|
+
| \.tox
|
|
36
|
+
| \.venv
|
|
37
|
+
| _build
|
|
38
|
+
| buck-out
|
|
39
|
+
| build
|
|
40
|
+
| dist
|
|
41
|
+
)/
|
|
42
|
+
'''
|
|
43
|
+
|
|
44
|
+
[tool.isort]
|
|
45
|
+
profile = "black"
|
|
46
|
+
|
|
47
|
+
[tool.skjold]
|
|
48
|
+
sources = ["pyup", "gemnasium"] # Sources to check against.
|
|
49
|
+
report_only = true # Report only, always exit with zero.
|
|
50
|
+
cache_dir = '.skylt_cache' # Cache location (default: `~/.skjold/cache`).
|
|
51
|
+
cache_expires = 86400 # Cache max. age.
|
|
52
|
+
verbose = true # Be verbose.
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
# Set the maximum line length to 79.
|
|
56
|
+
line-length = 79
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
# Add the `line-too-long` rule to the enforced rule set. By default, Ruff omits rules that
|
|
60
|
+
# overlap with the use of a formatter, like Black, but we can override this behavior by
|
|
61
|
+
# explicitly adding the rule.
|
|
62
|
+
extend-select = [
|
|
63
|
+
"E501",
|
|
64
|
+
"UP", # pyupgrade
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint.pydocstyle]
|
|
68
|
+
convention = "google"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
[tool.flake8]
|
|
72
|
+
# Check that this is aligned with your other tools like Black
|
|
73
|
+
max-line-length = 120
|
|
74
|
+
max-complexity = 18
|
|
75
|
+
exclude = [".git", "*migrations*", ".tox", ".venv", ".env"]
|
|
76
|
+
# Use extend-ignore to add to already ignored checks which are anti-patterns like W503.
|
|
77
|
+
extend-ignore = ["E501", "W503", "F403", "C901", "B904"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
[tool.git-cliff.changelog]
|
|
81
|
+
# A Tera template to be rendered as the changelog's header.
|
|
82
|
+
# See https://keats.github.io/tera/docs/#introduction
|
|
83
|
+
header = """
|
|
84
|
+
# Changelog\n
|
|
85
|
+
All notable changes to this project will be documented in this file.\n
|
|
86
|
+
"""
|
|
87
|
+
# A Tera template to be rendered for each release in the changelog.
|
|
88
|
+
# See https://keats.github.io/tera/docs/#introduction
|
|
89
|
+
body = """
|
|
90
|
+
{% if version %}\
|
|
91
|
+
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
|
92
|
+
{% else %}\
|
|
93
|
+
## 👨🏻💻🧋 Unreleased
|
|
94
|
+
{% endif %}\
|
|
95
|
+
{% for group, commits in commits | unique(attribute="message") | group_by(attribute="group") %}
|
|
96
|
+
### {{ group | upper_first }}
|
|
97
|
+
{% for commit in commits %}
|
|
98
|
+
- {{ commit.message | split(pat="\n") | first | upper_first | trim_end }}\
|
|
99
|
+
{% endfor %}
|
|
100
|
+
{% endfor %}
|
|
101
|
+
"""
|
|
102
|
+
# A Tera template to be rendered as the changelog's footer.
|
|
103
|
+
# See https://keats.github.io/tera/docs/#introduction
|
|
104
|
+
footer = """
|
|
105
|
+
<!-- generated by git-cliff -->
|
|
106
|
+
"""
|
|
107
|
+
# Remove leading and trailing whitespaces from the changelog's body.
|
|
108
|
+
trim = true
|
|
109
|
+
|
|
110
|
+
[tool.git-cliff.git]
|
|
111
|
+
# Parse commits according to the conventional commits specification.
|
|
112
|
+
# See https://www.conventionalcommits.org
|
|
113
|
+
conventional_commits = true
|
|
114
|
+
# Exclude commits that do not match the conventional commits specification.
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
commit_parsers = [
|
|
118
|
+
|
|
119
|
+
{ message = ".\\d+\\.\\d+\\.\\d+", skip = true },
|
|
120
|
+
{ message = "^Merge '[^']+' into 'master'", skip = true },
|
|
121
|
+
{ message = "^chore\\(release\\): prepare for", skip = true },
|
|
122
|
+
{ message = ".*format code with Black.*", skip = true },
|
|
123
|
+
|
|
124
|
+
{ message = "^feat", group = "🚀 Features" },
|
|
125
|
+
{ message = "^fix", group = "🐛 Bug Fixes" },
|
|
126
|
+
{ message = "^doc", group = "📝 Docs" },
|
|
127
|
+
{ message = "^perf", group = "🧰 Improvements" },
|
|
128
|
+
{ message = "^refactor", group = "🧰 Improvements" },
|
|
129
|
+
{ message = "^style", group = "💄 Style" },
|
|
130
|
+
{ message = "^test", group = "🧪 Testing" },
|
|
131
|
+
{ message = "^chore", group = "Miscellaneous Tasks" },
|
|
132
|
+
{ body = ".*security", group = "🔒 Security" },
|
|
133
|
+
{ body = ".*", group = "❓ Other (unconventional)" },
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
# An array of regex based parsers for extracting data from the commit message.filter_unconventional = false
|
|
137
|
+
# Assigns commits to groups.
|
|
138
|
+
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
|
|
139
|
+
# Exclude commits that are not matched by any commit parser.
|
|
140
|
+
filter_commits = true
|
|
141
|
+
# Order releases topologically instead of chronologically.
|
|
142
|
+
topo_order = false
|
|
143
|
+
# Order of commits in each group/release within the changelog.
|
|
144
|
+
# Allowed values: newest, oldest
|
|
145
|
+
sort_commits = "oldest"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-minosse
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Role and permission management for Django projects.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: django>=5.0.0
|
|
8
|
+
|
|
9
|
+
# django-template
|
|
10
|
+
My personal Django template project.
|
|
11
|
+
|
|
12
|
+
This a blank django template for my project. Build around [this post](https://victoria.dev/blog/my-django-project-best-practices-for-happy-developers/) and other find online.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/django_minosse.egg-info/PKG-INFO
|
|
4
|
+
src/django_minosse.egg-info/SOURCES.txt
|
|
5
|
+
src/django_minosse.egg-info/dependency_links.txt
|
|
6
|
+
src/django_minosse.egg-info/requires.txt
|
|
7
|
+
src/django_minosse.egg-info/top_level.txt
|
|
8
|
+
src/minosse/__init__.py
|
|
9
|
+
src/minosse/decorator.py
|
|
10
|
+
src/minosse/mixin.py
|
|
11
|
+
src/minosse/roles.py
|
|
12
|
+
src/minosse/management/__init__.py
|
|
13
|
+
src/minosse/management/commands/__init__.py
|
|
14
|
+
src/minosse/management/commands/sync_roles.py
|
|
15
|
+
tests/test_auth.py
|
|
16
|
+
tests/test_roles.py
|
|
17
|
+
tests/test_sync_roles.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django>=5.0.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
minosse
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
|
|
3
|
+
from django.core.exceptions import PermissionDenied
|
|
4
|
+
from minosse.roles import AbstractRole
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def role_required(role_class: AbstractRole):
|
|
8
|
+
def decorator(view_func):
|
|
9
|
+
@wraps(view_func)
|
|
10
|
+
def _wrapped_view(request, *args, **kwargs):
|
|
11
|
+
if not role_class.user_has_role(request.user):
|
|
12
|
+
raise PermissionDenied
|
|
13
|
+
return view_func(request, *args, **kwargs)
|
|
14
|
+
|
|
15
|
+
return _wrapped_view
|
|
16
|
+
|
|
17
|
+
return decorator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def permission_required(permission_codename: str):
|
|
21
|
+
def decorator(view_func):
|
|
22
|
+
@wraps(view_func)
|
|
23
|
+
def _wrapped_view(request, *args, **kwargs):
|
|
24
|
+
if not request.user.has_perm(permission_codename):
|
|
25
|
+
raise PermissionDenied
|
|
26
|
+
return view_func(request, *args, **kwargs)
|
|
27
|
+
|
|
28
|
+
return _wrapped_view
|
|
29
|
+
|
|
30
|
+
return decorator
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.core.management.base import BaseCommand
|
|
3
|
+
from django.core.management.base import CommandError
|
|
4
|
+
from django.utils.module_loading import import_string
|
|
5
|
+
from minosse.roles import RoleRegistry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Command(BaseCommand):
|
|
9
|
+
help = (
|
|
10
|
+
"Create or update Django groups and permissions for all registered"
|
|
11
|
+
" AbstractRole subclasses."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def add_arguments(self, parser):
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--registry",
|
|
17
|
+
dest="registry",
|
|
18
|
+
default=None,
|
|
19
|
+
help=(
|
|
20
|
+
"Dotted path to a RoleRegistry instance. "
|
|
21
|
+
"Overrides MINOSSE_ROLE_REGISTRY from settings."
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def handle(self, *args, **options):
|
|
26
|
+
registry_path = options.get("registry") or getattr(
|
|
27
|
+
settings, "MINOSSE_ROLE_REGISTRY", None
|
|
28
|
+
)
|
|
29
|
+
if not registry_path:
|
|
30
|
+
raise CommandError(
|
|
31
|
+
"No registry configured. Set MINOSSE_ROLE_REGISTRY in "
|
|
32
|
+
"settings or pass --registry <dotted.path>."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
registry = import_string(registry_path)
|
|
37
|
+
except ImportError as exc:
|
|
38
|
+
raise CommandError(
|
|
39
|
+
f"Could not import registry '{registry_path}': {exc}"
|
|
40
|
+
) from exc
|
|
41
|
+
|
|
42
|
+
if not isinstance(registry, RoleRegistry):
|
|
43
|
+
raise CommandError(
|
|
44
|
+
f"'{registry_path}' must be a RoleRegistry instance, "
|
|
45
|
+
f"got {type(registry).__name__}."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
roles = registry.get_roles()
|
|
49
|
+
if not roles:
|
|
50
|
+
self.stdout.write(
|
|
51
|
+
self.style.WARNING("No roles registered. Nothing to sync.")
|
|
52
|
+
)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
self.stdout.write(f"Syncing {len(roles)} role(s)...")
|
|
56
|
+
for role in roles:
|
|
57
|
+
group = role.get_group()
|
|
58
|
+
perm_count = group.permissions.count()
|
|
59
|
+
self.stdout.write(
|
|
60
|
+
self.style.SUCCESS(
|
|
61
|
+
f" OK {role._get_group_name()!r} "
|
|
62
|
+
f"({perm_count} permission(s))"
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self.stdout.write(self.style.SUCCESS("Done."))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
2
|
+
from minosse.roles import AbstractRole
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RoleRequiredMixin(LoginRequiredMixin):
|
|
6
|
+
required_role_class: type[AbstractRole] | None = None
|
|
7
|
+
|
|
8
|
+
def dispatch(self, request, *args, **kwargs):
|
|
9
|
+
if self.required_role_class is None:
|
|
10
|
+
raise ValueError(
|
|
11
|
+
"required_role_class must be set to a subclass of "
|
|
12
|
+
"AbstractRole."
|
|
13
|
+
)
|
|
14
|
+
if not self.required_role_class.user_has_role(request.user):
|
|
15
|
+
return self.handle_no_permission()
|
|
16
|
+
return super().dispatch(request, *args, **kwargs)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PermissionRequiredMixin(LoginRequiredMixin):
|
|
20
|
+
required_permission_codename: str | None = None
|
|
21
|
+
|
|
22
|
+
def dispatch(self, request, *args, **kwargs):
|
|
23
|
+
if self.required_permission_codename is None:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"required_permission_codename must be set to a valid "
|
|
26
|
+
"permission codename."
|
|
27
|
+
)
|
|
28
|
+
if not request.user.has_perm(self.required_permission_codename):
|
|
29
|
+
return self.handle_no_permission()
|
|
30
|
+
return super().dispatch(request, *args, **kwargs)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth.models import Group
|
|
4
|
+
from django.contrib.auth.models import Permission
|
|
5
|
+
from django.contrib.contenttypes.models import ContentType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RoleRegistry:
|
|
9
|
+
"""Registry that collects AbstractRole subclasses for bulk sync."""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self._roles: list[type] = []
|
|
13
|
+
|
|
14
|
+
def register(self, role_class: type) -> type:
|
|
15
|
+
if role_class not in self._roles:
|
|
16
|
+
self._roles.append(role_class)
|
|
17
|
+
return role_class
|
|
18
|
+
|
|
19
|
+
def get_roles(self) -> list[type]:
|
|
20
|
+
return list(self._roles)
|
|
21
|
+
|
|
22
|
+
def sync(self) -> list[Group]:
|
|
23
|
+
return [role.get_group() for role in self._roles]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AbstractRole:
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def get_permissions_list(cls):
|
|
30
|
+
available_permissions: dict[str, bool] = getattr(
|
|
31
|
+
cls, "available_permissions", {}
|
|
32
|
+
)
|
|
33
|
+
available_permissions = {
|
|
34
|
+
k: v for k, v in available_permissions.items() if v is True
|
|
35
|
+
}
|
|
36
|
+
return available_permissions.keys()
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def _get_group_name(cls) -> str:
|
|
40
|
+
name: Any | str = getattr(cls, "group_name", cls.__name__)
|
|
41
|
+
return name
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def _get_permission(
|
|
45
|
+
cls, all_permissions_flag: bool = False
|
|
46
|
+
) -> list[Permission]:
|
|
47
|
+
available_permissions = getattr(cls, "available_permissions", {})
|
|
48
|
+
if all_permissions_flag is False:
|
|
49
|
+
available_permissions = {
|
|
50
|
+
k: v for k, v in available_permissions.items() if v is True
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
permissions = []
|
|
54
|
+
for perm in cls.get_permissions_list():
|
|
55
|
+
content_type, _ = ContentType.objects.get_or_create(
|
|
56
|
+
app_label="minosse",
|
|
57
|
+
model="role",
|
|
58
|
+
)
|
|
59
|
+
permission, _ = Permission.objects.get_or_create(
|
|
60
|
+
codename=perm,
|
|
61
|
+
content_type=content_type,
|
|
62
|
+
defaults={"name": available_permissions.get(perm, perm)},
|
|
63
|
+
)
|
|
64
|
+
permissions.append(permission)
|
|
65
|
+
return permissions
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def _set_group_permissions(cls, group: Group):
|
|
69
|
+
group.permissions.clear()
|
|
70
|
+
group.permissions.add(*cls._get_permission())
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def get_group(cls) -> Group:
|
|
74
|
+
group_name = cls._get_group_name()
|
|
75
|
+
group, _ = Group.objects.get_or_create(name=group_name)
|
|
76
|
+
cls._set_group_permissions(group)
|
|
77
|
+
return group
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def add_user_to_role(cls, user):
|
|
81
|
+
group = cls.get_group()
|
|
82
|
+
if group:
|
|
83
|
+
user.groups.add(group)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def remove_user_from_role(cls, user):
|
|
87
|
+
group = cls.get_group()
|
|
88
|
+
if group:
|
|
89
|
+
user.groups.remove(group)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def user_has_role(cls, user):
|
|
93
|
+
if not user.is_authenticated:
|
|
94
|
+
return False
|
|
95
|
+
group_name = getattr(cls, "group_name", cls.__name__)
|
|
96
|
+
return user.groups.filter(name=group_name).exists()
|
|
97
|
+
|
|
98
|
+
class Meta:
|
|
99
|
+
abstract = True
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.contrib.auth.models import AnonymousUser
|
|
3
|
+
from django.contrib.auth.models import User
|
|
4
|
+
from django.core.exceptions import PermissionDenied
|
|
5
|
+
from django.http import HttpResponse
|
|
6
|
+
from django.test import RequestFactory
|
|
7
|
+
from django.views import View
|
|
8
|
+
from minosse.decorator import permission_required
|
|
9
|
+
from minosse.decorator import role_required
|
|
10
|
+
from minosse.mixin import PermissionRequiredMixin
|
|
11
|
+
from minosse.mixin import RoleRequiredMixin
|
|
12
|
+
from minosse.roles import AbstractRole
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AdminRole(AbstractRole):
|
|
16
|
+
available_permissions = {"can_admin": True}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EditorRole(AbstractRole):
|
|
20
|
+
available_permissions = {"can_edit_content": True}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def simple_view(request):
|
|
24
|
+
return HttpResponse("ok")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SimpleView(View):
|
|
28
|
+
def get(self, request, *args, **kwargs):
|
|
29
|
+
return HttpResponse("ok")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.django_db
|
|
33
|
+
class TestRoleRequiredDecorator:
|
|
34
|
+
def setup_method(self):
|
|
35
|
+
self.factory = RequestFactory()
|
|
36
|
+
self.view = role_required(AdminRole)(simple_view)
|
|
37
|
+
|
|
38
|
+
def test_allows_user_with_role(self):
|
|
39
|
+
user = User.objects.create_user(username="admin", password="pass")
|
|
40
|
+
AdminRole.add_user_to_role(user)
|
|
41
|
+
request = self.factory.get("/")
|
|
42
|
+
request.user = user
|
|
43
|
+
assert self.view(request).status_code == 200
|
|
44
|
+
|
|
45
|
+
def test_raises_permission_denied_for_authenticated_without_role(self):
|
|
46
|
+
user = User.objects.create_user(username="other", password="pass")
|
|
47
|
+
request = self.factory.get("/")
|
|
48
|
+
request.user = user
|
|
49
|
+
with pytest.raises(PermissionDenied):
|
|
50
|
+
self.view(request)
|
|
51
|
+
|
|
52
|
+
def test_raises_permission_denied_for_anonymous(self):
|
|
53
|
+
request = self.factory.get("/")
|
|
54
|
+
request.user = AnonymousUser()
|
|
55
|
+
with pytest.raises(PermissionDenied):
|
|
56
|
+
self.view(request)
|
|
57
|
+
|
|
58
|
+
def test_preserves_view_function_name(self):
|
|
59
|
+
assert role_required(AdminRole)(simple_view).__name__ == "simple_view"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.django_db
|
|
63
|
+
class TestPermissionRequiredDecorator:
|
|
64
|
+
def setup_method(self):
|
|
65
|
+
self.factory = RequestFactory()
|
|
66
|
+
self.view = permission_required("minosse.can_edit_content")(
|
|
67
|
+
simple_view
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def test_allows_user_with_permission(self):
|
|
71
|
+
user = User.objects.create_user(username="editor", password="pass")
|
|
72
|
+
EditorRole.add_user_to_role(user)
|
|
73
|
+
user = User.objects.get(pk=user.pk) # clear Django's permission cache
|
|
74
|
+
request = self.factory.get("/")
|
|
75
|
+
request.user = user
|
|
76
|
+
assert self.view(request).status_code == 200
|
|
77
|
+
|
|
78
|
+
def test_raises_permission_denied_without_permission(self):
|
|
79
|
+
user = User.objects.create_user(username="viewer", password="pass")
|
|
80
|
+
request = self.factory.get("/")
|
|
81
|
+
request.user = user
|
|
82
|
+
with pytest.raises(PermissionDenied):
|
|
83
|
+
self.view(request)
|
|
84
|
+
|
|
85
|
+
def test_raises_permission_denied_for_anonymous(self):
|
|
86
|
+
request = self.factory.get("/")
|
|
87
|
+
request.user = AnonymousUser()
|
|
88
|
+
with pytest.raises(PermissionDenied):
|
|
89
|
+
self.view(request)
|
|
90
|
+
|
|
91
|
+
def test_preserves_view_function_name(self):
|
|
92
|
+
assert (
|
|
93
|
+
permission_required("any.perm")(simple_view).__name__
|
|
94
|
+
== "simple_view"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.django_db
|
|
99
|
+
class TestRoleRequiredMixin:
|
|
100
|
+
def setup_method(self):
|
|
101
|
+
self.factory = RequestFactory()
|
|
102
|
+
|
|
103
|
+
def _make_view(self, role_class):
|
|
104
|
+
class TestView(RoleRequiredMixin, SimpleView):
|
|
105
|
+
required_role_class = role_class
|
|
106
|
+
|
|
107
|
+
return TestView.as_view()
|
|
108
|
+
|
|
109
|
+
def test_allows_user_with_role(self):
|
|
110
|
+
user = User.objects.create_user(username="admin2", password="pass")
|
|
111
|
+
AdminRole.add_user_to_role(user)
|
|
112
|
+
request = self.factory.get("/")
|
|
113
|
+
request.user = user
|
|
114
|
+
assert self._make_view(AdminRole)(request).status_code == 200
|
|
115
|
+
|
|
116
|
+
def test_raises_permission_denied_for_authenticated_without_role(self):
|
|
117
|
+
user = User.objects.create_user(username="norole", password="pass")
|
|
118
|
+
request = self.factory.get("/")
|
|
119
|
+
request.user = user
|
|
120
|
+
with pytest.raises(PermissionDenied):
|
|
121
|
+
self._make_view(AdminRole)(request)
|
|
122
|
+
|
|
123
|
+
def test_redirects_anonymous_user(self):
|
|
124
|
+
request = self.factory.get("/protected/")
|
|
125
|
+
request.user = AnonymousUser()
|
|
126
|
+
response = self._make_view(AdminRole)(request)
|
|
127
|
+
assert response.status_code == 302
|
|
128
|
+
|
|
129
|
+
def test_raises_value_error_when_role_class_not_set(self):
|
|
130
|
+
class MisconfiguredView(RoleRequiredMixin, SimpleView):
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
request = self.factory.get("/")
|
|
134
|
+
request.user = AnonymousUser()
|
|
135
|
+
with pytest.raises(ValueError):
|
|
136
|
+
MisconfiguredView.as_view()(request)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.django_db
|
|
140
|
+
class TestPermissionRequiredMixin:
|
|
141
|
+
def setup_method(self):
|
|
142
|
+
self.factory = RequestFactory()
|
|
143
|
+
|
|
144
|
+
def _make_view(self, perm):
|
|
145
|
+
class TestView(PermissionRequiredMixin, SimpleView):
|
|
146
|
+
required_permission_codename = perm
|
|
147
|
+
|
|
148
|
+
return TestView.as_view()
|
|
149
|
+
|
|
150
|
+
def test_allows_user_with_permission(self):
|
|
151
|
+
user = User.objects.create_user(username="editor2", password="pass")
|
|
152
|
+
EditorRole.add_user_to_role(user)
|
|
153
|
+
user = User.objects.get(pk=user.pk) # clear Django's permission cache
|
|
154
|
+
request = self.factory.get("/")
|
|
155
|
+
request.user = user
|
|
156
|
+
assert (
|
|
157
|
+
self._make_view("minosse.can_edit_content")(request).status_code
|
|
158
|
+
== 200
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def test_raises_permission_denied_for_authenticated_without_permission(
|
|
162
|
+
self,
|
|
163
|
+
):
|
|
164
|
+
user = User.objects.create_user(username="noperm", password="pass")
|
|
165
|
+
request = self.factory.get("/")
|
|
166
|
+
request.user = user
|
|
167
|
+
with pytest.raises(PermissionDenied):
|
|
168
|
+
self._make_view("minosse.can_edit_content")(request)
|
|
169
|
+
|
|
170
|
+
def test_redirects_anonymous_user(self):
|
|
171
|
+
request = self.factory.get("/protected/")
|
|
172
|
+
request.user = AnonymousUser()
|
|
173
|
+
response = self._make_view("minosse.can_edit_content")(request)
|
|
174
|
+
assert response.status_code == 302
|
|
175
|
+
|
|
176
|
+
def test_raises_value_error_when_codename_not_set(self):
|
|
177
|
+
class MisconfiguredView(PermissionRequiredMixin, SimpleView):
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
request = self.factory.get("/")
|
|
181
|
+
request.user = AnonymousUser()
|
|
182
|
+
with pytest.raises(ValueError):
|
|
183
|
+
MisconfiguredView.as_view()(request)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.contrib.auth.models import AnonymousUser
|
|
3
|
+
from django.contrib.auth.models import Group
|
|
4
|
+
from django.contrib.auth.models import Permission
|
|
5
|
+
from django.contrib.auth.models import User
|
|
6
|
+
from django.contrib.contenttypes.models import ContentType
|
|
7
|
+
from minosse.roles import AbstractRole
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SampleRole(AbstractRole):
|
|
11
|
+
available_permissions = {
|
|
12
|
+
"can_view": True,
|
|
13
|
+
"can_edit": False,
|
|
14
|
+
"not a_permission": "True",
|
|
15
|
+
"info": "This is just an informational entry, not a permission",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NamedRole(AbstractRole):
|
|
20
|
+
group_name = "custom_group"
|
|
21
|
+
available_permissions = {"can_manage": True}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestGetPermissionsList:
|
|
25
|
+
def test_returns_permission_keys(self):
|
|
26
|
+
assert set(SampleRole.get_permissions_list()) == {
|
|
27
|
+
"can_view",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def test_empty_when_no_permissions_defined(self):
|
|
31
|
+
class EmptyRole(AbstractRole):
|
|
32
|
+
available_permissions = {}
|
|
33
|
+
|
|
34
|
+
assert list(EmptyRole.get_permissions_list()) == []
|
|
35
|
+
|
|
36
|
+
def test_uses_class_available_permissions(self):
|
|
37
|
+
assert "can_manage" in NamedRole.get_permissions_list()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestGetGroupName:
|
|
41
|
+
def test_returns_class_name_by_default(self):
|
|
42
|
+
assert SampleRole._get_group_name() == "SampleRole"
|
|
43
|
+
|
|
44
|
+
def test_returns_custom_group_name(self):
|
|
45
|
+
assert NamedRole._get_group_name() == "custom_group"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.django_db
|
|
49
|
+
class TestGetPermission:
|
|
50
|
+
def test_returns_permission_objects_for_true_permissions(self):
|
|
51
|
+
perms = SampleRole._get_permission()
|
|
52
|
+
assert {p.codename for p in perms} == {"can_view"}
|
|
53
|
+
|
|
54
|
+
def test_excludes_false_and_non_bool_values(self):
|
|
55
|
+
perms = SampleRole._get_permission()
|
|
56
|
+
codenames = {p.codename for p in perms}
|
|
57
|
+
assert "can_edit" not in codenames
|
|
58
|
+
assert "info" not in codenames
|
|
59
|
+
|
|
60
|
+
def test_returns_empty_list_when_all_permissions_are_false(self):
|
|
61
|
+
class AllFalseRole(AbstractRole):
|
|
62
|
+
available_permissions = {"can_view": False, "can_edit": False}
|
|
63
|
+
|
|
64
|
+
assert AllFalseRole._get_permission() == []
|
|
65
|
+
|
|
66
|
+
def test_all_permissions_flag_true_yields_same_result(self):
|
|
67
|
+
default = {p.codename for p in SampleRole._get_permission()}
|
|
68
|
+
with_flag = {
|
|
69
|
+
p.codename
|
|
70
|
+
for p in SampleRole._get_permission(all_permissions_flag=True)
|
|
71
|
+
}
|
|
72
|
+
assert default == with_flag
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.django_db
|
|
76
|
+
class TestGetGroup:
|
|
77
|
+
def test_creates_group_on_first_call(self):
|
|
78
|
+
SampleRole.get_group()
|
|
79
|
+
assert Group.objects.filter(name="SampleRole").exists()
|
|
80
|
+
|
|
81
|
+
def test_creates_group_with_permissions(self):
|
|
82
|
+
SampleRole.get_group()
|
|
83
|
+
group = Group.objects.get(name="SampleRole")
|
|
84
|
+
codenames = set(group.permissions.values_list("codename", flat=True))
|
|
85
|
+
assert codenames == {"can_view"}
|
|
86
|
+
|
|
87
|
+
def test_false_permissions_excluded_from_group(self):
|
|
88
|
+
SampleRole.get_group()
|
|
89
|
+
group = Group.objects.get(name="SampleRole")
|
|
90
|
+
codenames = set(group.permissions.values_list("codename", flat=True))
|
|
91
|
+
assert "can_edit" not in codenames
|
|
92
|
+
assert "info" not in codenames
|
|
93
|
+
|
|
94
|
+
def test_idempotent_when_called_multiple_times(self):
|
|
95
|
+
SampleRole.get_group()
|
|
96
|
+
SampleRole.get_group()
|
|
97
|
+
assert Group.objects.filter(name="SampleRole").count() == 1
|
|
98
|
+
|
|
99
|
+
def test_removes_extra_permissions_from_group(self):
|
|
100
|
+
group = SampleRole.get_group()
|
|
101
|
+
ct, _ = ContentType.objects.get_or_create(
|
|
102
|
+
app_label="auth", model="user"
|
|
103
|
+
)
|
|
104
|
+
extra, _ = Permission.objects.get_or_create(
|
|
105
|
+
codename="extra_perm",
|
|
106
|
+
content_type=ct,
|
|
107
|
+
defaults={"name": "Extra Permission"},
|
|
108
|
+
)
|
|
109
|
+
group.permissions.add(extra)
|
|
110
|
+
assert group.permissions.filter(codename="extra_perm").exists()
|
|
111
|
+
|
|
112
|
+
SampleRole.get_group()
|
|
113
|
+
group.refresh_from_db()
|
|
114
|
+
codenames = set(group.permissions.values_list("codename", flat=True))
|
|
115
|
+
assert codenames == {"can_view"}
|
|
116
|
+
|
|
117
|
+
def test_returns_group_object(self):
|
|
118
|
+
group = SampleRole.get_group()
|
|
119
|
+
assert group is not None
|
|
120
|
+
assert group.name == "SampleRole"
|
|
121
|
+
|
|
122
|
+
def test_creates_group_implicitly(self):
|
|
123
|
+
class FreshRole(AbstractRole):
|
|
124
|
+
available_permissions = {}
|
|
125
|
+
|
|
126
|
+
group = FreshRole.get_group()
|
|
127
|
+
assert group is not None
|
|
128
|
+
assert group.name == "FreshRole"
|
|
129
|
+
|
|
130
|
+
def test_returns_named_group(self):
|
|
131
|
+
group = NamedRole.get_group()
|
|
132
|
+
assert group is not None
|
|
133
|
+
assert group.name == "custom_group"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.mark.django_db
|
|
137
|
+
class TestAddRemoveUserFromRole:
|
|
138
|
+
def test_add_user_to_role(self):
|
|
139
|
+
user = User.objects.create_user(username="user1", password="pass")
|
|
140
|
+
SampleRole.add_user_to_role(user)
|
|
141
|
+
assert SampleRole.get_group() in user.groups.all()
|
|
142
|
+
|
|
143
|
+
def test_remove_user_from_role(self):
|
|
144
|
+
user = User.objects.create_user(username="user2", password="pass")
|
|
145
|
+
SampleRole.add_user_to_role(user)
|
|
146
|
+
assert SampleRole.get_group() in user.groups.all()
|
|
147
|
+
SampleRole.remove_user_from_role(user)
|
|
148
|
+
assert SampleRole.get_group() not in user.groups.all()
|
|
149
|
+
|
|
150
|
+
def test_add_user_creates_group_if_needed(self):
|
|
151
|
+
class OrphanRole(AbstractRole):
|
|
152
|
+
available_permissions = {}
|
|
153
|
+
|
|
154
|
+
user = User.objects.create_user(username="user3", password="pass")
|
|
155
|
+
OrphanRole.add_user_to_role(user)
|
|
156
|
+
assert user.groups.filter(name="OrphanRole").exists()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.mark.django_db
|
|
160
|
+
class TestUserHasRole:
|
|
161
|
+
def test_returns_true_for_user_with_role(self):
|
|
162
|
+
user = User.objects.create_user(username="member", password="pass")
|
|
163
|
+
SampleRole.add_user_to_role(user)
|
|
164
|
+
assert SampleRole.user_has_role(user) is True
|
|
165
|
+
|
|
166
|
+
def test_returns_false_for_user_without_role(self):
|
|
167
|
+
user = User.objects.create_user(username="outsider", password="pass")
|
|
168
|
+
assert SampleRole.user_has_role(user) is False
|
|
169
|
+
|
|
170
|
+
def test_returns_false_for_anonymous_user(self):
|
|
171
|
+
assert SampleRole.user_has_role(AnonymousUser()) is False
|
|
172
|
+
|
|
173
|
+
def test_role_check_is_group_specific(self):
|
|
174
|
+
user = User.objects.create_user(username="manager", password="pass")
|
|
175
|
+
NamedRole.add_user_to_role(user)
|
|
176
|
+
assert NamedRole.user_has_role(user) is True
|
|
177
|
+
assert SampleRole.user_has_role(user) is False
|
|
178
|
+
|
|
179
|
+
def test_returns_false_after_remove(self):
|
|
180
|
+
user = User.objects.create_user(username="ex_member", password="pass")
|
|
181
|
+
SampleRole.add_user_to_role(user)
|
|
182
|
+
SampleRole.remove_user_from_role(user)
|
|
183
|
+
assert SampleRole.user_has_role(user) is False
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from io import StringIO
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from django.contrib.auth.models import Group
|
|
5
|
+
from django.core.management import call_command
|
|
6
|
+
from django.core.management.base import CommandError
|
|
7
|
+
from minosse.roles import AbstractRole
|
|
8
|
+
from minosse.roles import RoleRegistry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EditorRole(AbstractRole):
|
|
12
|
+
group_name = "editors"
|
|
13
|
+
available_permissions = {"can_publish": True, "can_draft": True}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ViewerRole(AbstractRole):
|
|
17
|
+
group_name = "viewers"
|
|
18
|
+
available_permissions = {"can_read": True}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
test_registry = RoleRegistry()
|
|
22
|
+
test_registry.register(EditorRole)
|
|
23
|
+
test_registry.register(ViewerRole)
|
|
24
|
+
|
|
25
|
+
empty_registry = RoleRegistry()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestRoleRegistry:
|
|
29
|
+
def test_register_adds_role(self):
|
|
30
|
+
reg = RoleRegistry()
|
|
31
|
+
reg.register(EditorRole)
|
|
32
|
+
assert EditorRole in reg.get_roles()
|
|
33
|
+
|
|
34
|
+
def test_register_is_idempotent(self):
|
|
35
|
+
reg = RoleRegistry()
|
|
36
|
+
reg.register(EditorRole)
|
|
37
|
+
reg.register(EditorRole)
|
|
38
|
+
assert reg.get_roles().count(EditorRole) == 1
|
|
39
|
+
|
|
40
|
+
def test_register_returns_class(self):
|
|
41
|
+
reg = RoleRegistry()
|
|
42
|
+
result = reg.register(EditorRole)
|
|
43
|
+
assert result is EditorRole
|
|
44
|
+
|
|
45
|
+
def test_get_roles_returns_all(self):
|
|
46
|
+
reg = RoleRegistry()
|
|
47
|
+
reg.register(EditorRole)
|
|
48
|
+
reg.register(ViewerRole)
|
|
49
|
+
assert set(reg.get_roles()) == {EditorRole, ViewerRole}
|
|
50
|
+
|
|
51
|
+
@pytest.mark.django_db
|
|
52
|
+
def test_sync_creates_all_groups(self):
|
|
53
|
+
reg = RoleRegistry()
|
|
54
|
+
reg.register(EditorRole)
|
|
55
|
+
reg.register(ViewerRole)
|
|
56
|
+
groups = reg.sync()
|
|
57
|
+
assert {g.name for g in groups} == {"editors", "viewers"}
|
|
58
|
+
|
|
59
|
+
def test_register_as_decorator(self):
|
|
60
|
+
reg = RoleRegistry()
|
|
61
|
+
|
|
62
|
+
@reg.register
|
|
63
|
+
class TempRole(AbstractRole):
|
|
64
|
+
available_permissions = {}
|
|
65
|
+
|
|
66
|
+
assert TempRole in reg.get_roles()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.django_db
|
|
70
|
+
class TestSyncRolesCommand:
|
|
71
|
+
def _run(self, registry_path=None):
|
|
72
|
+
out = StringIO()
|
|
73
|
+
kwargs = {"stdout": out}
|
|
74
|
+
if registry_path:
|
|
75
|
+
kwargs["registry"] = registry_path
|
|
76
|
+
call_command("sync_roles", **kwargs)
|
|
77
|
+
return out.getvalue()
|
|
78
|
+
|
|
79
|
+
def test_syncs_all_registered_roles(self, settings):
|
|
80
|
+
settings.MINOSSE_ROLE_REGISTRY = "tests.test_sync_roles.test_registry"
|
|
81
|
+
output = self._run()
|
|
82
|
+
assert Group.objects.filter(name="editors").exists()
|
|
83
|
+
assert Group.objects.filter(name="viewers").exists()
|
|
84
|
+
assert "Done." in output
|
|
85
|
+
|
|
86
|
+
def test_output_shows_permission_count(self, settings):
|
|
87
|
+
settings.MINOSSE_ROLE_REGISTRY = "tests.test_sync_roles.test_registry"
|
|
88
|
+
output = self._run()
|
|
89
|
+
assert "'editors'" in output
|
|
90
|
+
assert "'viewers'" in output
|
|
91
|
+
|
|
92
|
+
def test_registry_flag_overrides_settings(self, settings):
|
|
93
|
+
settings.MINOSSE_ROLE_REGISTRY = "tests.test_sync_roles.empty_registry"
|
|
94
|
+
self._run(registry_path="tests.test_sync_roles.test_registry")
|
|
95
|
+
assert Group.objects.filter(name="editors").exists()
|
|
96
|
+
|
|
97
|
+
def test_empty_registry_prints_warning(self, settings):
|
|
98
|
+
settings.MINOSSE_ROLE_REGISTRY = "tests.test_sync_roles.empty_registry"
|
|
99
|
+
output = self._run()
|
|
100
|
+
assert "No roles registered" in output
|
|
101
|
+
|
|
102
|
+
def test_raises_when_no_registry_configured(self, settings):
|
|
103
|
+
if hasattr(settings, "MINOSSE_ROLE_REGISTRY"):
|
|
104
|
+
del settings.MINOSSE_ROLE_REGISTRY
|
|
105
|
+
with pytest.raises(CommandError, match="No registry configured"):
|
|
106
|
+
self._run()
|
|
107
|
+
|
|
108
|
+
def test_raises_on_invalid_import_path(self, settings):
|
|
109
|
+
settings.MINOSSE_ROLE_REGISTRY = "nonexistent.module.registry"
|
|
110
|
+
with pytest.raises(CommandError, match="Could not import registry"):
|
|
111
|
+
self._run()
|
|
112
|
+
|
|
113
|
+
def test_raises_when_object_is_not_registry(self, settings):
|
|
114
|
+
settings.MINOSSE_ROLE_REGISTRY = "tests.test_sync_roles.EditorRole"
|
|
115
|
+
with pytest.raises(
|
|
116
|
+
CommandError, match="must be a RoleRegistry instance"
|
|
117
|
+
):
|
|
118
|
+
self._run()
|
|
119
|
+
|
|
120
|
+
def test_sync_is_idempotent(self, settings):
|
|
121
|
+
settings.MINOSSE_ROLE_REGISTRY = "tests.test_sync_roles.test_registry"
|
|
122
|
+
self._run()
|
|
123
|
+
self._run()
|
|
124
|
+
assert Group.objects.filter(name="editors").count() == 1
|