Skip to content

Commit ee8f61a

Browse files
sampaccoudPanchoutNathan
authored andcommitted
✨(backend) give an order to choices
We are going to need to compare choices to materialize the fact that choices are ordered. For example an admin role is higer than an editor role but lower than an owner role. We will need this to compute the reach and role resulting from all the document accesses (resp. link accesses) assigned on a document's ancestors.
1 parent 40785f3 commit ee8f61a

File tree

5 files changed

+138
-106
lines changed

5 files changed

+138
-106
lines changed

src/backend/core/api/serializers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import magic
1313
from rest_framework import exceptions, serializers
1414

15-
from core import enums, models, utils
15+
from core import choices, enums, models, utils
1616
from core.services.ai_services import AI_ACTIONS
1717
from core.services.converter_services import (
1818
ConversionError,
@@ -97,7 +97,7 @@ def validate(self, attrs):
9797

9898
if not self.Meta.model.objects.filter( # pylint: disable=no-member
9999
Q(user=user) | Q(team__in=user.teams),
100-
role__in=models.PRIVILEGED_ROLES,
100+
role__in=choices.PRIVILEGED_ROLES,
101101
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
102102
).exists():
103103
raise exceptions.PermissionDenied(

src/backend/core/api/viewsets.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from rest_framework.permissions import AllowAny
3333
from rest_framework.throttling import UserRateThrottle
3434

35-
from core import authentication, enums, models
35+
from core import authentication, choices, enums, models
3636
from core.services.ai_services import AIService
3737
from core.services.collaboration_services import CollaborationService
3838
from core.tasks.mail import send_ask_for_access_mail
@@ -1460,12 +1460,12 @@ def list(self, request, *args, **kwargs):
14601460
document__in=ancestors.filter(depth__gte=highest_readable.depth)
14611461
)
14621462

1463-
is_privileged = bool(roles.intersection(set(models.PRIVILEGED_ROLES)))
1463+
is_privileged = bool(roles.intersection(set(choices.PRIVILEGED_ROLES)))
14641464
if is_privileged:
14651465
serializer_class = serializers.DocumentAccessSerializer
14661466
else:
14671467
# Return only the document's privileged accesses
1468-
queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES)
1468+
queryset = queryset.filter(role__in=choices.PRIVILEGED_ROLES)
14691469
serializer_class = serializers.DocumentAccessLightSerializer
14701470

14711471
queryset = queryset.distinct()
@@ -1707,11 +1707,11 @@ def get_queryset(self):
17071707
queryset.filter(
17081708
db.Q(
17091709
document__accesses__user=user,
1710-
document__accesses__role__in=models.PRIVILEGED_ROLES,
1710+
document__accesses__role__in=choices.PRIVILEGED_ROLES,
17111711
)
17121712
| db.Q(
17131713
document__accesses__team__in=teams,
1714-
document__accesses__role__in=models.PRIVILEGED_ROLES,
1714+
document__accesses__role__in=choices.PRIVILEGED_ROLES,
17151715
),
17161716
)
17171717
# Abilities are computed based on logged-in user's role and

