Skip to content

Commit db30437

Browse files
authored
refactor: access control (#1026)
1 parent 19b29e2 commit db30437

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+1244
-1213
lines changed

bullet/bullet_admin/access.py

Lines changed: 141 additions & 227 deletions
Large diffs are not rendered by default.

bullet/bullet_admin/admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
@admin.register(BranchRole)
77
class BranchRoleAdmin(admin.ModelAdmin):
8-
list_display = ("branch", "user", "is_translator", "is_admin")
8+
list_display = ("branch", "user", "is_admin")
99
list_filter = ("branch",)
1010
autocomplete_fields = ("user",)
1111

bullet/bullet_admin/forms/category.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from competitions.models import Category
2-
from django.forms import ModelForm
2+
from django.forms import CheckboxSelectMultiple, ModelForm
33

44

55
class CategoryForm(ModelForm):
@@ -14,13 +14,15 @@ class Meta:
1414
"max_members_per_team",
1515
"max_teams_per_school",
1616
"max_teams_second_round",
17+
"educations",
1718
]
1819

1920
labels = {
2021
"problems_per_team": "Available problems",
2122
"max_members_per_team": "Max. number of team members",
2223
"max_teams_per_school": "Max. number of teams per school",
2324
"max_teams_second_round": "Max. number of teams per school (2nd round)",
25+
"educations": "Allowed school grades",
2426
}
2527

2628
help_texts = {
@@ -35,5 +37,9 @@ class Meta:
3537
"round of registration.",
3638
}
3739

40+
widgets = {
41+
"educations": CheckboxSelectMultiple,
42+
}
43+
3844
def __init__(self, **kwargs):
3945
super().__init__(**kwargs)

bullet/bullet_admin/forms/education.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from django import forms
22
from education.models import School
33

4+
from bullet_admin.forms.utils import get_country_choices
5+
46

57
class SchoolForm(forms.ModelForm):
68
class Meta:
@@ -16,3 +18,7 @@ class Meta:
1618
"search": "You can add any extra words or phrases that won't be shown, but "
1719
"will get used by the search engine.",
1820
}
21+
22+
def __init__(self, competition, user, **kwargs):
23+
super().__init__(**kwargs)
24+
self.fields["country"].choices = get_country_choices(competition, user)

bullet/bullet_admin/forms/users.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ class Meta:
1717
class BranchRoleForm(forms.ModelForm):
1818
class Meta:
1919
model = BranchRole
20-
fields = ("is_translator", "is_photographer", "is_admin")
20+
fields = ("is_admin",)
2121

2222

2323
class CompetitionRoleForm(forms.ModelForm):
2424
class Meta:
2525
model = CompetitionRole
26-
fields = ("venue_objects", "countries", "can_delegate", "is_operator")
26+
fields = ("venue_objects", "countries", "is_operator")
2727
widgets = {
2828
"countries": forms.CheckboxSelectMultiple(),
2929
"venue_objects": forms.CheckboxSelectMultiple(),
@@ -72,5 +72,7 @@ def clean(self):
7272
"The user cannot be both a venue and a country administrator."
7373
)
7474

75-
if self.cleaned_data["can_delegate"] and self.cleaned_data["is_operator"]:
76-
raise ValidationError("Operator cannot have delegate permission.")
75+
if countries and self.cleaned_data["is_operator"]:
76+
raise ValidationError(
77+
"Operator with countries is unsupported permission configuration."
78+
)

bullet/bullet_admin/forms/utils.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
from users.models import User
77

88

