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.
@@ -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,4 @@
1
+ # django-template
2
+ My personal Django template project.
3
+
4
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ django>=5.0.0
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
@@ -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