src/backend/core/choices.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Declare and configure choices for Docs' core application."""
2+
3+
from django.db.models import TextChoices
4+
from django.utils.translation import gettext_lazy as _
5+
6+
7+
class PriorityTextChoices(TextChoices):
8+
"""
9+
This class inherits from Django's TextChoices and provides a method to get the priority
10+
of a given value based on its position in the class.
11+
"""
12+
13+
@classmethod
14+
def get_priority(cls, value):
15+
"""Returns the priority of the given value based on its order in the class."""
16+
members = list(cls.__members__.values())
17+
return members.index(value) + 1 if value in members else 0
18+
19+
@classmethod
20+
def max(cls, *roles):
21+
"""
22+
Return the highest-priority role among the given roles, using get_priority().
23+
If no valid roles are provided, returns None.
24+
"""
25+
26+
valid_roles = [role for role in roles if cls.get_priority(role) is not None]
27+
if not valid_roles:
28+
return None
29+
return max(valid_roles, key=cls.get_priority)
30+
31+
32+
class LinkRoleChoices(PriorityTextChoices):
33+
"""Defines the possible roles a link can offer on a document."""
34+
35+
READER = "reader", _("Reader") # Can read
36+
EDITOR = "editor", _("Editor") # Can read and edit
37+
38+
39+
class RoleChoices(PriorityTextChoices):
40+
"""Defines the possible roles a user can have in a resource."""
41+
42+
READER = "reader", _("Reader") # Can read
43+
EDITOR = "editor", _("Editor") # Can read and edit
44+
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
45+
OWNER = "owner", _("Owner")
46+
47+
48+
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
49+
50+
51+
class LinkReachChoices(PriorityTextChoices):
52+
"""Defines types of access for links"""
53+
54+
RESTRICTED = (
55+
"restricted",
56+
_("Restricted"),
57+
) # Only users with a specific access can read/edit the document
58+
AUTHENTICATED = (
59+
"authenticated",
60+
_("Authenticated"),
61+
) # Any authenticated user can access the document
62+
PUBLIC = "public", _("Public") # Even anonymous users can access the document
63+
64+
@classmethod
65+
def get_select_options(cls, ancestors_links):
66+
"""
67+
Determines the valid select options for link reach and link role depending on the
68+
list of ancestors' link reach/role.
69+
Args:
70+
ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys
71+
representing the reach and role of ancestors links.
72+
Returns:
73+
Dictionary mapping possible reach levels to their corresponding possible roles.
74+
"""
75+
# If no ancestors, return all options
76+
if not ancestors_links:
77+
return {
78+
reach: LinkRoleChoices.values if reach != cls.RESTRICTED else None
79+
for reach in cls.values
80+
}
81+
82+
# Initialize result with all possible reaches and role options as sets
83+
result = {
84+
reach: set(LinkRoleChoices.values) if reach != cls.RESTRICTED else None
85+
for reach in cls.values
86+
}
87+
88+
# Group roles by reach level
89+
reach_roles = defaultdict(set)
90+
for link in ancestors_links:
91+
reach_roles[link["link_reach"]].add(link["link_role"])
92+
93+
# Rule 1: public/editor → override everything
94+
if LinkRoleChoices.EDITOR in reach_roles.get(cls.PUBLIC, set()):
95+
return {cls.PUBLIC: [LinkRoleChoices.EDITOR]}
96+
97+
# Rule 2: authenticated/editor
98+
if LinkRoleChoices.EDITOR in reach_roles.get(cls.AUTHENTICATED, set()):
99+
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
100+
result.pop(cls.RESTRICTED, None)
101+
102+
# Rule 3: public/reader
103+
if LinkRoleChoices.READER in reach_roles.get(cls.PUBLIC, set()):
104+
result.pop(cls.AUTHENTICATED, None)
105+
result.pop(cls.RESTRICTED, None)
106+
107+
# Rule 4: authenticated/reader
108+
if LinkRoleChoices.READER in reach_roles.get(cls.AUTHENTICATED, set()):
109+
result.pop(cls.RESTRICTED, None)
110+
111+
# Clean up: remove empty entries and convert sets to ordered lists
112+
cleaned = {}
113+
for reach in cls.values:
114+
if reach in result:
115+
if result[reach]:
116+
cleaned[reach] = [
117+
r for r in LinkRoleChoices.values if r in result[reach]
118+
]
119+
else:
120+
# Could be [] or None (for RESTRICTED reach)
121+
cleaned[reach] = result[reach]
122+
123+
return cleaned

src/backend/core/models.py

Lines changed: 2 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
from timezone_field import TimeZoneField
3434
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
3535

36+
from .choices import PRIVILEGED_ROLES, LinkReachChoices, LinkRoleChoices, RoleChoices
37+
3638
logger = getLogger(__name__)
3739

3840

@@ -50,100 +52,6 @@ def get_trashbin_cutoff():
5052
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS)
5153

5254