9-
def get_country_choices(competition: Competition, user: User = None, allow_empty=False):
9+
def get_country_choices(
10+
competition: Competition, user: User | None = None, allow_empty=False
11+
):
1012
countries = [
1113
Country(c)
1214
for c in BranchCountry.objects.filter(branch=competition.branch).values_list(
@@ -17,9 +19,11 @@ def get_country_choices(competition: Competition, user: User = None, allow_empty
1719
choices = [(c.code, c.name) for c in countries]
1820
choices.sort(key=lambda x: x[1])
1921

20-
if not user.get_branch_role(competition.branch).is_admin:
21-
crole = user.get_competition_role(competition)
22-
choices = list(filter(lambda x: x[0] in crole.countries, choices))
22+
if user:
23+
if not user.get_branch_role(competition.branch).is_admin:
24+
crole = user.get_competition_role(competition)
25+
countries = set(crole.countries or [])
26+
choices = list(filter(lambda x: x[0] in countries, choices))
2327

2428
if allow_empty:
2529
choices.insert(0, ("", "--------"))
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 5.2.7 on 2025-10-13 08:10
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("bullet_admin", "0008_remove_branchrole_is_school_editor"),
9+
]
10+
11+
operations = [
12+
migrations.RemoveField(
13+
model_name="branchrole",
14+
name="is_photographer",
15+
),
16+
migrations.RemoveField(
17+
model_name="branchrole",
18+
name="is_translator",
19+
),
20+
migrations.RemoveField(
21+
model_name="competitionrole",
22+
name="can_delegate",
23+
),
24+
]

bullet/bullet_admin/mixins.py

Lines changed: 7 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
1-
from typing import Callable, Protocol
1+
from typing import Any, Callable, Protocol
22

33
from competitions.models import Venue
4-
from django.contrib.auth.views import redirect_to_login
5-
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
4+
from django.core.exceptions import ImproperlyConfigured
65
from django.db.models import QuerySet
76
from django.http import HttpRequest, HttpResponseNotFound
8-
from django.urls import reverse
97
from users.models.organizers import User
108

11-
from bullet_admin.utils import get_active_competition, get_redirect_url, is_admin
9+
from bullet_admin.utils import get_active_competition, get_redirect_url
1210

1311

1412
class MixinProtocol(Protocol):
1513
request: HttpRequest
1614
get_context_data: Callable[..., dict]
15+
kwargs: dict[str, Any]
1716
get_object: Callable
1817
get_queryset: Callable
1918
get_model: Callable
@@ -24,73 +23,10 @@ class AuthedHttpRequest(HttpRequest):
2423
user: User # type: ignore
2524

2625

27-
class AccessMixin(MixinProtocol):
28-
def can_access(self):
29-
raise NotImplementedError()
30-
31-
def handle_fail(self):
32-
if self.request.user.is_authenticated:
33-
raise PermissionDenied("You don't have access to this page.")
34-
35-
return redirect_to_login(
36-
self.request.get_full_path(), reverse("badmin:login"), "next"
37-
)
38-
39-
def dispatch(self, request, *args, **kwargs):
40-
if self.request.user.is_anonymous or not self.can_access():
41-
return self.handle_fail()
42-
return super().dispatch(request, *args, **kwargs)
43-
44-
45-
class AdminRequiredMixin(AccessMixin):
46-
def can_access(self):
47-
competition = get_active_competition(self.request)
48-
if not competition:
49-
return False
50-
51-
return is_admin(self.request.user, competition)
52-
53-
54-
class OperatorRequiredMixin(AccessMixin):
55-
def can_access(self):
56-
competition = get_active_competition(self.request)
57-
if not competition:
58-
return False
59-
60-
brole = self.request.user.get_branch_role(self.request.BRANCH)
61-
if brole.is_admin:
62-
return True
63-
64-
crole = self.request.user.get_competition_role(competition)
65-
return crole.venues or crole.countries
66-
67-
68-
class TranslatorRequiredMixin(AccessMixin):
69-
def can_access(self):
70-
role = self.request.user.get_branch_role(self.request.BRANCH)
71-
return role.is_translator
72-
73-
74-
class DelegateRequiredMixin(AccessMixin):
75-
def can_access(self):
76-
role = self.request.user.get_branch_role(self.request.BRANCH)
77-
if role.is_admin:
78-
return True
79-
80-
competition = get_active_competition(self.request)
81-
if not competition:
82-
return False
83-
84-
crole = self.request.user.get_competition_role(competition)
85-
return crole.can_delegate and not crole.is_operator
86-
87-
88-
class VenueMixin:
26+
class VenueMixin(MixinProtocol):
8927
"""
9028
Sets self.venue to venue admin's venue or ?venue parameter
9129
if accessed by higher admin.
92-
93-
Should be used after `AnyAdminRequiredMixin`.
9430
"""
9531

9632
def get_available_venues(self, request) -> QuerySet[Venue]:
@@ -128,7 +64,7 @@ def get_context_data(self, *args, **kwargs):
12864
return ctx
12965

13066

131-
class IsOperatorContext:
67+
class IsOperatorContext(MixinProtocol):
13268
def get_context_data(self, **kwargs):
13369
ctx = super().get_context_data(**kwargs)
13470
competition = get_active_competition(self.request)
@@ -137,7 +73,7 @@ def get_context_data(self, **kwargs):
13773
return ctx
13874

13975

140-
class RedirectBackMixin:
76+
class RedirectBackMixin(MixinProtocol):
14177
default_success_url = None
14278

14379
def get_default_success_url(self) -> str:

bullet/bullet_admin/models.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
from typing import TYPE_CHECKING, Collection
2+
13
from django.db import models
24
from django_countries.fields import CountryField
35
from web.fields import BranchField, ChoiceArrayField
46

7+
if TYPE_CHECKING:
8+
from competitions.models.venues import Venue
9+
510

611
class BranchRole(models.Model):
12+
id: int
713
user = models.ForeignKey("users.User", on_delete=models.CASCADE)
14+
user_id: int
815
branch = BranchField()
9-
is_translator = models.BooleanField(default=False)
10-
is_photographer = models.BooleanField(default=False)
1116
is_admin = models.BooleanField(default=False)
1217

1318
class Meta:
@@ -17,21 +22,23 @@ class Meta:
1722

1823

1924
class CompetitionRole(models.Model):
25+
id: int
2026
user = models.ForeignKey("users.User", on_delete=models.CASCADE)
27+
user_id: int
2128
competition = models.ForeignKey(
2229
"competitions.Competition", on_delete=models.CASCADE
2330
)
31+
competition_id: int
2432
countries = ChoiceArrayField(CountryField(), blank=True, null=True)
2533
venue_objects = models.ManyToManyField(
2634
"competitions.Venue",
2735
related_name="+",
2836
blank=True,
2937
)
30-
can_delegate = models.BooleanField(default=False)
3138
is_operator = models.BooleanField(default=False)
3239

3340
@property
34-
def venues(self):
41+
def venues(self) -> "Collection[Venue]":
3542
if not self.id:
3643
return []
3744
return self.venue_objects.all()

0 commit comments

Comments
 (0)