From df4e06f192fad0610c1f9d17b0c07bd7e149cde7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Snorre=20S=C3=A6ther?=
<112980079+Snorre98@users.noreply.github.com>
Date: Thu, 19 Sep 2024 22:34:57 +0200
Subject: [PATCH 1/7] 1386 create issue forms (#1387)
---
.github/ISSUE_TEMPLATE/1-feature.yml | 55 ++++++++++++++++++++++++++
.github/ISSUE_TEMPLATE/2-bug.yml | 59 ++++++++++++++++++++++++++++
2 files changed, 114 insertions(+)
create mode 100644 .github/ISSUE_TEMPLATE/1-feature.yml
create mode 100644 .github/ISSUE_TEMPLATE/2-bug.yml
diff --git a/.github/ISSUE_TEMPLATE/1-feature.yml b/.github/ISSUE_TEMPLATE/1-feature.yml
new file mode 100644
index 000000000..d4f7d4607
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/1-feature.yml
@@ -0,0 +1,55 @@
+name: 🚀 Feature Request
+description: Suggest an idea for this project
+title: "[FEATURE]
"
+labels: ["feature"]
+body:
+- type: checkboxes
+ attributes:
+ label: 👀 Is there an existing feature request for this?
+ description: Please search to see if a feature request already exists for the feature you are suggesting.
+ options:
+ - label: I have searched the existing feature requests
+ required: true
+- type: textarea
+ attributes:
+ label: 🌟 Feature statement
+ description: What is your feature request? Please describe.
+ placeholder: A clear and concise description of what the feature is.
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: 👨👩👦 Is the feature related to some other issue or PR?
+ description: Pleas give the isse or PR-number.
+ placeholder: "#issue-number" or "#PR-number"
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: 💡 Proposed Solution
+ description: Describe the solution you'd like
+ placeholder: A clear and concise description of what you want to happen.
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: 🔦 Additional Context
+ description: Add any other context or screenshots about the feature request here.
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: 🗺️ Where on Samfundet4 do you want the feature?
+ description: Pleas give a file, or general "area" of Samfundet4
+ validations:
+ required: true
+- type: dropdown
+ attributes:
+ label: Priority
+ description: How important is this feature to you?
+ options:
+ - Nice to have
+ - Important
+ - Critical
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/2-bug.yml b/.github/ISSUE_TEMPLATE/2-bug.yml
new file mode 100644
index 000000000..2a5d3bcf0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/2-bug.yml
@@ -0,0 +1,59 @@
+name: 🐞 Bug
+description: File a bug/issue
+title: "[BUG] "
+labels: ["Bug"]
+body:
+- type: checkboxes
+ attributes:
+ label: 👀 Is there an existing issue for this?
+ description: Please search to see if an issue already exists for the bug you encountered.
+ options:
+ - label: I have searched the existing issues
+ required: true
+- type: textarea
+ attributes:
+ label: 🚫 Current Behavior
+ description: A concise description of what you're experiencing.
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: ✅ Expected Behavior
+ description: A concise description of what you expected to happen.
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: 🧩 Steps To Reproduce
+ description: Steps to reproduce the behavior.
+ placeholder: |
+ 1. In this environment...
+ 1. With this config...
+ 1. Run '...'
+ 1. See error...
+ validations:
+ required: true
+- type: textarea
+ attributes:
+ label: 🐼 Environment
+ description: |
+ examples:
+ - **OS**: Ubuntu 20.04
+ - **Node**: 13.14.0
+ - **npm**: 7.6.3
+ value: |
+ - OS:
+ - Node:
+ - npm:
+ render: markdown
+ validations:
+ required: false
+- type: textarea
+ attributes:
+ label: 🦮 Anything else?
+ description: |
+ Links? References? Anything that will give us more context about the issue you are encountering!
+
+ Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
+ validations:
+ required: true
From b6b74771731b5d08705ec94a61a6bb2b4202f34b Mon Sep 17 00:00:00 2001
From: Robin <16273164+robines@users.noreply.github.com>
Date: Mon, 23 Sep 2024 16:20:21 +0200
Subject: [PATCH 2/7] Add user roles for organizations/gangs/sections (#1257)
* Create user roles for organizations/gangs/sections
* Fix user role admin names
* Type annotations
* Ruff format
* Remove content_type from Role
* Add remaining resolvers for recruitment, and org/gang/section
* Add working examples for Interview and InterviewRoom views
* ruff method order
* mypy
* Remove roles from user model
* Move to samfundet app
* ruff
* get_perm: support permissions without period
* Fix hierarchy permission checking
* Add some tests
* Remove RoleMixin to avoid unnecessary method calls and exception raising/catching
This means when the obj hasattr check is True, we can assume the method is implemented.
* Begin docs
* Update migration
* Add code examples to docs
* Add link to docs
* Fix import merge
* Update migrations
* Create fixture_organization2, update fixture_gang2 to use it
This lets us more easily test different organizational hierarchies
* Add fixture_gang_section2
* Update tests, and add hierarchy testing
* Revert changes in views
* ruff
* Remove RoleAuthBackend fixture for more readable code
* Add note about return_id to docs
* [skip ci] Add real-world example to docs
* Update example to a more "normal" model
* Update migration
---
backend/root/settings/base.py | 1 +
backend/root/utils/permissions.py | 20 ++
backend/root/utils/routes.py | 24 ++
backend/samfundet/admin.py | 22 ++
backend/samfundet/backend.py | 37 +++
backend/samfundet/conftest.py | 80 +++++-
...angrole_usergangsectionrole_userorgrole.py | 75 +++++
backend/samfundet/models/__init__.py | 4 +
backend/samfundet/models/general.py | 30 ++
backend/samfundet/models/recruitment.py | 57 ++++
backend/samfundet/models/role.py | 34 +++
backend/samfundet/tests/test_roles.py | 272 ++++++++++++++++++
backend/samfundet/utils.py | 11 +-
docs/technical/README.md | 1 +
docs/technical/backend/rolesystem.md | 85 ++++++
frontend/src/routes/backend.ts | 24 ++
16 files changed, 773 insertions(+), 4 deletions(-)
create mode 100644 backend/samfundet/backend.py
create mode 100644 backend/samfundet/migrations/0002_role_usergangrole_usergangsectionrole_userorgrole.py
create mode 100644 backend/samfundet/models/role.py
create mode 100644 backend/samfundet/tests/test_roles.py
create mode 100644 docs/technical/backend/rolesystem.md
diff --git a/backend/root/settings/base.py b/backend/root/settings/base.py
index 137c2857f..789200fa7 100644
--- a/backend/root/settings/base.py
+++ b/backend/root/settings/base.py
@@ -129,6 +129,7 @@
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend', # default
+ 'samfundet.backend.RoleAuthBackend',
]
# Password validation
diff --git a/backend/root/utils/permissions.py b/backend/root/utils/permissions.py
index 97fcad2ef..e907be694 100644
--- a/backend/root/utils/permissions.py
+++ b/backend/root/utils/permissions.py
@@ -278,6 +278,11 @@
SAMFUNDET_DELETE_RESERVATION = 'samfundet.delete_reservation'
SAMFUNDET_VIEW_RESERVATION = 'samfundet.view_reservation'
+SAMFUNDET_ADD_ROLE = 'samfundet.add_role'
+SAMFUNDET_CHANGE_ROLE = 'samfundet.change_role'
+SAMFUNDET_DELETE_ROLE = 'samfundet.delete_role'
+SAMFUNDET_VIEW_ROLE = 'samfundet.view_role'
+
SAMFUNDET_ADD_SAKSDOKUMENT = 'samfundet.add_saksdokument'
SAMFUNDET_CHANGE_SAKSDOKUMENT = 'samfundet.change_saksdokument'
SAMFUNDET_DELETE_SAKSDOKUMENT = 'samfundet.delete_saksdokument'
@@ -310,6 +315,21 @@
SAMFUNDET_DELETE_USERFEEDBACKMODEL = 'samfundet.delete_userfeedbackmodel'
SAMFUNDET_VIEW_USERFEEDBACKMODEL = 'samfundet.view_userfeedbackmodel'
+SAMFUNDET_ADD_USERGANGROLE = 'samfundet.add_usergangrole'
+SAMFUNDET_CHANGE_USERGANGROLE = 'samfundet.change_usergangrole'
+SAMFUNDET_DELETE_USERGANGROLE = 'samfundet.delete_usergangrole'
+SAMFUNDET_VIEW_USERGANGROLE = 'samfundet.view_usergangrole'
+
+SAMFUNDET_ADD_USERGANGSECTIONROLE = 'samfundet.add_usergangsectionrole'
+SAMFUNDET_CHANGE_USERGANGSECTIONROLE = 'samfundet.change_usergangsectionrole'
+SAMFUNDET_DELETE_USERGANGSECTIONROLE = 'samfundet.delete_usergangsectionrole'
+SAMFUNDET_VIEW_USERGANGSECTIONROLE = 'samfundet.view_usergangsectionrole'
+
+SAMFUNDET_ADD_USERORGROLE = 'samfundet.add_userorgrole'
+SAMFUNDET_CHANGE_USERORGROLE = 'samfundet.change_userorgrole'
+SAMFUNDET_DELETE_USERORGROLE = 'samfundet.delete_userorgrole'
+SAMFUNDET_VIEW_USERORGROLE = 'samfundet.view_userorgrole'
+
SAMFUNDET_ADD_USERPREFERENCE = 'samfundet.add_userpreference'
SAMFUNDET_CHANGE_USERPREFERENCE = 'samfundet.change_userpreference'
SAMFUNDET_DELETE_USERPREFERENCE = 'samfundet.delete_userpreference'
diff --git a/backend/root/utils/routes.py b/backend/root/utils/routes.py
index 5bd801646..8b0d4b2be 100644
--- a/backend/root/utils/routes.py
+++ b/backend/root/utils/routes.py
@@ -43,6 +43,30 @@
admin__auth_group_delete = 'admin:auth_group_delete'
admin__auth_group_change = 'admin:auth_group_change'
adminauthgroup__objectId = ''
+admin__samfundet_role_changelist = 'admin:samfundet_role_changelist'
+admin__samfundet_role_add = 'admin:samfundet_role_add'
+admin__samfundet_role_history = 'admin:samfundet_role_history'
+admin__samfundet_role_delete = 'admin:samfundet_role_delete'
+admin__samfundet_role_change = 'admin:samfundet_role_change'
+adminsamfundetrole__objectId = ''
+admin__samfundet_userorgrole_changelist = 'admin:samfundet_userorgrole_changelist'
+admin__samfundet_userorgrole_add = 'admin:samfundet_userorgrole_add'
+admin__samfundet_userorgrole_history = 'admin:samfundet_userorgrole_history'
+admin__samfundet_userorgrole_delete = 'admin:samfundet_userorgrole_delete'
+admin__samfundet_userorgrole_change = 'admin:samfundet_userorgrole_change'
+adminsamfundetuserorgrole__objectId = ''
+admin__samfundet_usergangrole_changelist = 'admin:samfundet_usergangrole_changelist'
+admin__samfundet_usergangrole_add = 'admin:samfundet_usergangrole_add'
+admin__samfundet_usergangrole_history = 'admin:samfundet_usergangrole_history'
+admin__samfundet_usergangrole_delete = 'admin:samfundet_usergangrole_delete'
+admin__samfundet_usergangrole_change = 'admin:samfundet_usergangrole_change'
+adminsamfundetusergangrole__objectId = ''
+admin__samfundet_usergangsectionrole_changelist = 'admin:samfundet_usergangsectionrole_changelist'
+admin__samfundet_usergangsectionrole_add = 'admin:samfundet_usergangsectionrole_add'
+admin__samfundet_usergangsectionrole_history = 'admin:samfundet_usergangsectionrole_history'
+admin__samfundet_usergangsectionrole_delete = 'admin:samfundet_usergangsectionrole_delete'
+admin__samfundet_usergangsectionrole_change = 'admin:samfundet_usergangsectionrole_change'
+adminsamfundetusergangsectionrole__objectId = ''
admin__auth_permission_permissions = 'admin:auth_permission_permissions'
admin__auth_permission_permissions_manage_user = 'admin:auth_permission_permissions_manage_user'
admin__auth_permission_permissions_manage_group = 'admin:auth_permission_permissions_manage_group'
diff --git a/backend/samfundet/admin.py b/backend/samfundet/admin.py
index 5007a52ae..3df2c2de2 100644
--- a/backend/samfundet/admin.py
+++ b/backend/samfundet/admin.py
@@ -21,6 +21,7 @@
CustomGuardedModelAdmin,
)
+from .models.role import Role, UserOrgRole, UserGangRole, UserGangSectionRole
from .models.event import Event, EventGroup, EventRegistration, PurchaseFeedbackModel
from .models.general import (
Tag,
@@ -161,6 +162,27 @@ def members(self, obj: Group) -> int:
return n
+@admin.register(Role)
+class RoleAdmin(admin.ModelAdmin):
+ list_display = ('name',)
+ filter_horizontal = ['permissions']
+
+
+@admin.register(UserOrgRole)
+class UserOrgRoleAdmin(admin.ModelAdmin):
+ list_display = ('user', 'role', 'obj')
+
+
+@admin.register(UserGangRole)
+class UserGangRoleAdmin(admin.ModelAdmin):
+ list_display = ('user', 'role', 'obj')
+
+
+@admin.register(UserGangSectionRole)
+class UserGangSectionRoleAdmin(admin.ModelAdmin):
+ list_display = ('user', 'role', 'obj')
+
+
@admin.register(Permission)
class PermissionAdmin(CustomGuardedModelAdmin):
# ordering = []
diff --git a/backend/samfundet/backend.py b/backend/samfundet/backend.py
new file mode 100644
index 000000000..7146afc59
--- /dev/null
+++ b/backend/samfundet/backend.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from typing import Any
+
+from django.contrib.auth.backends import BaseBackend
+
+from samfundet.utils import get_perm
+from samfundet.models import User
+from samfundet.models.role import UserOrgRole, UserGangRole, UserGangSectionRole
+
+
+class RoleAuthBackend(BaseBackend):
+ def has_perm(self, user_obj: User, perm: str, obj: Any = None) -> bool: # noqa: C901
+ if not user_obj.is_active or obj is None:
+ return False
+
+ if user_obj.is_superuser:
+ return True
+
+ permission = get_perm(perm=perm, model=obj)
+
+ if hasattr(obj, 'resolve_org'):
+ org_id = obj.resolve_org(return_id=True)
+ if org_id is not None and UserOrgRole.objects.filter(user=user_obj, obj__id=org_id, role__permissions=permission).exists():
+ return True
+
+ if hasattr(obj, 'resolve_gang'):
+ gang_id = obj.resolve_gang(return_id=True)
+ if gang_id is not None and UserGangRole.objects.filter(user=user_obj, obj__id=gang_id, role__permissions=permission).exists():
+ return True
+
+ if hasattr(obj, 'resolve_section'):
+ section_id = obj.resolve_section(return_id=True)
+ if section_id is not None and UserGangSectionRole.objects.filter(user=user_obj, obj__id=section_id, role__permissions=permission).exists():
+ return True
+
+ return False
diff --git a/backend/samfundet/conftest.py b/backend/samfundet/conftest.py
index 206cfed73..8efc8ff6e 100644
--- a/backend/samfundet/conftest.py
+++ b/backend/samfundet/conftest.py
@@ -11,12 +11,14 @@
from django.test import Client, TestCase
from django.utils import timezone
from django.core.files.images import ImageFile
-from django.contrib.auth.models import Group
+from django.contrib.auth.models import Group, Permission
+from django.contrib.contenttypes.models import ContentType
import root.management.commands.seed_scripts.billig as billig_seed
from root.settings import BASE_DIR
from samfundet.constants import DEV_PASSWORD
+from samfundet.models.role import Role
from samfundet.models.event import Event
from samfundet.models.billig import BilligEvent
from samfundet.models.general import (
@@ -29,6 +31,7 @@
Campus,
BlogPost,
TextItem,
+ GangSection,
Reservation,
Organization,
MerchVariation,
@@ -225,6 +228,13 @@ def fixture_organization() -> Iterator[Organization]:
organization.delete()
+@pytest.fixture
+def fixture_organization2() -> Iterator[Organization]:
+ organization = Organization.objects.create(name='UKA')
+ yield organization
+ organization.delete()
+
+
@pytest.fixture
def fixture_gang(fixture_organization: Organization) -> Iterator[Gang]:
organization = Gang.objects.create(
@@ -238,17 +248,81 @@ def fixture_gang(fixture_organization: Organization) -> Iterator[Gang]:
@pytest.fixture
-def fixture_gang2(fixture_organization: Organization) -> Iterator[Gang]:
+def fixture_gang2(fixture_organization2: Organization) -> Iterator[Gang]:
organization = Gang.objects.create(
name_nb='Gang 2',
name_en='Gang 2',
abbreviation='G2',
- organization=fixture_organization,
+ organization=fixture_organization2,
)
yield organization
organization.delete()
+@pytest.fixture
+def fixture_gang_section(fixture_gang: Gang) -> Iterator[GangSection]:
+ gang_section = GangSection.objects.create(
+ name_nb='Test Gang Section',
+ name_en='Test Gang Section',
+ gang=fixture_gang,
+ )
+ yield gang_section
+ gang_section.delete()
+
+
+@pytest.fixture
+def fixture_gang_section2(fixture_gang2: Gang) -> Iterator[GangSection]:
+ gang_section = GangSection.objects.create(
+ name_nb='Test Gang Section 2',
+ name_en='Test Gang Section 2',
+ gang=fixture_gang2,
+ )
+ yield gang_section
+ gang_section.delete()
+
+
+@pytest.fixture
+def fixture_role() -> Iterator[Role]:
+ role = Role.objects.create(
+ name='Test Role',
+ )
+ yield role
+ role.delete()
+
+
+@pytest.fixture
+def fixture_org_permission() -> Iterator[Permission]:
+ permission = Permission.objects.create(
+ name='Test Org Permission',
+ codename='test_org_permission',
+ content_type=ContentType.objects.get_for_model(Organization),
+ )
+ yield permission
+ permission.delete()
+
+
+@pytest.fixture
+def fixture_gang_permission() -> Iterator[Permission]:
+ permission = Permission.objects.create(
+ name='Test Gang Permission',
+ codename='test_gang_permission',
+ content_type=ContentType.objects.get_for_model(Gang),
+ )
+ yield permission
+ permission.delete()
+
+
+@pytest.fixture
+def fixture_gang_section_permission() -> Iterator[Permission]:
+ permission = Permission.objects.create(
+ name='Test Gang Section Permission',
+ codename='test_gang_section_permission',
+ content_type=ContentType.objects.get_for_model(GangSection),
+ )
+ yield permission
+ permission.delete()
+
+
@pytest.fixture
def fixture_text_item() -> Iterator[TextItem]:
text_item = TextItem.objects.create(
diff --git a/backend/samfundet/migrations/0002_role_usergangrole_usergangsectionrole_userorgrole.py b/backend/samfundet/migrations/0002_role_usergangrole_usergangsectionrole_userorgrole.py
new file mode 100644
index 000000000..8f72f9cd5
--- /dev/null
+++ b/backend/samfundet/migrations/0002_role_usergangrole_usergangsectionrole_userorgrole.py
@@ -0,0 +1,75 @@
+# Generated by Django 5.0.7 on 2024-09-17 18:27
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ('samfundet', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Role',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=255)),
+ ('permissions', models.ManyToManyField(to='auth.permission')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='UserGangRole',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('version', models.PositiveIntegerField(blank=True, default=0, editable=False, null=True)),
+ ('created_at', models.DateTimeField(blank=True, editable=False, null=True)),
+ ('updated_at', models.DateTimeField(blank=True, editable=False, null=True)),
+ ('created_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+ ('obj', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.gang')),
+ ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.role')),
+ ('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='UserGangSectionRole',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('version', models.PositiveIntegerField(blank=True, default=0, editable=False, null=True)),
+ ('created_at', models.DateTimeField(blank=True, editable=False, null=True)),
+ ('updated_at', models.DateTimeField(blank=True, editable=False, null=True)),
+ ('created_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+ ('obj', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.gangsection')),
+ ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.role')),
+ ('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='UserOrgRole',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('version', models.PositiveIntegerField(blank=True, default=0, editable=False, null=True)),
+ ('created_at', models.DateTimeField(blank=True, editable=False, null=True)),
+ ('updated_at', models.DateTimeField(blank=True, editable=False, null=True)),
+ ('created_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+ ('obj', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.organization')),
+ ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.role')),
+ ('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/backend/samfundet/models/__init__.py b/backend/samfundet/models/__init__.py
index 9b8a85b70..8e427388b 100644
--- a/backend/samfundet/models/__init__.py
+++ b/backend/samfundet/models/__init__.py
@@ -12,12 +12,16 @@
User,
Image,
Profile,
+ GangSection,
+ Organization,
UserPreference,
)
__all__ = [
'User',
'Gang',
+ 'GangSection',
+ 'Organization',
'Event',
'Image',
'Profile',
diff --git a/backend/samfundet/models/general.py b/backend/samfundet/models/general.py
index 51c2741da..92a29a6e0 100644
--- a/backend/samfundet/models/general.py
+++ b/backend/samfundet/models/general.py
@@ -273,6 +273,11 @@ class Meta:
verbose_name = 'Organization'
verbose_name_plural = 'Organizations'
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ if return_id:
+ return self.id
+ return self
+
def __str__(self) -> str:
return self.name
@@ -323,6 +328,17 @@ class Meta:
verbose_name = 'Gang'
verbose_name_plural = 'Gangs'
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ if return_id:
+ # noinspection PyTypeChecker
+ return self.organization_id
+ return self.organization
+
+ def resolve_gang(self, *, return_id: bool = False) -> Gang | int:
+ if return_id:
+ return self.id
+ return self
+
def __str__(self) -> str:
return f'{self.gang_type} - {self.name_nb}'
@@ -333,6 +349,20 @@ class GangSection(CustomBaseModel):
logo = models.ForeignKey(Image, on_delete=models.PROTECT, blank=True, null=True, verbose_name='Logo')
gang = models.ForeignKey(Gang, blank=False, null=False, related_name='gang', on_delete=models.PROTECT, verbose_name='Gjeng')
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.gang.resolve_org(return_id=return_id)
+
+ def resolve_gang(self, *, return_id: bool = False) -> Gang | int:
+ if return_id:
+ # noinspection PyTypeChecker
+ return self.gang_id
+ return self.gang
+
+ def resolve_section(self, *, return_id: bool = False) -> GangSection | int:
+ if return_id:
+ return self.id
+ return self
+
def __str__(self) -> str:
return f'{self.gang.name_nb} - {self.name_nb}'
diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py
index 52ccc137c..b7d351329 100644
--- a/backend/samfundet/models/recruitment.py
+++ b/backend/samfundet/models/recruitment.py
@@ -32,6 +32,12 @@ class Recruitment(CustomBaseModel):
max_applications = models.PositiveIntegerField(null=True, blank=True, verbose_name='Max applications per applicant')
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ if return_id:
+ # noinspection PyTypeChecker
+ return self.organization_id
+ return self.organization
+
def recruitment_progress(self) -> float:
applications = RecruitmentApplication.objects.filter(recruitment=self)
if applications.count() == 0:
@@ -155,6 +161,15 @@ class RecruitmentPosition(CustomBaseModel):
# TODO: Implement interviewer functionality
interviewers = models.ManyToManyField(to=User, help_text='Interviewers for the position', blank=True, related_name='interviewers')
+ def resolve_gang(self, *, return_id: bool = False) -> Gang | int:
+ if return_id:
+ # noinspection PyTypeChecker
+ return self.gang_id
+ return self.gang
+
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.gang.resolve_org(return_id=return_id)
+
def __str__(self) -> str:
return f'Position: {self.name_en} in {self.recruitment}'
@@ -184,6 +199,9 @@ class RecruitmentSeparatePosition(CustomBaseModel):
blank=True,
)
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.recruitment.resolve_org(return_id=return_id)
+
def __str__(self) -> str:
return f'Seperate recruitment: {self.name_nb} ({self.recruitment})'
@@ -199,6 +217,15 @@ class InterviewRoom(CustomBaseModel):
def __str__(self) -> str:
return self.name
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.recruitment.resolve_org(return_id=return_id)
+
+ def resolve_gang(self, *, return_id: bool = False) -> Gang | int:
+ if return_id:
+ # noinspection PyTypeChecker
+ return self.gang_id
+ return self.gang
+
def clean(self) -> None:
super().clean()
@@ -223,6 +250,12 @@ class Interview(CustomBaseModel):
interviewers = models.ManyToManyField(to='samfundet.User', help_text='Interviewers for this interview', blank=True, related_name='interviews')
notes = models.TextField(help_text='Notes for the interview', null=True, blank=True)
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.room.resolve_org(return_id=return_id)
+
+ def resolve_gang(self, *, return_id: bool = False) -> Gang | int:
+ return self.room.resolve_gang(return_id=return_id)
+
class RecruitmentApplication(CustomBaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -253,6 +286,12 @@ class RecruitmentApplication(CustomBaseModel):
choices=RecruitmentApplicantStates.choices, default=RecruitmentApplicantStates.NOT_SET, help_text='The state of the applicant for the recruiter'
)
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.recruitment.resolve_org(return_id=return_id)
+
+ def resolve_gang(self, *, return_id: bool = False) -> Gang | int:
+ return self.recruitment_position.resolve_gang(return_id=return_id)
+
def organize_priorities(self) -> None:
"""Organizes priorites from 1 to n, so that it is sequential with no gaps"""
applications_for_user = RecruitmentApplication.objects.filter(recruitment=self.recruitment, user=self.user).order_by('applicant_priority')
@@ -383,6 +422,9 @@ class RecruitmentInterviewAvailability(CustomBaseModel):
end_time = models.TimeField(help_text='Last possible time of day for interviews', default='23:00:00', null=False, blank=False)
timeslot_interval = models.PositiveSmallIntegerField(help_text='The time interval (in minutes) between each timeslot', default=30)
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.recruitment.resolve_org(return_id=return_id)
+
class OccupiedTimeslot(FullCleanSaveMixin):
user = models.ForeignKey(
@@ -403,6 +445,9 @@ class OccupiedTimeslot(FullCleanSaveMixin):
class Meta:
constraints = [models.UniqueConstraint(fields=['user', 'recruitment', 'start_dt', 'end_dt'], name='occupied_UNIQ')]
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.recruitment.resolve_org(return_id=return_id)
+
class RecruitmentStatistics(FullCleanSaveMixin):
recruitment = models.OneToOneField(Recruitment, on_delete=models.CASCADE, blank=True, null=True, related_name='statistics')
@@ -422,6 +467,9 @@ def save(self, *args: tuple, **kwargs: dict) -> None:
def __str__(self) -> str:
return f'{self.recruitment} stats'
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.recruitment.resolve_org(return_id=return_id)
+
def generate_time_stats(self) -> None:
for h in range(0, 24):
time_stat, created = RecruitmentTimeStat.objects.get_or_create(recruitment_stats=self, hour=h)
@@ -465,6 +513,9 @@ def save(self, *args: tuple, **kwargs: dict) -> None:
self.count = count
super().save(*args, **kwargs)
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.recruitment_stats.resolve_org(return_id=return_id)
+
class RecruitmentDateStat(models.Model):
recruitment_stats = models.ForeignKey(RecruitmentStatistics, on_delete=models.CASCADE, blank=False, null=False, related_name='date_stats')
@@ -482,6 +533,9 @@ def save(self, *args: tuple, **kwargs: dict) -> None:
self.count = count
super().save(*args, **kwargs)
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.recruitment_stats.resolve_org(return_id=return_id)
+
class RecruitmentCampusStat(models.Model):
recruitment_stats = models.ForeignKey(RecruitmentStatistics, on_delete=models.CASCADE, blank=False, null=False, related_name='campus_stats')
@@ -498,6 +552,9 @@ def save(self, *args: tuple, **kwargs: dict) -> None:
).count()
super().save(*args, **kwargs)
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.recruitment_stats.resolve_org(return_id=return_id)
+
class RecruitmentGangStat(models.Model):
recruitment_stats = models.ForeignKey(RecruitmentStatistics, on_delete=models.CASCADE, blank=False, null=False, related_name='gang_stats')
diff --git a/backend/samfundet/models/role.py b/backend/samfundet/models/role.py
new file mode 100644
index 000000000..826ffab14
--- /dev/null
+++ b/backend/samfundet/models/role.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from django.db import models
+from django.conf import settings
+
+from root.utils.mixins import CustomBaseModel
+
+
+class Role(models.Model):
+ name = models.CharField(max_length=255)
+ permissions = models.ManyToManyField('auth.Permission')
+
+ def __str__(self) -> str:
+ return self.name
+
+
+class UserRoleBase(CustomBaseModel):
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+ role = models.ForeignKey(Role, on_delete=models.CASCADE)
+
+ class Meta:
+ abstract = True
+
+
+class UserOrgRole(UserRoleBase):
+ obj = models.ForeignKey('samfundet.Organization', on_delete=models.CASCADE)
+
+
+class UserGangRole(UserRoleBase):
+ obj = models.ForeignKey('samfundet.Gang', on_delete=models.CASCADE)
+
+
+class UserGangSectionRole(UserRoleBase):
+ obj = models.ForeignKey('samfundet.GangSection', on_delete=models.CASCADE)
diff --git a/backend/samfundet/tests/test_roles.py b/backend/samfundet/tests/test_roles.py
new file mode 100644
index 000000000..859ecee98
--- /dev/null
+++ b/backend/samfundet/tests/test_roles.py
@@ -0,0 +1,272 @@
+from __future__ import annotations
+
+from django.contrib.auth.models import Permission
+
+from samfundet.models import Gang, User, GangSection, Organization
+from samfundet.backend import RoleAuthBackend
+from samfundet.models.role import Role, UserOrgRole, UserGangRole, UserGangSectionRole
+
+
+def test_has_perm_superuser(fixture_superuser: User, fixture_organization: Organization, fixture_org_permission: Permission):
+ """Test that superusers have permissions to all resources even without any roles."""
+ backend = RoleAuthBackend()
+ assert backend.has_perm(fixture_superuser, fixture_org_permission.codename, fixture_organization)
+
+
+def test_has_perm_inactive_user(
+ fixture_user: User,
+ fixture_organization: Organization,
+ fixture_org_permission: Permission,
+):
+ backend = RoleAuthBackend()
+ assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization)
+
+
+def test_has_perm_inactive_user_with_role(fixture_user: User, fixture_organization: Organization, fixture_org_permission: Permission, fixture_role: Role):
+ """Test that inactive users who would otherwise have access to a resource, don't."""
+ backend = RoleAuthBackend()
+ fixture_role.permissions.add(fixture_org_permission)
+ UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization)
+ fixture_user.is_active = False
+ assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization)
+
+
+def test_has_perm_no_obj(fixture_user: User, fixture_org_permission: Permission):
+ backend = RoleAuthBackend()
+ assert not backend.has_perm(fixture_user, fixture_org_permission.codename, None)
+
+
+def test_has_perm_superuser_no_obj(fixture_superuser: User, fixture_org_permission: Permission):
+ backend = RoleAuthBackend()
+ assert not backend.has_perm(fixture_superuser, fixture_org_permission.codename, None)
+
+
+def test_has_perm_user_with_no_roles(
+ fixture_user: User,
+ fixture_role: Role,
+ fixture_organization: Organization,
+ fixture_gang: Gang,
+ fixture_gang_section: GangSection,
+ fixture_org_permission: Permission,
+ fixture_gang_permission: Permission,
+ fixture_gang_section_permission: Permission,
+):
+ backend = RoleAuthBackend()
+ """Sanity check. Within the scope of our auth backend, a user with no roles should have no permissions,
+ on any hierarchical level."""
+
+ # Create a role with permission to our example resources (org/gang/section), but don't attach it to user.
+ fixture_role.permissions.add(fixture_org_permission)
+ fixture_role.permissions.add(fixture_gang_permission)
+ fixture_role.permissions.add(fixture_gang_section_permission)
+
+ assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization)
+ assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang)
+ assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section)
+
+
+def test_has_perm_user_with_org_role(fixture_user: User, fixture_role: Role, fixture_organization: Organization, fixture_org_permission: Permission):
+ backend = RoleAuthBackend()
+ """Test that giving user an OrgRole with permission to access a resource, actually gives them access."""
+ fixture_role.permissions.add(fixture_org_permission)
+ UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization)
+
+ assert backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization)
+
+
+def test_has_perm_user_with_gang_role(fixture_user: User, fixture_role: Role, fixture_gang: Gang, fixture_gang_permission: Permission):
+ backend = RoleAuthBackend()
+ """Test that giving user a GangRole with permission to access a resource, actually gives them access."""
+ fixture_role.permissions.add(fixture_gang_permission)
+ UserGangRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang)
+
+ assert backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang)
+
+
+def test_has_perm_user_with_section_role(
+ fixture_user: User,
+ fixture_role: Role,
+ fixture_gang_section: GangSection,
+ fixture_gang_section_permission: Permission,
+):
+ backend = RoleAuthBackend()
+ """Test that giving user a GangSectionRole with permission to access a resource, actually gives them access."""
+ fixture_role.permissions.add(fixture_gang_section_permission)
+ UserGangSectionRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang_section)
+
+ assert backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section)
+
+
+def test_has_perm_different_orgs(
+ fixture_user: User,
+ fixture_organization: Organization,
+ fixture_organization2: Organization,
+ fixture_org_permission: Permission,
+ fixture_role: Role,
+):
+ backend = RoleAuthBackend()
+ """Test that giving user a role to a specific org, does not give it to other orgs"""
+ fixture_role.permissions.add(fixture_org_permission)
+
+ assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization)
+
+ UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization)
+
+ assert backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization)
+ assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization2)
+
+
+def test_has_perm_different_gangs(
+ fixture_user: User,
+ fixture_gang: Gang,
+ fixture_gang2: Gang,
+ fixture_gang_permission: Permission,
+ fixture_role: Role,
+):
+ backend = RoleAuthBackend()
+ """Test that giving user a role to a specific gang, does not give it to other gangs"""
+ fixture_role.permissions.add(fixture_gang_permission)
+
+ assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang)
+
+ UserGangRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang)
+
+ assert backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang)
+ assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang2)
+
+
+def test_has_perm_different_gang_sections(
+ fixture_user: User,
+ fixture_gang_section: GangSection,
+ fixture_gang_section2: Gang,
+ fixture_gang_section_permission: Permission,
+ fixture_role: Role,
+):
+ backend = RoleAuthBackend()
+ """Test that giving user a role to a specific gang section, does not give it to other gang sections"""
+ fixture_role.permissions.add(fixture_gang_section_permission)
+
+ assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section)
+
+ UserGangSectionRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang_section)
+
+ assert backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section)
+ assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section2)
+
+
+def test_has_perm_different_users(
+ fixture_user: User,
+ fixture_user2: User,
+ fixture_organization: Organization,
+ fixture_gang: Gang,
+ fixture_gang_section: GangSection,
+ fixture_org_permission: Permission,
+ fixture_gang_permission: Permission,
+ fixture_gang_section_permission: Permission,
+ fixture_role: Role,
+):
+ backend = RoleAuthBackend()
+ """Test that giving user a role, does not give it to other users"""
+ fixture_role.permissions.add(fixture_org_permission)
+ fixture_role.permissions.add(fixture_gang_permission)
+ fixture_role.permissions.add(fixture_gang_section_permission)
+
+ UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization)
+ UserGangRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang)
+ UserGangSectionRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang_section)
+
+ assert not backend.has_perm(fixture_user2, fixture_org_permission.codename, fixture_organization)
+ assert not backend.has_perm(fixture_user2, fixture_gang_permission.codename, fixture_gang)
+ assert not backend.has_perm(fixture_user2, fixture_gang_section_permission.codename, fixture_gang_section)
+
+
+def test_has_perm_org_downward(
+ fixture_user: User,
+ fixture_organization: Organization,
+ fixture_organization2: Organization,
+ fixture_gang: Gang,
+ fixture_gang2: Gang,
+ fixture_gang_section: GangSection,
+ fixture_role: Role,
+ fixture_org_permission: Permission,
+ fixture_gang_permission: Permission,
+ fixture_gang_section_permission: Permission,
+):
+ backend = RoleAuthBackend()
+ """Test that giving permission on org/gang level, also gives it downwards (gang/section)."""
+ fixture_role.permissions.add(fixture_gang_section_permission)
+ fixture_role.permissions.add(fixture_gang_permission)
+
+ fixture_gang.organization = fixture_organization
+ fixture_gang_section.gang = fixture_gang
+
+ # Giving a user an Org role should give the same permissions on Gang and Section levels
+
+ org_role = UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization)
+
+ assert backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang)
+ assert backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section)
+
+ org_role.delete()
+
+ # Permissions should be gone after deleting org role
+
+ assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang)
+ assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section)
+
+ # Giving a user a Gang role should give the same permissions on Section level
+
+ gang_role = UserGangRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang)
+
+ assert backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section)
+
+ gang_role.delete()
+
+ assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section)
+
+ # Give the user the Org role again, and ensure that after we detach Gang and Section from the Organization,
+ # we no longer have permissions.
+ UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization)
+
+ assert backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section)
+
+ fixture_gang_section.gang = fixture_gang2
+
+ assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section)
+
+ assert backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang)
+
+ fixture_gang.organization = fixture_organization2
+
+ assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang)
+
+
+def test_has_perm_section_upward(
+ fixture_user: User,
+ fixture_organization: Organization,
+ fixture_gang: Gang,
+ fixture_gang_section: GangSection,
+ fixture_role: Role,
+ fixture_org_permission: Permission,
+ fixture_gang_permission: Permission,
+ fixture_gang_section_permission: Permission,
+):
+ backend = RoleAuthBackend()
+ """Test that giving permission on section/gang level, does not give it upwards (gang/org)."""
+ fixture_role.permissions.add(fixture_org_permission)
+ fixture_role.permissions.add(fixture_gang_permission)
+ fixture_role.permissions.add(fixture_gang_section_permission)
+
+ fixture_gang.organization = fixture_organization
+ fixture_gang_section.gang = fixture_gang
+
+ section_role = UserGangSectionRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang_section)
+
+ assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang)
+ assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization)
+
+ section_role.delete()
+
+ UserGangRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang)
+
+ assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization)
diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py
index 0d4fda08b..6755ad78d 100644
--- a/backend/samfundet/utils.py
+++ b/backend/samfundet/utils.py
@@ -3,10 +3,12 @@
import datetime
from django.http import QueryDict
-from django.db.models import Q
+from django.db.models import Q, Model
from django.utils.timezone import make_aware
from django.core.exceptions import ValidationError
from django.db.models.query import QuerySet
+from django.contrib.auth.models import Permission
+from django.contrib.contenttypes.models import ContentType
from .models import User
from .models.event import Event
@@ -90,3 +92,10 @@ def get_occupied_timeslots_from_request(
occupied_timeslots.append(OccupiedTimeslot(user=user, recruitment=recruitment, start_dt=start_date, end_dt=end_date))
return occupied_timeslots
+
+
+def get_perm(*, perm: str, model: type[Model]) -> Permission:
+ codename = perm.split('.')[1] if '.' in perm else perm
+ content_type = ContentType.objects.get_for_model(model=model)
+ permission = Permission.objects.get(codename=codename, content_type=content_type)
+ return permission
diff --git a/docs/technical/README.md b/docs/technical/README.md
index 1d5752bbf..feb7553cd 100644
--- a/docs/technical/README.md
+++ b/docs/technical/README.md
@@ -14,6 +14,7 @@
- [Billig (payment system)](/docs/technical/backend/billig.md)
- [Seed scripts](/docs/technical/backend/seed.md)
+- [Role System](/docs/technical/backend/rolesystem.md)
### Pipelines & Deployment
- [Pipeline (mypy, eslint, tsc, ...)](/docs/technical/pipeline.md)
diff --git a/docs/technical/backend/rolesystem.md b/docs/technical/backend/rolesystem.md
new file mode 100644
index 000000000..833e73633
--- /dev/null
+++ b/docs/technical/backend/rolesystem.md
@@ -0,0 +1,85 @@
+# Role System
+
+The role system in Samfundet4 builds on the Django "authentication backend" concept. Our system adds
+a [custom auth backend](https://docs.djangoproject.com/en/5.0/topics/auth/customizing/). The goal of the system is to
+enable us to answer queries like:
+
+> Does Bob have access to edit case document X, which belongs to the UKA organization?
+>
+>Does Bob have access to view recruitment position X, which belongs to Web (a section of MG in the Samfundet
+> organization)
+
+The system uses hierarchical permission checking. It first checks if a user has a permission to a specific object on the
+Organizational level, then Gang level, and finally the Gang Section level. This means that if a user has a specific
+permission for an object on the Organizational level, they also have it on the Gang and Gang Section levels. And if a
+user has it on the Gang level, they also have it on the Gang Section level.
+
+## Real-world example
+
+Before we get into the technical details of the system, it's important to know how the system is used, so here's a
+real-world example.
+
+Say we have a "Interviewer" role. This role gives permissions to view and manage interviews in a recruitment. If the
+user is given this role on the Organization level, it means they are able to manage absolutely all interviews for gangs
+and sections which belong to the organization. If they are given the role on the Gang level, they are able to manage all
+interviews for the gang and the gang's sections. And finally, if they are given the role on the Gang Section level, they
+are only able to manage interviews belonging to the gang section.
+
+## Organization/Gang/Section resolvers
+
+For the auth backend to know what organization an object belongs to, models need to implement
+the `resolve_org`/`resolve_gang`/`resolve_section` methods. The purpose of these resolvers is to return the
+org/gang/section the object belongs to. Models implementing these methods may not have ambiguous ownership, or be owned
+by multiple orgs/gangs/sections.
+
+> Example: The Venue model doesn't implement these methods, as they aren't "owned" by anybody
+>
+> Example: A Recruitment is owned by an organization, therefore `resolve_org` returns that organization.
+
+These resolver methods only have a single parameter: `return_id`. The purpose of this argument is to avoid having to
+unnecessarily fetch a whole instance from the database, when we only need the ID. All models which implement the
+resolvers must respect this argument if possible.
+
+Here's an example implementation of `resolve_org` for the Recruitment model:
+
+```python
+class Recruitment(CustomBaseModel):
+ ...
+ organization = models.ForeignKey(to=Organization)
+
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ if return_id:
+ return self.organization_id
+ return self.organization
+```
+
+And another example, showing how models can just call each other's resolvers to greatly simplify things:
+
+```python
+class RecruitmentPosition(CustomBaseModel):
+ ...
+ gang = models.ForeignKey(to=Gang)
+
+ def resolve_org(self, *, return_id: bool = False) -> Organization | int:
+ return self.gang.resolve_org(return_id=return_id)
+```
+
+## Role
+
+A Role simply contains a name and a list of permissions. An "Interviewer" Role may for example contain permissions
+related to interviews, interview rooms, applications, etc.
+
+## Organization/Gang/Section User Roles
+
+To tie users together to roles, we use either `UserOrgRole`/`UserGangRole`/`UserGangSectionRole`. These models contain
+three fields: user, role, and object. For `UserOrgRole`, the obj will be an instance of Organization, for `UserGangRole`
+it'll be an instance of Gang, and for `UserGangSectionRole` it'll be an instance of a GangSection.
+
+To reiterate: if we create a `UserOrgRole` instance, it gives the user all of the role's permissions for the given
+organization.
+
+## Inheritance
+
+There is no inheritance in our system, as that often leads to unnecessary complexity, both in the code and in our mental
+understanding of how a specific role – or a set of roles – operates. If something is wrong with a role's permissions,
+you can simply fix it then and there, instead of looking up and down the inheritance tree to see where the issue is.
diff --git a/frontend/src/routes/backend.ts b/frontend/src/routes/backend.ts
index 1ad8ec5b5..b1b8171d9 100644
--- a/frontend/src/routes/backend.ts
+++ b/frontend/src/routes/backend.ts
@@ -42,6 +42,30 @@ export const ROUTES_BACKEND = {
admin__auth_group_delete: '/admin/auth/group/:objectId/delete/',
admin__auth_group_change: '/admin/auth/group/:objectId/change/',
adminauthgroup__objectId: '/admin/auth/group/:objectId/',
+ admin__samfundet_role_changelist: '/admin/samfundet/role/',
+ admin__samfundet_role_add: '/admin/samfundet/role/add/',
+ admin__samfundet_role_history: '/admin/samfundet/role/:objectId/history/',
+ admin__samfundet_role_delete: '/admin/samfundet/role/:objectId/delete/',
+ admin__samfundet_role_change: '/admin/samfundet/role/:objectId/change/',
+ adminsamfundetrole__objectId: '/admin/samfundet/role/:objectId/',
+ admin__samfundet_userorgrole_changelist: '/admin/samfundet/userorgrole/',
+ admin__samfundet_userorgrole_add: '/admin/samfundet/userorgrole/add/',
+ admin__samfundet_userorgrole_history: '/admin/samfundet/userorgrole/:objectId/history/',
+ admin__samfundet_userorgrole_delete: '/admin/samfundet/userorgrole/:objectId/delete/',
+ admin__samfundet_userorgrole_change: '/admin/samfundet/userorgrole/:objectId/change/',
+ adminsamfundetuserorgrole__objectId: '/admin/samfundet/userorgrole/:objectId/',
+ admin__samfundet_usergangrole_changelist: '/admin/samfundet/usergangrole/',
+ admin__samfundet_usergangrole_add: '/admin/samfundet/usergangrole/add/',
+ admin__samfundet_usergangrole_history: '/admin/samfundet/usergangrole/:objectId/history/',
+ admin__samfundet_usergangrole_delete: '/admin/samfundet/usergangrole/:objectId/delete/',
+ admin__samfundet_usergangrole_change: '/admin/samfundet/usergangrole/:objectId/change/',
+ adminsamfundetusergangrole__objectId: '/admin/samfundet/usergangrole/:objectId/',
+ admin__samfundet_usergangsectionrole_changelist: '/admin/samfundet/usergangsectionrole/',
+ admin__samfundet_usergangsectionrole_add: '/admin/samfundet/usergangsectionrole/add/',
+ admin__samfundet_usergangsectionrole_history: '/admin/samfundet/usergangsectionrole/:objectId/history/',
+ admin__samfundet_usergangsectionrole_delete: '/admin/samfundet/usergangsectionrole/:objectId/delete/',
+ admin__samfundet_usergangsectionrole_change: '/admin/samfundet/usergangsectionrole/:objectId/change/',
+ adminsamfundetusergangsectionrole__objectId: '/admin/samfundet/usergangsectionrole/:objectId/',
admin__auth_permission_permissions: '/admin/auth/permission/:objectPk/permissions/',
admin__auth_permission_permissions_manage_user: '/admin/auth/permission/:objectPk/permissions/user-manage/:userId/',
admin__auth_permission_permissions_manage_group: '/admin/auth/permission/:objectPk/permissions/group-manage/:groupId/',
From e787700a3f41085e7f3babc6998a6661065ab431 Mon Sep 17 00:00:00 2001
From: Robin <16273164+robines@users.noreply.github.com>
Date: Mon, 23 Sep 2024 17:41:18 +0200
Subject: [PATCH 3/7] Fix run pipeline alias (#1389)
---
backend/aliases.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/aliases.sh b/backend/aliases.sh
index 6b1afc274..1548fd975 100755
--- a/backend/aliases.sh
+++ b/backend/aliases.sh
@@ -76,5 +76,5 @@ alias poetry-run-migrations-apply='poetry run python manage.py migrate' # Apply
alias poetry-run-collectstatic='poetry run python manage.py collectstatic --noinput' # Collect static files from apps to root of project.
alias poetry-run-pytest-run='poetry run pytest' # Run pytest on project.
-alias poetry-run-pipeline='poetry-mypy-run && poetry-ruff-check && poetry-ruff-format-check && poetry-migrations-verify && poetry-pytest-run' # Run all checks in pipeline.
+alias poetry-run-pipeline='poetry-run-mypy-run && poetry-run-ruff-check && poetry-run-ruff-format-check && poetry-run-migrations-verify && poetry-run-pytest-run' # Run all checks in pipeline.
alias poetry-run-seed='poetry run python manage.py seed' # Apply seed of database.
From 4aedc77c25f8df4aac37643d4b2799e648ae8963 Mon Sep 17 00:00:00 2001
From: Robin <16273164+robines@users.noreply.github.com>
Date: Mon, 23 Sep 2024 17:45:10 +0200
Subject: [PATCH 4/7] Remove gang-specific permission fields (#1388)
---
..._remove_gang_event_admin_group_and_more.py | 25 ++++++
backend/samfundet/models/general.py | 11 +--
backend/samfundet/signals.py | 46 +----------
backend/samfundet/tests/test_signals.py | 82 +------------------
4 files changed, 29 insertions(+), 135 deletions(-)
create mode 100644 backend/samfundet/migrations/0003_remove_gang_event_admin_group_and_more.py
diff --git a/backend/samfundet/migrations/0003_remove_gang_event_admin_group_and_more.py b/backend/samfundet/migrations/0003_remove_gang_event_admin_group_and_more.py
new file mode 100644
index 000000000..2a1e8ef8d
--- /dev/null
+++ b/backend/samfundet/migrations/0003_remove_gang_event_admin_group_and_more.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.1.1 on 2024-09-23 14:24
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('samfundet', '0002_role_usergangrole_usergangsectionrole_userorgrole'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='gang',
+ name='event_admin_group',
+ ),
+ migrations.RemoveField(
+ model_name='gang',
+ name='gang_leader_group',
+ ),
+ migrations.RemoveField(
+ model_name='gang',
+ name='recruitment_admin_group',
+ ),
+ ]
diff --git a/backend/samfundet/models/general.py b/backend/samfundet/models/general.py
index 92a29a6e0..1a9c25197 100644
--- a/backend/samfundet/models/general.py
+++ b/backend/samfundet/models/general.py
@@ -16,7 +16,7 @@
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
-from django.contrib.auth.models import Group, AbstractUser
+from django.contrib.auth.models import AbstractUser
from root.utils import permissions
from root.utils.mixins import CustomBaseModel, FullCleanSaveMixin
@@ -315,15 +315,6 @@ class Gang(CustomBaseModel):
gang_type = models.ForeignKey(to=GangType, related_name='gangs', verbose_name='Gruppetype', blank=True, null=True, on_delete=models.SET_NULL)
info_page = models.ForeignKey(to='samfundet.InformationPage', verbose_name='Infoside', blank=True, null=True, on_delete=models.SET_NULL)
- # Gang related permission groups
- gang_leader_group = models.OneToOneField(Group, related_name='gang_as_leader', verbose_name='Gangleder', blank=True, null=True, on_delete=models.SET_NULL)
- event_admin_group = models.OneToOneField(
- Group, related_name='gang_as_event_admin_group', verbose_name='Arrangementgruppe', blank=True, null=True, on_delete=models.SET_NULL
- )
- recruitment_admin_group = models.OneToOneField(
- Group, related_name='gang_as_recruitment_admin_group', verbose_name='Innganggruppe', blank=True, null=True, on_delete=models.SET_NULL
- )
-
class Meta:
verbose_name = 'Gang'
verbose_name_plural = 'Gangs'
diff --git a/backend/samfundet/signals.py b/backend/samfundet/signals.py
index 23dc22fd1..b52154997 100644
--- a/backend/samfundet/signals.py
+++ b/backend/samfundet/signals.py
@@ -2,14 +2,10 @@
from typing import Any
-from guardian.shortcuts import assign_perm, remove_perm
-
from django.dispatch import receiver
-from django.db.models.signals import pre_save, post_save, m2m_changed
-
-from samfundet.permissions import SAMFUNDET_CHANGE_EVENT, SAMFUNDET_DELETE_EVENT
+from django.db.models.signals import pre_save, post_save
-from .models import Gang, User, Event, Profile, UserPreference
+from .models import User, Profile, UserPreference
from .models.recruitment import Recruitment, RecruitmentStatistics, RecruitmentApplication
from .models.model_choices import RecruitmentStatusChoices
@@ -28,44 +24,6 @@ def create_profile(sender: User, instance: User, *, created: bool, **kwargs: Any
Profile.objects.get_or_create(user=instance)
-@receiver(m2m_changed, sender=Event.editors.through)
-def update_editor_permissions( # noqa: C901
- sender: User,
- instance: Event,
- action: str,
- model: Gang,
- pk_set: set[int],
- *,
- reverse: bool,
- **kwargs: dict,
-) -> None:
- if action in ['post_add', 'post_remove', 'post_clear']:
- current_editors: set[Gang] = set(instance.editors.all())
-
- # In the case of a removal or clear, the related objects have already been removed by the time the
- # signal handler is called, so we can calculate the set of removed objects by subtracting the current
- # set of related objects from the set of all related object primary keys.
- if action in ['post_remove', 'post_clear']:
- removed_gangs: set[Gang] = set(model.objects.filter(pk__in=pk_set)) - current_editors
- for gang in removed_gangs:
- if gang.event_admin_group:
- remove_perm(perm=SAMFUNDET_CHANGE_EVENT, user_or_group=gang.event_admin_group, obj=instance)
- remove_perm(perm=SAMFUNDET_DELETE_EVENT, user_or_group=gang.event_admin_group, obj=instance)
- if gang.gang_leader_group:
- remove_perm(perm=SAMFUNDET_CHANGE_EVENT, user_or_group=gang.gang_leader_group, obj=instance)
- remove_perm(perm=SAMFUNDET_DELETE_EVENT, user_or_group=gang.gang_leader_group, obj=instance)
-
- # In the case of an add, the related objects have already been added by the time the signal handler is called.
- if action == 'post_add':
- for gang in current_editors:
- if gang.event_admin_group:
- assign_perm(perm=SAMFUNDET_CHANGE_EVENT, user_or_group=gang.event_admin_group, obj=instance)
- assign_perm(perm=SAMFUNDET_DELETE_EVENT, user_or_group=gang.event_admin_group, obj=instance)
- if gang.gang_leader_group:
- assign_perm(perm=SAMFUNDET_CHANGE_EVENT, user_or_group=gang.gang_leader_group, obj=instance)
- assign_perm(perm=SAMFUNDET_DELETE_EVENT, user_or_group=gang.gang_leader_group, obj=instance)
-
-
@receiver(post_save, sender=Recruitment)
def create_recruitment_statistics(sender: Recruitment, instance: Recruitment, *, created: bool, **kwargs: Any) -> None:
"""Ensures stats are created when an recruitment is created"""
diff --git a/backend/samfundet/tests/test_signals.py b/backend/samfundet/tests/test_signals.py
index c3cacf56b..6a4de9dcc 100644
--- a/backend/samfundet/tests/test_signals.py
+++ b/backend/samfundet/tests/test_signals.py
@@ -1,10 +1,6 @@
from __future__ import annotations
-from guardian.shortcuts import get_perms
-
-from django.contrib.auth.models import Group
-
-from samfundet.models import Gang, User, Event, Profile, UserPreference
+from samfundet.models import User, Profile, UserPreference
class TestUserSignals:
@@ -41,79 +37,3 @@ def test_create_profile(self):
### Cleanup ###
user.delete()
-
-
-class TestEditorPermissions:
- def test_update_editor_permissions_add(
- self,
- fixture_event: Event,
- fixture_group: Group,
- fixture_gang: Gang,
- ):
- ### Arrange ###
-
- ### Act ###
- fixture_gang.event_admin_group = fixture_group
- fixture_gang.save()
- fixture_event.editors.add(fixture_gang)
- fixture_event.save()
- editor_perms = get_perms(fixture_gang.event_admin_group, fixture_event)
-
- ### Assert ###
- assert 'change_event' in editor_perms
- assert 'delete_event' in editor_perms
-
- def test_update_editor_permissions_remove(
- self,
- fixture_event: Event,
- fixture_group: Group,
- fixture_gang: Gang,
- ):
- ### Arrange ###
-
- ### Act ###
- fixture_gang.event_admin_group = fixture_group
- fixture_gang.save()
- fixture_event.editors.remove(fixture_gang)
- editor_perms = get_perms(fixture_gang.event_admin_group, fixture_event)
-
- ### Assert ###
- assert 'change_event' not in editor_perms
- assert 'delete_event' not in editor_perms
-
- def test_group_leader_permissions_add(
- self,
- fixture_event: Event,
- fixture_group: Group,
- fixture_gang: Gang,
- ):
- ### Arrange ###
-
- ### Act ###
- fixture_gang.gang_leader_group = fixture_group
- fixture_gang.save()
- fixture_event.editors.add(fixture_gang)
- fixture_event.save()
- editor_perms = get_perms(fixture_gang.gang_leader_group, fixture_event)
-
- ### Assert ###
- assert 'change_event' in editor_perms
- assert 'delete_event' in editor_perms
-
- def test_group_leader_permissions_remove(
- self,
- fixture_event: Event,
- fixture_group: Group,
- fixture_gang: Gang,
- ):
- ### Arrange ###
-
- ### Act ###
- fixture_gang.gang_leader_group = fixture_group
- fixture_gang.save()
- fixture_event.editors.remove(fixture_gang)
- editor_perms = get_perms(fixture_gang.gang_leader_group, fixture_event)
-
- ### Assert ###
- assert 'change_event' not in editor_perms
- assert 'delete_event' not in editor_perms
From 2cace97103d37330ccdc9a3837b6cc121daa17cf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Snorre=20S=C3=A6ther?=
<112980079+Snorre98@users.noreply.github.com>
Date: Mon, 23 Sep 2024 18:06:14 +0200
Subject: [PATCH 5/7] 1358 implemented api docs (#1359)
* adds drf spectacular
* adds title and project description
* adds documentation on api documentation
* RRRRuff
* added spectacular to dev deps
* changed urls.py back
* added "samfundet:schema" to schema urls
* repair poetry.lock
* removes alias for generating schema, deleted schema.
* removes commend
* moved spectacular url patterns
---
README.md | 1 +
backend/poetry.lock | 297 ++++++++++++++++++++++++++++++----
backend/pyproject.toml | 1 +
backend/root/settings/base.py | 10 ++
backend/samfundet/urls.py | 6 +-
docs/api-docs.md | 19 +++
6 files changed, 299 insertions(+), 35 deletions(-)
create mode 100644 docs/api-docs.md
diff --git a/README.md b/README.md
index 1692e3d0c..b7d25a9ca 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
- [Technologies used on Samf4 🤖](/docs/technical/Samf4Tech.md)
- [Project Specific Commands](/docs/docker-project-specific-commands.md)
- [Useful Docker aliases](/docs/docker-project-specific-commands.md)
+- [🌐 API documentation](/docs/api-docs.md)
## Installation
diff --git a/backend/poetry.lock b/backend/poetry.lock
index c0528476e..2cc0f3946 100644
--- a/backend/poetry.lock
+++ b/backend/poetry.lock
@@ -14,6 +14,25 @@ files = [
[package.extras]
tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
+[[package]]
+name = "attrs"
+version = "24.2.0"
+description = "Classes Without Boilerplate"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"},
+ {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"},
+]
+
+[package.extras]
+benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
+tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
+
[[package]]
name = "bandit"
version = "1.7.9"
@@ -312,6 +331,29 @@ files = [
[package.dependencies]
django = ">=4.2"
+[[package]]
+name = "drf-spectacular"
+version = "0.27.2"
+description = "Sane and flexible OpenAPI 3 schema generation for Django REST framework"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "drf-spectacular-0.27.2.tar.gz", hash = "sha256:a199492f2163c4101055075ebdbb037d59c6e0030692fc83a1a8c0fc65929981"},
+ {file = "drf_spectacular-0.27.2-py3-none-any.whl", hash = "sha256:b1c04bf8b2fbbeaf6f59414b4ea448c8787aba4d32f76055c3b13335cf7ec37b"},
+]
+
+[package.dependencies]
+Django = ">=2.2"
+djangorestframework = ">=3.10.3"
+inflection = ">=0.3.1"
+jsonschema = ">=2.6.0"
+PyYAML = ">=5.1"
+uritemplate = ">=2.0.0"
+
+[package.extras]
+offline = ["drf-spectacular-sidecar"]
+sidecar = ["drf-spectacular-sidecar"]
+
[[package]]
name = "gunicorn"
version = "23.0.0"
@@ -335,13 +377,27 @@ tornado = ["tornado (>=0.2)"]
[[package]]
name = "idna"
-version = "3.8"
+version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.6"
files = [
- {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"},
- {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"},
+ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
+ {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
+]
+
+[package.extras]
+all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+
+[[package]]
+name = "inflection"
+version = "0.5.1"
+description = "A port of Ruby on Rails inflector to Python"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"},
+ {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"},
]
[[package]]
@@ -355,6 +411,41 @@ files = [
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
+[[package]]
+name = "jsonschema"
+version = "4.23.0"
+description = "An implementation of JSON Schema validation for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"},
+ {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"},
+]
+
+[package.dependencies]
+attrs = ">=22.2.0"
+jsonschema-specifications = ">=2023.03.6"
+referencing = ">=0.28.4"
+rpds-py = ">=0.7.1"
+
+[package.extras]
+format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"]
+format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2023.12.1"
+description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"},
+ {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"},
+]
+
+[package.dependencies]
+referencing = ">=0.31.0"
+
[[package]]
name = "markdown-it-py"
version = "3.0.0"
@@ -602,36 +693,36 @@ testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "psycopg"
-version = "3.2.1"
+version = "3.2.2"
description = "PostgreSQL database adapter for Python"
optional = false
python-versions = ">=3.8"
files = [
- {file = "psycopg-3.2.1-py3-none-any.whl", hash = "sha256:ece385fb413a37db332f97c49208b36cf030ff02b199d7635ed2fbd378724175"},
- {file = "psycopg-3.2.1.tar.gz", hash = "sha256:dc8da6dc8729dacacda3cc2f17d2c9397a70a66cf0d2b69c91065d60d5f00cb7"},
+ {file = "psycopg-3.2.2-py3-none-any.whl", hash = "sha256:babf565d459d8f72fb65da5e211dd0b58a52c51e4e1fa9cadecff42d6b7619b2"},
+ {file = "psycopg-3.2.2.tar.gz", hash = "sha256:8bad2e497ce22d556dac1464738cb948f8d6bab450d965cf1d8a8effd52412e0"},
]
[package.dependencies]
-psycopg-c = {version = "3.2.1", optional = true, markers = "implementation_name != \"pypy\" and extra == \"c\""}
-typing-extensions = ">=4.4"
+psycopg-c = {version = "3.2.2", optional = true, markers = "implementation_name != \"pypy\" and extra == \"c\""}
+typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""}
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
-binary = ["psycopg-binary (==3.2.1)"]
-c = ["psycopg-c (==3.2.1)"]
-dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.6)", "types-setuptools (>=57.4)", "wheel (>=0.37)"]
+binary = ["psycopg-binary (==3.2.2)"]
+c = ["psycopg-c (==3.2.2)"]
+dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.11)", "types-setuptools (>=57.4)", "wheel (>=0.37)"]
docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"]
pool = ["psycopg-pool"]
-test = ["anyio (>=4.0)", "mypy (>=1.6)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"]
+test = ["anyio (>=4.0)", "mypy (>=1.11)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"]
[[package]]
name = "psycopg-c"
-version = "3.2.1"
+version = "3.2.2"
description = "PostgreSQL database adapter for Python -- C optimisation distribution"
optional = false
python-versions = ">=3.8"
files = [
- {file = "psycopg_c-3.2.1.tar.gz", hash = "sha256:2d09943cc8a855c42c1e23b4298957b7ce8f27bf3683258c52fd139f601f7cda"},
+ {file = "psycopg_c-3.2.2.tar.gz", hash = "sha256:de8cac75bc6640ef0f54ad9187b81e07c430206a83c566b73d4cca41ecccb7c8"},
]
[[package]]
@@ -748,6 +839,21 @@ files = [
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
]
+[[package]]
+name = "referencing"
+version = "0.35.1"
+description = "JSON Referencing + Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"},
+ {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"},
+]
+
+[package.dependencies]
+attrs = ">=22.2.0"
+rpds-py = ">=0.7.0"
+
[[package]]
name = "requests"
version = "2.32.3"
@@ -787,31 +893,143 @@ pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
+[[package]]
+name = "rpds-py"
+version = "0.20.0"
+description = "Python bindings to Rust's persistent data structures (rpds)"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"},
+ {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6377e647bbfd0a0b159fe557f2c6c602c159fc752fa316572f012fc0bf67150"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb851b7df9dda52dc1415ebee12362047ce771fc36914586b2e9fcbd7d293b3e"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e0f80b739e5a8f54837be5d5c924483996b603d5502bfff79bf33da06164ee2"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a8c94dad2e45324fc74dce25e1645d4d14df9a4e54a30fa0ae8bad9a63928e3"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e604fe73ba048c06085beaf51147eaec7df856824bfe7b98657cf436623daf"},
+ {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df3de6b7726b52966edf29663e57306b23ef775faf0ac01a3e9f4012a24a4140"},
+ {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf258ede5bc22a45c8e726b29835b9303c285ab46fc7c3a4cc770736b5304c9f"},
+ {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:55fea87029cded5df854ca7e192ec7bdb7ecd1d9a3f63d5c4eb09148acf4a7ce"},
+ {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae94bd0b2f02c28e199e9bc51485d0c5601f58780636185660f86bf80c89af94"},
+ {file = "rpds_py-0.20.0-cp310-none-win32.whl", hash = "sha256:28527c685f237c05445efec62426d285e47a58fb05ba0090a4340b73ecda6dee"},
+ {file = "rpds_py-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:238a2d5b1cad28cdc6ed15faf93a998336eb041c4e440dd7f902528b8891b399"},
+ {file = "rpds_py-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac2f4f7a98934c2ed6505aead07b979e6f999389f16b714448fb39bbaa86a489"},
+ {file = "rpds_py-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:220002c1b846db9afd83371d08d239fdc865e8f8c5795bbaec20916a76db3318"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d7919548df3f25374a1f5d01fbcd38dacab338ef5f33e044744b5c36729c8db"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:758406267907b3781beee0f0edfe4a179fbd97c0be2e9b1154d7f0a1279cf8e5"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d61339e9f84a3f0767b1995adfb171a0d00a1185192718a17af6e124728e0f5"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1259c7b3705ac0a0bd38197565a5d603218591d3f6cee6e614e380b6ba61c6f6"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c1dc0f53856b9cc9a0ccca0a7cc61d3d20a7088201c0937f3f4048c1718a209"},
+ {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7e60cb630f674a31f0368ed32b2a6b4331b8350d67de53c0359992444b116dd3"},
+ {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dbe982f38565bb50cb7fb061ebf762c2f254ca3d8c20d4006878766e84266272"},
+ {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:514b3293b64187172bc77c8fb0cdae26981618021053b30d8371c3a902d4d5ad"},
+ {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a26ffe9d4dd35e4dfdd1e71f46401cff0181c75ac174711ccff0459135fa58"},
+ {file = "rpds_py-0.20.0-cp311-none-win32.whl", hash = "sha256:89c19a494bf3ad08c1da49445cc5d13d8fefc265f48ee7e7556839acdacf69d0"},
+ {file = "rpds_py-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:c638144ce971df84650d3ed0096e2ae7af8e62ecbbb7b201c8935c370df00a2c"},
+ {file = "rpds_py-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6"},
+ {file = "rpds_py-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4"},
+ {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef"},
+ {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821"},
+ {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940"},
+ {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174"},
+ {file = "rpds_py-0.20.0-cp312-none-win32.whl", hash = "sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139"},
+ {file = "rpds_py-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585"},
+ {file = "rpds_py-0.20.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa9a0521aeca7d4941499a73ad7d4f8ffa3d1affc50b9ea11d992cd7eff18a29"},
+ {file = "rpds_py-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1f1d51eccb7e6c32ae89243cb352389228ea62f89cd80823ea7dd1b98e0b91"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a86a9b96070674fc88b6f9f71a97d2c1d3e5165574615d1f9168ecba4cecb24"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c8ef2ebf76df43f5750b46851ed1cdf8f109d7787ca40035fe19fbdc1acc5a7"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b25f024b421d5859d156750ea9a65651793d51b76a2e9238c05c9d5f203a9"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57eb94a8c16ab08fef6404301c38318e2c5a32216bf5de453e2714c964c125c8"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1940dae14e715e2e02dfd5b0f64a52e8374a517a1e531ad9412319dc3ac7879"},
+ {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d20277fd62e1b992a50c43f13fbe13277a31f8c9f70d59759c88f644d66c619f"},
+ {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:06db23d43f26478303e954c34c75182356ca9aa7797d22c5345b16871ab9c45c"},
+ {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2a5db5397d82fa847e4c624b0c98fe59d2d9b7cf0ce6de09e4d2e80f8f5b3f2"},
+ {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a35df9f5548fd79cb2f52d27182108c3e6641a4feb0f39067911bf2adaa3e57"},
+ {file = "rpds_py-0.20.0-cp313-none-win32.whl", hash = "sha256:fd2d84f40633bc475ef2d5490b9c19543fbf18596dcb1b291e3a12ea5d722f7a"},
+ {file = "rpds_py-0.20.0-cp313-none-win_amd64.whl", hash = "sha256:9bc2d153989e3216b0559251b0c260cfd168ec78b1fac33dd485750a228db5a2"},
+ {file = "rpds_py-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:f2fbf7db2012d4876fb0d66b5b9ba6591197b0f165db8d99371d976546472a24"},
+ {file = "rpds_py-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1e5f3cd7397c8f86c8cc72d5a791071431c108edd79872cdd96e00abd8497d29"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce9845054c13696f7af7f2b353e6b4f676dab1b4b215d7fe5e05c6f8bb06f965"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c3e130fd0ec56cb76eb49ef52faead8ff09d13f4527e9b0c400307ff72b408e1"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b16aa0107ecb512b568244ef461f27697164d9a68d8b35090e9b0c1c8b27752"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa7f429242aae2947246587d2964fad750b79e8c233a2367f71b554e9447949c"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af0fc424a5842a11e28956e69395fbbeab2c97c42253169d87e90aac2886d751"},
+ {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b8c00a3b1e70c1d3891f0db1b05292747f0dbcfb49c43f9244d04c70fbc40eb8"},
+ {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:40ce74fc86ee4645d0a225498d091d8bc61f39b709ebef8204cb8b5a464d3c0e"},
+ {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4fe84294c7019456e56d93e8ababdad5a329cd25975be749c3f5f558abb48253"},
+ {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:338ca4539aad4ce70a656e5187a3a31c5204f261aef9f6ab50e50bcdffaf050a"},
+ {file = "rpds_py-0.20.0-cp38-none-win32.whl", hash = "sha256:54b43a2b07db18314669092bb2de584524d1ef414588780261e31e85846c26a5"},
+ {file = "rpds_py-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:a1862d2d7ce1674cffa6d186d53ca95c6e17ed2b06b3f4c476173565c862d232"},
+ {file = "rpds_py-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3fde368e9140312b6e8b6c09fb9f8c8c2f00999d1823403ae90cc00480221b22"},
+ {file = "rpds_py-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9824fb430c9cf9af743cf7aaf6707bf14323fb51ee74425c380f4c846ea70789"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11ef6ce74616342888b69878d45e9f779b95d4bd48b382a229fe624a409b72c5"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52d3f2f82b763a24ef52f5d24358553e8403ce05f893b5347098014f2d9eff2"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d35cef91e59ebbeaa45214861874bc6f19eb35de96db73e467a8358d701a96c"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d72278a30111e5b5525c1dd96120d9e958464316f55adb030433ea905866f4de"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c29cbbba378759ac5786730d1c3cb4ec6f8ababf5c42a9ce303dc4b3d08cda"},
+ {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6632f2d04f15d1bd6fe0eedd3b86d9061b836ddca4c03d5cf5c7e9e6b7c14580"},
+ {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d0b67d87bb45ed1cd020e8fbf2307d449b68abc45402fe1a4ac9e46c3c8b192b"},
+ {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec31a99ca63bf3cd7f1a5ac9fe95c5e2d060d3c768a09bc1d16e235840861420"},
+ {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e6c9976e38f4d8c4a63bd8a8edac5307dffd3ee7e6026d97f3cc3a2dc02a0b"},
+ {file = "rpds_py-0.20.0-cp39-none-win32.whl", hash = "sha256:569b3ea770c2717b730b61998b6c54996adee3cef69fc28d444f3e7920313cf7"},
+ {file = "rpds_py-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:e6900ecdd50ce0facf703f7a00df12374b74bbc8ad9fe0f6559947fb20f82364"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:617c7357272c67696fd052811e352ac54ed1d9b49ab370261a80d3b6ce385045"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9426133526f69fcaba6e42146b4e12d6bc6c839b8b555097020e2b78ce908dcc"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deb62214c42a261cb3eb04d474f7155279c1a8a8c30ac89b7dcb1721d92c3c02"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcaeb7b57f1a1e071ebd748984359fef83ecb026325b9d4ca847c95bc7311c92"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d454b8749b4bd70dd0a79f428731ee263fa6995f83ccb8bada706e8d1d3ff89d"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d807dc2051abe041b6649681dce568f8e10668e3c1c6543ebae58f2d7e617855"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c20f0ddeb6e29126d45f89206b8291352b8c5b44384e78a6499d68b52ae511"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7f19250ceef892adf27f0399b9e5afad019288e9be756d6919cb58892129f51"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4f1ed4749a08379555cebf4650453f14452eaa9c43d0a95c49db50c18b7da075"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dcedf0b42bcb4cfff4101d7771a10532415a6106062f005ab97d1d0ab5681c60"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:39ed0d010457a78f54090fafb5d108501b5aa5604cc22408fc1c0c77eac14344"},
+ {file = "rpds_py-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f918a1a130a6dfe1d7fe0f105064141342e7dd1611f2e6a21cd2f5c8cb1cfb3e"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f60012a73aa396be721558caa3a6fd49b3dd0033d1675c6d59c4502e870fcf0c"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d2b1ad682a3dfda2a4e8ad8572f3100f95fad98cb99faf37ff0ddfe9cbf9d03"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:614fdafe9f5f19c63ea02817fa4861c606a59a604a77c8cdef5aa01d28b97921"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa518bcd7600c584bf42e6617ee8132869e877db2f76bcdc281ec6a4113a53ab"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0475242f447cc6cb8a9dd486d68b2ef7fbee84427124c232bff5f63b1fe11e5"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90a4cd061914a60bd51c68bcb4357086991bd0bb93d8aa66a6da7701370708f"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:def7400461c3a3f26e49078302e1c1b38f6752342c77e3cf72ce91ca69fb1bc1"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:65794e4048ee837494aea3c21a28ad5fc080994dfba5b036cf84de37f7ad5074"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:faefcc78f53a88f3076b7f8be0a8f8d35133a3ecf7f3770895c25f8813460f08"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5b4f105deeffa28bbcdff6c49b34e74903139afa690e35d2d9e3c2c2fba18cec"},
+ {file = "rpds_py-0.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdfc3a892927458d98f3d55428ae46b921d1f7543b89382fdb483f5640daaec8"},
+ {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"},
+]
+
[[package]]
name = "ruff"
-version = "0.6.4"
+version = "0.6.5"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
- {file = "ruff-0.6.4-py3-none-linux_armv6l.whl", hash = "sha256:c4b153fc152af51855458e79e835fb6b933032921756cec9af7d0ba2aa01a258"},
- {file = "ruff-0.6.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bedff9e4f004dad5f7f76a9d39c4ca98af526c9b1695068198b3bda8c085ef60"},
- {file = "ruff-0.6.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d02a4127a86de23002e694d7ff19f905c51e338c72d8e09b56bfb60e1681724f"},
- {file = "ruff-0.6.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7862f42fc1a4aca1ea3ffe8a11f67819d183a5693b228f0bb3a531f5e40336fc"},
- {file = "ruff-0.6.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eebe4ff1967c838a1a9618a5a59a3b0a00406f8d7eefee97c70411fefc353617"},
- {file = "ruff-0.6.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:932063a03bac394866683e15710c25b8690ccdca1cf192b9a98260332ca93408"},
- {file = "ruff-0.6.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:50e30b437cebef547bd5c3edf9ce81343e5dd7c737cb36ccb4fe83573f3d392e"},
- {file = "ruff-0.6.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c44536df7b93a587de690e124b89bd47306fddd59398a0fb12afd6133c7b3818"},
- {file = "ruff-0.6.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ea086601b22dc5e7693a78f3fcfc460cceabfdf3bdc36dc898792aba48fbad6"},
- {file = "ruff-0.6.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b52387d3289ccd227b62102c24714ed75fbba0b16ecc69a923a37e3b5e0aaaa"},
- {file = "ruff-0.6.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0308610470fcc82969082fc83c76c0d362f562e2f0cdab0586516f03a4e06ec6"},
- {file = "ruff-0.6.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:803b96dea21795a6c9d5bfa9e96127cc9c31a1987802ca68f35e5c95aed3fc0d"},
- {file = "ruff-0.6.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:66dbfea86b663baab8fcae56c59f190caba9398df1488164e2df53e216248baa"},
- {file = "ruff-0.6.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:34d5efad480193c046c86608dbba2bccdc1c5fd11950fb271f8086e0c763a5d1"},
- {file = "ruff-0.6.4-py3-none-win32.whl", hash = "sha256:f0f8968feea5ce3777c0d8365653d5e91c40c31a81d95824ba61d871a11b8523"},
- {file = "ruff-0.6.4-py3-none-win_amd64.whl", hash = "sha256:549daccee5227282289390b0222d0fbee0275d1db6d514550d65420053021a58"},
- {file = "ruff-0.6.4-py3-none-win_arm64.whl", hash = "sha256:ac4b75e898ed189b3708c9ab3fc70b79a433219e1e87193b4f2b77251d058d14"},
- {file = "ruff-0.6.4.tar.gz", hash = "sha256:ac3b5bfbee99973f80aa1b7cbd1c9cbce200883bdd067300c22a6cc1c7fba212"},
+ {file = "ruff-0.6.5-py3-none-linux_armv6l.whl", hash = "sha256:7e4e308f16e07c95fc7753fc1aaac690a323b2bb9f4ec5e844a97bb7fbebd748"},
+ {file = "ruff-0.6.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:932cd69eefe4daf8c7d92bd6689f7e8182571cb934ea720af218929da7bd7d69"},
+ {file = "ruff-0.6.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a8d42d11fff8d3143ff4da41742a98f8f233bf8890e9fe23077826818f8d680"},
+ {file = "ruff-0.6.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a50af6e828ee692fb10ff2dfe53f05caecf077f4210fae9677e06a808275754f"},
+ {file = "ruff-0.6.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:794ada3400a0d0b89e3015f1a7e01f4c97320ac665b7bc3ade24b50b54cb2972"},
+ {file = "ruff-0.6.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:381413ec47f71ce1d1c614f7779d88886f406f1fd53d289c77e4e533dc6ea200"},
+ {file = "ruff-0.6.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:52e75a82bbc9b42e63c08d22ad0ac525117e72aee9729a069d7c4f235fc4d276"},
+ {file = "ruff-0.6.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09c72a833fd3551135ceddcba5ebdb68ff89225d30758027280968c9acdc7810"},
+ {file = "ruff-0.6.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:800c50371bdcb99b3c1551d5691e14d16d6f07063a518770254227f7f6e8c178"},
+ {file = "ruff-0.6.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e25ddd9cd63ba1f3bd51c1f09903904a6adf8429df34f17d728a8fa11174253"},
+ {file = "ruff-0.6.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7291e64d7129f24d1b0c947ec3ec4c0076e958d1475c61202497c6aced35dd19"},
+ {file = "ruff-0.6.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9ad7dfbd138d09d9a7e6931e6a7e797651ce29becd688be8a0d4d5f8177b4b0c"},
+ {file = "ruff-0.6.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:005256d977021790cc52aa23d78f06bb5090dc0bfbd42de46d49c201533982ae"},
+ {file = "ruff-0.6.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:482c1e6bfeb615eafc5899127b805d28e387bd87db38b2c0c41d271f5e58d8cc"},
+ {file = "ruff-0.6.5-py3-none-win32.whl", hash = "sha256:cf4d3fa53644137f6a4a27a2b397381d16454a1566ae5335855c187fbf67e4f5"},
+ {file = "ruff-0.6.5-py3-none-win_amd64.whl", hash = "sha256:3e42a57b58e3612051a636bc1ac4e6b838679530235520e8f095f7c44f706ff9"},
+ {file = "ruff-0.6.5-py3-none-win_arm64.whl", hash = "sha256:51935067740773afdf97493ba9b8231279e9beef0f2a8079188c4776c25688e0"},
+ {file = "ruff-0.6.5.tar.gz", hash = "sha256:4d32d87fab433c0cf285c3683dd4dae63be05fd7a1d65b3f5bf7cdd05a6b96fb"},
]
[[package]]
@@ -880,6 +1098,17 @@ files = [
{file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"},
]
+[[package]]
+name = "uritemplate"
+version = "4.1.1"
+description = "Implementation of RFC 6570 URI Templates"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"},
+ {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"},
+]
+
[[package]]
name = "urllib3"
version = "2.2.3"
@@ -900,4 +1129,4 @@ zstd = ["zstandard (>=0.18.0)"]
[metadata]
lock-version = "2.0"
python-versions = "3.11.2"
-content-hash = "bc6150514b9c608a0bf66b39220be2e2c34dcebd5f5b5c60b3984b097cb60416"
+content-hash = "97507f9ee621e9046ab16ab233f8178329d4ed4af80f344ec9789dc6f4699097"
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 882348854..5ad4d0941 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -166,6 +166,7 @@ debugpy = "1.*"
requests = "2.*"
pytest = "8.*"
pytest-django = "4.*"
+drf-spectacular = "0.27.*"
[build-system]
requires = ["poetry-core"]
diff --git a/backend/root/settings/base.py b/backend/root/settings/base.py
index 789200fa7..bc866ed3b 100644
--- a/backend/root/settings/base.py
+++ b/backend/root/settings/base.py
@@ -87,6 +87,7 @@
'corsheaders',
'root', # Register to enable management.commands.
'samfundet',
+ 'drf_spectacular',
]
MIDDLEWARE = [
@@ -178,9 +179,18 @@
'root.custom_classes.permission_classes.SuperUserPermission',
# 'root.custom_classes.permission_classes.CustomDjangoObjectPermissions',
],
+ 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
### End: DRF ###
+SPECTACULAR_SETTINGS = {
+ 'TITLE': 'Samfundet4 API',
+ 'DESCRIPTION': 'Samfundet4 is the new webpage of Studentersamfundet in Trondhjem',
+ 'VERSION': '1.0.0',
+ 'SERVE_INCLUDE_SCHEMA': False,
+ # OTHER SETTINGS
+}
+
### django-guardian ###
INSTALLED_APPS += [
'guardian',
diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py
index 2fa8e59cc..cc49592f0 100644
--- a/backend/samfundet/urls.py
+++ b/backend/samfundet/urls.py
@@ -1,6 +1,8 @@
# imports
from __future__ import annotations
+from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
+
from rest_framework import routers
from django.urls import path, include
@@ -8,7 +10,6 @@
from . import views
# End: imports -----------------------------------------------------------------
-
router = routers.DefaultRouter()
router.register('images', views.ImageView, 'images')
router.register('tags', views.TagView, 'tags')
@@ -52,6 +53,9 @@
urlpatterns = [
path('api/', include(router.urls)),
+ path('schema/', SpectacularAPIView.as_view(), name='schema'),
+ path('schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='samfundet:schema'), name='swagger_ui'),
+ path('schema/redoc/', SpectacularRedocView.as_view(url_name='samfundet:schema'), name='redoc'),
path('csrf/', views.CsrfView.as_view(), name='csrf'),
path('login/', views.LoginView.as_view(), name='login'),
path('register/', views.RegisterView.as_view(), name='register'),
diff --git a/docs/api-docs.md b/docs/api-docs.md
new file mode 100644
index 000000000..963faf179
--- /dev/null
+++ b/docs/api-docs.md
@@ -0,0 +1,19 @@
+# API docs
+
+API docs are generated by [drf-spectacular](https://drf-spectacular.readthedocs.io/en/latest/readme.html).
+
+API documentation is available as two different interfaces:
+
+[Swagger-UI](http://localhost:8000/schema/swagger-ui/#/) or [Redoc](http://localhost:8000/schema/redoc/)
+
+
+
+🐋 _When backend server is running_
+
+## API schema file
+
+If you want a schema file for the API you can go to [http://localhost:8000/schema/](http://localhost:8000/schema/).
+
+A schema file will be downloaded which can be used for multiple purposes, like sharing API documentation, or to generate code for recreating or testing the API.
+
+> 💡 Note: You might encounter some error messages during this process. These errors are typically related to drf-spectacular not being able to parse certain views in views.py. However, the tool will still attempt to generate the documentation, though the results might not be fully comprehensive.
From e58e81b606443277a9b35c43bc2e7981f521fffd Mon Sep 17 00:00:00 2001
From: Robin <16273164+robines@users.noreply.github.com>
Date: Tue, 24 Sep 2024 11:26:03 +0200
Subject: [PATCH 6/7] Biome: Ignore permissions.ts (#1391)
---
biome.jsonc | 1 +
1 file changed, 1 insertion(+)
diff --git a/biome.jsonc b/biome.jsonc
index cbad94cae..f8edeb4c7 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -13,6 +13,7 @@
"**/*.jsonc",
"**/*.config.ts",
"**/src/routes/backend.ts", // Autogenerated file.
+ "**/src/permissions/permissions.ts", // Autogenerated file.
"**/build/static/**/*", // Build files from frontend.
"**/dist/assets/**/*", // Build files from frontend.
"**/.history/**/*", // VSCode extension.
From cc8f3d2b70d64c538780efdec80f311b623d931e Mon Sep 17 00:00:00 2001
From: Robin <16273164+robines@users.noreply.github.com>
Date: Tue, 24 Sep 2024 15:34:56 +0200
Subject: [PATCH 7/7] Create basic page for listing all roles (#1390)
* Create basic page for listing roles
* Biome
---
frontend/src/Pages/AdminPage/applets.ts | 9 ++-
.../RolesAdminPage/RolesAdminPage.tsx | 66 +++++++++++++++++++
.../src/PagesAdmin/RolesAdminPage/index.ts | 1 +
frontend/src/PagesAdmin/index.ts | 1 +
frontend/src/dto.ts | 33 ++++++++++
frontend/src/i18n/constants.ts | 2 +
frontend/src/i18n/translations.ts | 4 ++
frontend/src/permissions/permissions.ts | 37 +++++++----
frontend/src/router/router.tsx | 11 ++++
frontend/src/routes/frontend.ts | 2 +
10 files changed, 154 insertions(+), 12 deletions(-)
create mode 100644 frontend/src/PagesAdmin/RolesAdminPage/RolesAdminPage.tsx
create mode 100644 frontend/src/PagesAdmin/RolesAdminPage/index.ts
diff --git a/frontend/src/Pages/AdminPage/applets.ts b/frontend/src/Pages/AdminPage/applets.ts
index 821c92f69..e3eda63e2 100644
--- a/frontend/src/Pages/AdminPage/applets.ts
+++ b/frontend/src/Pages/AdminPage/applets.ts
@@ -15,7 +15,7 @@ export const appletCategories: AppletCategory[] = [
url: ROUTES.frontend.admin_events,
},
{
- title_nb: 'Informasjonssider',
+ title_nb: 'Informasjonssider',
title_en: 'Information pages',
perm: PERM.SAMFUNDET_ADD_INFORMATIONPAGE,
icon: 'ph:note-pencil-light',
@@ -56,6 +56,13 @@ export const appletCategories: AppletCategory[] = [
icon: 'mdi:person-search',
url: ROUTES.frontend.admin_users,
},
+ {
+ title_nb: 'Roller',
+ title_en: 'Roles',
+ perm: PERM.SAMFUNDET_VIEW_ROLE,
+ icon: 'ph:user-circle-gear',
+ url: ROUTES.frontend.admin_roles,
+ },
{
title_nb: 'Gjenger',
title_en: 'Gangs',
diff --git a/frontend/src/PagesAdmin/RolesAdminPage/RolesAdminPage.tsx b/frontend/src/PagesAdmin/RolesAdminPage/RolesAdminPage.tsx
new file mode 100644
index 000000000..528f0403b
--- /dev/null
+++ b/frontend/src/PagesAdmin/RolesAdminPage/RolesAdminPage.tsx
@@ -0,0 +1,66 @@
+import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+import { CrudButtons } from '~/Components';
+import { Table } from '~/Components/Table';
+import { AdminPageLayout } from '~/PagesAdmin/AdminPageLayout/AdminPageLayout';
+import type { RoleDto } from '~/dto';
+import { KEY } from '~/i18n/constants';
+import { lowerCapitalize } from '~/utils';
+
+export function RolesAdminPage() {
+ const { t } = useTranslation();
+
+ const navigate = useNavigate();
+
+ const [roles, setRoles] = useState([
+ {
+ id: 1,
+ name: 'Opptaksansvarlig',
+ permissions: ['samfundet.test_permission', 'samfundet.user_create'],
+ },
+ {
+ id: 2,
+ name: 'Intervjuer',
+ permissions: [],
+ },
+ ]);
+ const [loading, setLoading] = useState(false);
+
+ const columns = [
+ { content: t(KEY.common_name), sortable: true },
+ { content: lowerCapitalize(`${t(KEY.common_count)} ${t(KEY.common_permissions)}`), sortable: true },
+ { content: lowerCapitalize(`${t(KEY.common_count)} ${t(KEY.common_users)}`), sortable: true },
+ { content: '' },
+ ];
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: navigate does not need to be in deplist
+ const data = useMemo(() => {
+ if (!roles) return [];
+ return roles.map((r) => {
+ return [
+ {
+ content: r.name,
+ value: r.name,
+ },
+ {
+ content: r.permissions.length,
+ value: r.permissions.length,
+ },
+ {
+ content: 0,
+ value: 0,
+ },
+ {
+ content: navigate('#')} />,
+ },
+ ];
+ });
+ }, [roles]);
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/PagesAdmin/RolesAdminPage/index.ts b/frontend/src/PagesAdmin/RolesAdminPage/index.ts
new file mode 100644
index 000000000..c22ccd910
--- /dev/null
+++ b/frontend/src/PagesAdmin/RolesAdminPage/index.ts
@@ -0,0 +1 @@
+export { RolesAdminPage } from './RolesAdminPage';
diff --git a/frontend/src/PagesAdmin/index.ts b/frontend/src/PagesAdmin/index.ts
index e084ff490..2994f6282 100644
--- a/frontend/src/PagesAdmin/index.ts
+++ b/frontend/src/PagesAdmin/index.ts
@@ -25,6 +25,7 @@ export { RecruitmentPositionOverviewPage } from './RecruitmentPositionOverviewPa
export { RecruitmentUnprocessedApplicantsPage } from './RecruitmentUnprocessedApplicantsPage';
export { RecruitmentUsersWithoutInterviewGangPage } from './RecruitmentUsersWithoutInterviewGangPage';
export { RecruitmentUsersWithoutThreeInterviewCriteriaPage } from './RecruitmentUsersWithoutThreeInterviewCriteriaPage';
+export { RolesAdminPage } from './RolesAdminPage';
export { SaksdokumentAdminPage } from './SaksdokumentAdminPage';
export { SaksdokumentFormAdminPage } from './SaksdokumentFormAdminPage';
export { SultenMenuAdminPage } from './SultenMenuAdminPage';
diff --git a/frontend/src/dto.ts b/frontend/src/dto.ts
index 4e164a43d..aabd01caa 100644
--- a/frontend/src/dto.ts
+++ b/frontend/src/dto.ts
@@ -322,6 +322,14 @@ export type GangTypeDto = {
gangs: GangDto[];
};
+export type GangSectionDto = {
+ id: number;
+ name_nb: string;
+ name_en: string;
+ logo?: string;
+ gang: GangDto;
+};
+
export type ClosedPeriodDto = {
id: number;
message_no: string;
@@ -359,6 +367,31 @@ export type KeyValueDto = {
value: string;
};
+// ############################################################
+// Roles
+// ############################################################
+
+export type RoleDto = {
+ id: number;
+ name: string;
+ permissions: string[];
+};
+
+export type UserGangRoleDto = {
+ id: number;
+ obj: GangDto;
+};
+
+export type UserGangSectionRoleDto = {
+ id: number;
+ obj: GangSectionDto;
+};
+
+export type UserOrganizationRoleDto = {
+ id: number;
+ obj: OrganizationDto;
+};
+
// ############################################################
// Recruitment
// ############################################################
diff --git a/frontend/src/i18n/constants.ts b/frontend/src/i18n/constants.ts
index 319af276e..7cca42f5e 100644
--- a/frontend/src/i18n/constants.ts
+++ b/frontend/src/i18n/constants.ts
@@ -118,6 +118,7 @@ export const KEY = {
common_users: 'common_users',
common_email: 'common_email',
common_total: 'common_total',
+ common_roles: 'common_roles',
common_guests: 'common_guests',
common_occasion: 'common_occasion',
common_phonenumber: 'common_phonenumber',
@@ -138,6 +139,7 @@ export const KEY = {
common_contact_us: 'common_contact_us',
common_doors_date: 'common_doors_date',
common_restaurant: 'common_restaurant',
+ common_permissions: 'common_permissions',
common_member: 'common_member',
common_membership: 'common_membership',
common_select_all: 'common_select_all',
diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts
index e2896f8e2..ab8940c7f 100644
--- a/frontend/src/i18n/translations.ts
+++ b/frontend/src/i18n/translations.ts
@@ -79,6 +79,7 @@ export const nb = prepareTranslations({
[KEY.common_title]: 'Tittel',
[KEY.common_login]: 'Logg inn',
[KEY.common_users]: 'Brukere',
+ [KEY.common_roles]: 'Roller',
[KEY.common_active]: 'Aktiv',
[KEY.common_event]: 'Arrangement',
[KEY.common_repeat]: 'Repeter',
@@ -120,6 +121,7 @@ export const nb = prepareTranslations({
[KEY.common_member]: 'Medlem',
[KEY.common_membership]: 'Medlemskap',
[KEY.common_doors_date]: 'Dørene åpner',
+ [KEY.common_permissions]: 'Tilganger',
[KEY.common_restaurant]: 'Restaurant',
[KEY.common_contact_us]: 'Kontakt oss',
[KEY.common_select_all]: 'Velg alle',
@@ -501,6 +503,7 @@ export const en = prepareTranslations({
[KEY.common_title]: 'Title',
[KEY.common_login]: 'Log in',
[KEY.common_users]: 'Users',
+ [KEY.common_roles]: 'Roles',
[KEY.common_active]: 'Active',
[KEY.common_cancel]: 'Cancel',
[KEY.common_venues]: 'Venues',
@@ -553,6 +556,7 @@ export const en = prepareTranslations({
[KEY.common_opening_hours]: 'Opening hours',
[KEY.common_general]: 'General',
[KEY.common_doors_date]: 'Doors',
+ [KEY.common_permissions]: 'Permissions',
[KEY.common_long_description]: 'Long description',
[KEY.common_short_description]: 'Short description',
[KEY.common_back_to_samfundet]: 'Back to samfundet.no',
diff --git a/frontend/src/permissions/permissions.ts b/frontend/src/permissions/permissions.ts
index 06b68baf8..0f01b2d7e 100644
--- a/frontend/src/permissions/permissions.ts
+++ b/frontend/src/permissions/permissions.ts
@@ -1,11 +1,11 @@
/**
-"""
-THIS FILE IS AUTOGENERATED.
-DO NOT WRITE IN THIS FILE, AS IT WILL BE OVERWRITTEN ON NEXT UPDATE.
+ """
+ THIS FILE IS AUTOGENERATED.
+ DO NOT WRITE IN THIS FILE, AS IT WILL BE OVERWRITTEN ON NEXT UPDATE.
-THIS FILE WAS GENERATED BY: root.management.commands.generate_permissions
-"""
-*/
+ THIS FILE WAS GENERATED BY: root.management.commands.generate_permissions
+ """
+ */
// ############################################################
@@ -183,11 +183,6 @@ export const SAMFUNDET_CHANGE_NONMEMBEREMAILREGISTRATION = 'samfundet.change_non
export const SAMFUNDET_DELETE_NONMEMBEREMAILREGISTRATION = 'samfundet.delete_nonmemberemailregistration';
export const SAMFUNDET_VIEW_NONMEMBEREMAILREGISTRATION = 'samfundet.view_nonmemberemailregistration';
-export const SAMFUNDET_ADD_NOTIFICATION = 'samfundet.add_notification';
-export const SAMFUNDET_CHANGE_NOTIFICATION = 'samfundet.change_notification';
-export const SAMFUNDET_DELETE_NOTIFICATION = 'samfundet.delete_notification';
-export const SAMFUNDET_VIEW_NOTIFICATION = 'samfundet.view_notification';
-
export const SAMFUNDET_ADD_OCCUPIEDTIMESLOT = 'samfundet.add_occupiedtimeslot';
export const SAMFUNDET_CHANGE_OCCUPIEDTIMESLOT = 'samfundet.change_occupiedtimeslot';
export const SAMFUNDET_DELETE_OCCUPIEDTIMESLOT = 'samfundet.delete_occupiedtimeslot';
@@ -282,6 +277,11 @@ export const SAMFUNDET_CHANGE_RESERVATION = 'samfundet.change_reservation';
export const SAMFUNDET_DELETE_RESERVATION = 'samfundet.delete_reservation';
export const SAMFUNDET_VIEW_RESERVATION = 'samfundet.view_reservation';
+export const SAMFUNDET_ADD_ROLE = 'samfundet.add_role';
+export const SAMFUNDET_CHANGE_ROLE = 'samfundet.change_role';
+export const SAMFUNDET_DELETE_ROLE = 'samfundet.delete_role';
+export const SAMFUNDET_VIEW_ROLE = 'samfundet.view_role';
+
export const SAMFUNDET_ADD_SAKSDOKUMENT = 'samfundet.add_saksdokument';
export const SAMFUNDET_CHANGE_SAKSDOKUMENT = 'samfundet.change_saksdokument';
export const SAMFUNDET_DELETE_SAKSDOKUMENT = 'samfundet.delete_saksdokument';
@@ -314,6 +314,21 @@ export const SAMFUNDET_CHANGE_USERFEEDBACKMODEL = 'samfundet.change_userfeedback
export const SAMFUNDET_DELETE_USERFEEDBACKMODEL = 'samfundet.delete_userfeedbackmodel';
export const SAMFUNDET_VIEW_USERFEEDBACKMODEL = 'samfundet.view_userfeedbackmodel';
+export const SAMFUNDET_ADD_USERGANGROLE = 'samfundet.add_usergangrole';
+export const SAMFUNDET_CHANGE_USERGANGROLE = 'samfundet.change_usergangrole';
+export const SAMFUNDET_DELETE_USERGANGROLE = 'samfundet.delete_usergangrole';
+export const SAMFUNDET_VIEW_USERGANGROLE = 'samfundet.view_usergangrole';
+
+export const SAMFUNDET_ADD_USERGANGSECTIONROLE = 'samfundet.add_usergangsectionrole';
+export const SAMFUNDET_CHANGE_USERGANGSECTIONROLE = 'samfundet.change_usergangsectionrole';
+export const SAMFUNDET_DELETE_USERGANGSECTIONROLE = 'samfundet.delete_usergangsectionrole';
+export const SAMFUNDET_VIEW_USERGANGSECTIONROLE = 'samfundet.view_usergangsectionrole';
+
+export const SAMFUNDET_ADD_USERORGROLE = 'samfundet.add_userorgrole';
+export const SAMFUNDET_CHANGE_USERORGROLE = 'samfundet.change_userorgrole';
+export const SAMFUNDET_DELETE_USERORGROLE = 'samfundet.delete_userorgrole';
+export const SAMFUNDET_VIEW_USERORGROLE = 'samfundet.view_userorgrole';
+
export const SAMFUNDET_ADD_USERPREFERENCE = 'samfundet.add_userpreference';
export const SAMFUNDET_CHANGE_USERPREFERENCE = 'samfundet.change_userpreference';
export const SAMFUNDET_DELETE_USERPREFERENCE = 'samfundet.delete_userpreference';
diff --git a/frontend/src/router/router.tsx b/frontend/src/router/router.tsx
index 0f86b5696..6ad6476e4 100644
--- a/frontend/src/router/router.tsx
+++ b/frontend/src/router/router.tsx
@@ -57,6 +57,7 @@ import {
RecruitmentUnprocessedApplicantsPage,
RecruitmentUsersWithoutInterviewGangPage,
RecruitmentUsersWithoutThreeInterviewCriteriaPage,
+ RolesAdminPage,
SaksdokumentAdminPage,
SaksdokumentFormAdminPage,
SultenMenuAdminPage,
@@ -179,6 +180,16 @@ export const router = createBrowserRouter(
element={} />}
/>
+ {/* Roles */}
+ }
+ handle={{ crumb: () => {t(KEY.common_roles)} }}
+ >
+ } />}
+ />
+
{/* Events */}
}
diff --git a/frontend/src/routes/frontend.ts b/frontend/src/routes/frontend.ts
index 0e1363341..d49b59766 100644
--- a/frontend/src/routes/frontend.ts
+++ b/frontend/src/routes/frontend.ts
@@ -42,6 +42,8 @@ export const ROUTES_FRONTEND = {
admin_impersonate: '/impersonate/',
// Users
admin_users: '/control-panel/users/',
+ // Roles
+ admin_roles: '/control-panel/roles/',
// Gangs:
admin_gangs: '/control-panel/gangs/',
admin_gangs_create: '/control-panel/gangs/create/',