53-
class LinkRoleChoices(models.TextChoices):
54-
"""Defines the possible roles a link can offer on a document."""
55-
56-
READER = "reader", _("Reader") # Can read
57-
EDITOR = "editor", _("Editor") # Can read and edit
58-
59-
60-
class RoleChoices(models.TextChoices):
61-
"""Defines the possible roles a user can have in a resource."""
62-
63-
READER = "reader", _("Reader") # Can read
64-
EDITOR = "editor", _("Editor") # Can read and edit
65-
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
66-
OWNER = "owner", _("Owner")
67-
68-
69-
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
70-
71-
72-
class LinkReachChoices(models.TextChoices):
73-
"""Defines types of access for links"""
74-
75-
RESTRICTED = (
76-
"restricted",
77-
_("Restricted"),
78-
) # Only users with a specific access can read/edit the document
79-
AUTHENTICATED = (
80-
"authenticated",
81-
_("Authenticated"),
82-
) # Any authenticated user can access the document
83-
PUBLIC = "public", _("Public") # Even anonymous users can access the document
84-
85-
@classmethod
86-
def get_select_options(cls, ancestors_links):
87-
"""
88-
Determines the valid select options for link reach and link role depending on the
89-
list of ancestors' link reach/role.
90-
Args:
91-
ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys
92-
representing the reach and role of ancestors links.
93-
Returns:
94-
Dictionary mapping possible reach levels to their corresponding possible roles.
95-
"""
96-
# If no ancestors, return all options
97-
if not ancestors_links:
98-
return {
99-
reach: LinkRoleChoices.values if reach != cls.RESTRICTED else None
100-
for reach in cls.values
101-
}
102-
103-
# Initialize result with all possible reaches and role options as sets
104-
result = {
105-
reach: set(LinkRoleChoices.values) if reach != cls.RESTRICTED else None
106-
for reach in cls.values
107-
}
108-
109-
# Group roles by reach level
110-
reach_roles = defaultdict(set)
111-
for link in ancestors_links:
112-
reach_roles[link["link_reach"]].add(link["link_role"])
113-
114-
# Rule 1: public/editor → override everything
115-
if LinkRoleChoices.EDITOR in reach_roles.get(cls.PUBLIC, set()):
116-
return {cls.PUBLIC: [LinkRoleChoices.EDITOR]}
117-
118-
# Rule 2: authenticated/editor
119-
if LinkRoleChoices.EDITOR in reach_roles.get(cls.AUTHENTICATED, set()):
120-
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
121-
result.pop(cls.RESTRICTED, None)
122-
123-
# Rule 3: public/reader
124-
if LinkRoleChoices.READER in reach_roles.get(cls.PUBLIC, set()):
125-
result.pop(cls.AUTHENTICATED, None)
126-
result.pop(cls.RESTRICTED, None)
127-
128-
# Rule 4: authenticated/reader
129-
if LinkRoleChoices.READER in reach_roles.get(cls.AUTHENTICATED, set()):
130-
result.pop(cls.RESTRICTED, None)
131-
132-
# Clean up: remove empty entries and convert sets to ordered lists
133-
cleaned = {}
134-
for reach in cls.values:
135-
if reach in result:
136-
if result[reach]:
137-
cleaned[reach] = [
138-
r for r in LinkRoleChoices.values if r in result[reach]
139-
]
140-
else:
141-
# Could be [] or None (for RESTRICTED reach)
142-
cleaned[reach] = result[reach]
143-
144-
return cleaned
145-
146-
14755
class DuplicateEmailError(Exception):
14856
"""Raised when an email is already associated with a pre-existing user."""
14957

src/backend/core/tests/documents/test_api_document_accesses.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99
from rest_framework.test import APIClient
1010

11-
from core import factories, models
11+
from core import choices, factories, models
1212
from core.api import serializers
1313
from core.tests.conftest import TEAM, USER, VIA
1414
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
@@ -70,7 +70,8 @@ def test_api_document_accesses_list_unexisting_document():
7070

7171
@pytest.mark.parametrize("via", VIA)
7272
@pytest.mark.parametrize(
73-
"role", [role for role in models.RoleChoices if role not in models.PRIVILEGED_ROLES]
73+
"role",
74+
[role for role in choices.RoleChoices if role not in choices.PRIVILEGED_ROLES],
7475
)
7576
def test_api_document_accesses_list_authenticated_related_non_privileged(
7677
via, role, mock_user_teams
@@ -131,7 +132,7 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
131132
# Make sure only privileged roles are returned
132133
accesses = [grand_parent_access, parent_access, document_access, access1, access2]
133134
privileged_accesses = [
134-
acc for acc in accesses if acc.role in models.PRIVILEGED_ROLES
135+
acc for acc in accesses if acc.role in choices.PRIVILEGED_ROLES
135136
]
136137
assert len(content) == len(privileged_accesses)
137138

@@ -159,7 +160,7 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
159160

160161
@pytest.mark.parametrize("via", VIA)
161162
@pytest.mark.parametrize(
162-
"role", [role for role in models.RoleChoices if role in models.PRIVILEGED_ROLES]
163+
"role", [role for role in choices.RoleChoices if role in choices.PRIVILEGED_ROLES]
163164
)
164165
def test_api_document_accesses_list_authenticated_related_privileged(
165166
via, role, mock_user_teams
@@ -335,7 +336,7 @@ def test_api_document_accesses_retrieve_authenticated_related(
335336
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
336337
)
337338

338-
if not role in models.PRIVILEGED_ROLES:
339+
if not role in choices.PRIVILEGED_ROLES:
339340
assert response.status_code == 403
340341
else:
341342
access_user = serializers.UserSerializer(instance=access.user).data

0 commit comments

Comments
 (0)