Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
368 changes: 141 additions & 227 deletions bullet/bullet_admin/access.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion bullet/bullet_admin/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

@admin.register(BranchRole)
class BranchRoleAdmin(admin.ModelAdmin):
list_display = ("branch", "user", "is_translator", "is_admin")
list_display = ("branch", "user", "is_admin")
list_filter = ("branch",)
autocomplete_fields = ("user",)

Expand Down
8 changes: 7 additions & 1 deletion bullet/bullet_admin/forms/category.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from competitions.models import Category
from django.forms import ModelForm
from django.forms import CheckboxSelectMultiple, ModelForm


class CategoryForm(ModelForm):
Expand All @@ -14,13 +14,15 @@ class Meta:
"max_members_per_team",
"max_teams_per_school",
"max_teams_second_round",
"educations",
]

labels = {
"problems_per_team": "Available problems",
"max_members_per_team": "Max. number of team members",
"max_teams_per_school": "Max. number of teams per school",
"max_teams_second_round": "Max. number of teams per school (2nd round)",
"educations": "Allowed school grades",
}

help_texts = {
Expand All @@ -35,5 +37,9 @@ class Meta:
"round of registration.",
}

widgets = {
"educations": CheckboxSelectMultiple,
}

def __init__(self, **kwargs):
super().__init__(**kwargs)
6 changes: 6 additions & 0 deletions bullet/bullet_admin/forms/education.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from django import forms
from education.models import School

from bullet_admin.forms.utils import get_country_choices


class SchoolForm(forms.ModelForm):
class Meta:
Expand All @@ -16,3 +18,7 @@ class Meta:
"search": "You can add any extra words or phrases that won't be shown, but "
"will get used by the search engine.",
}

def __init__(self, competition, user, **kwargs):
super().__init__(**kwargs)
self.fields["country"].choices = get_country_choices(competition, user)
10 changes: 6 additions & 4 deletions bullet/bullet_admin/forms/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ class Meta:
class BranchRoleForm(forms.ModelForm):
class Meta:
model = BranchRole
fields = ("is_translator", "is_photographer", "is_admin")
fields = ("is_admin",)


class CompetitionRoleForm(forms.ModelForm):
class Meta:
model = CompetitionRole
fields = ("venue_objects", "countries", "can_delegate", "is_operator")
fields = ("venue_objects", "countries", "is_operator")
widgets = {
"countries": forms.CheckboxSelectMultiple(),
"venue_objects": forms.CheckboxSelectMultiple(),
Expand Down Expand Up @@ -72,5 +72,7 @@ def clean(self):
"The user cannot be both a venue and a country administrator."
)

if self.cleaned_data["can_delegate"] and self.cleaned_data["is_operator"]:
raise ValidationError("Operator cannot have delegate permission.")
if countries and self.cleaned_data["is_operator"]:
raise ValidationError(
"Operator with countries is unsupported permission configuration."
)
12 changes: 8 additions & 4 deletions bullet/bullet_admin/forms/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
from users.models import User


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

if not user.get_branch_role(competition.branch).is_admin:
crole = user.get_competition_role(competition)
choices = list(filter(lambda x: x[0] in crole.countries, choices))
if user:
if not user.get_branch_role(competition.branch).is_admin:
crole = user.get_competition_role(competition)
countries = set(crole.countries or [])
choices = list(filter(lambda x: x[0] in countries, choices))

if allow_empty:
choices.insert(0, ("", "--------"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 5.2.7 on 2025-10-13 08:10

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("bullet_admin", "0008_remove_branchrole_is_school_editor"),
]

operations = [
migrations.RemoveField(
model_name="branchrole",
name="is_photographer",
),
migrations.RemoveField(
model_name="branchrole",
name="is_translator",
),
migrations.RemoveField(
model_name="competitionrole",
name="can_delegate",
),
]
78 changes: 7 additions & 71 deletions bullet/bullet_admin/mixins.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
from typing import Callable, Protocol
from typing import Any, Callable, Protocol

from competitions.models import Venue
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.core.exceptions import ImproperlyConfigured
from django.db.models import QuerySet
from django.http import HttpRequest, HttpResponseNotFound
from django.urls import reverse
from users.models.organizers import User

from bullet_admin.utils import get_active_competition, get_redirect_url, is_admin
from bullet_admin.utils import get_active_competition, get_redirect_url


class MixinProtocol(Protocol):
request: HttpRequest
get_context_data: Callable[..., dict]
kwargs: dict[str, Any]
get_object: Callable
get_queryset: Callable
get_model: Callable
Expand All @@ -24,73 +23,10 @@ class AuthedHttpRequest(HttpRequest):
user: User # type: ignore


class AccessMixin(MixinProtocol):
def can_access(self):
raise NotImplementedError()

def handle_fail(self):
if self.request.user.is_authenticated:
raise PermissionDenied("You don't have access to this page.")

return redirect_to_login(
self.request.get_full_path(), reverse("badmin:login"), "next"
)

def dispatch(self, request, *args, **kwargs):
if self.request.user.is_anonymous or not self.can_access():
return self.handle_fail()
return super().dispatch(request, *args, **kwargs)


class AdminRequiredMixin(AccessMixin):
def can_access(self):
competition = get_active_competition(self.request)
if not competition:
return False

return is_admin(self.request.user, competition)


class OperatorRequiredMixin(AccessMixin):
def can_access(self):
competition = get_active_competition(self.request)
if not competition:
return False

brole = self.request.user.get_branch_role(self.request.BRANCH)
if brole.is_admin:
return True

crole = self.request.user.get_competition_role(competition)
return crole.venues or crole.countries


class TranslatorRequiredMixin(AccessMixin):
def can_access(self):
role = self.request.user.get_branch_role(self.request.BRANCH)
return role.is_translator


class DelegateRequiredMixin(AccessMixin):
def can_access(self):
role = self.request.user.get_branch_role(self.request.BRANCH)
if role.is_admin:
return True

competition = get_active_competition(self.request)
if not competition:
return False

crole = self.request.user.get_competition_role(competition)
return crole.can_delegate and not crole.is_operator


class VenueMixin:
class VenueMixin(MixinProtocol):
"""
Sets self.venue to venue admin's venue or ?venue parameter
if accessed by higher admin.

Should be used after `AnyAdminRequiredMixin`.
"""

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


class IsOperatorContext:
class IsOperatorContext(MixinProtocol):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
competition = get_active_competition(self.request)
Expand All @@ -137,7 +73,7 @@ def get_context_data(self, **kwargs):
return ctx


class RedirectBackMixin:
class RedirectBackMixin(MixinProtocol):
default_success_url = None

def get_default_success_url(self) -> str:
Expand Down
15 changes: 11 additions & 4 deletions bullet/bullet_admin/models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
from typing import TYPE_CHECKING, Collection

from django.db import models
from django_countries.fields import CountryField
from web.fields import BranchField, ChoiceArrayField

if TYPE_CHECKING:
from competitions.models.venues import Venue


class BranchRole(models.Model):
id: int
user = models.ForeignKey("users.User", on_delete=models.CASCADE)
user_id: int
branch = BranchField()
is_translator = models.BooleanField(default=False)
is_photographer = models.BooleanField(default=False)
is_admin = models.BooleanField(default=False)

class Meta:
Expand All @@ -17,21 +22,23 @@ class Meta:


class CompetitionRole(models.Model):
id: int
user = models.ForeignKey("users.User", on_delete=models.CASCADE)
user_id: int
competition = models.ForeignKey(
"competitions.Competition", on_delete=models.CASCADE
)
competition_id: int
countries = ChoiceArrayField(CountryField(), blank=True, null=True)
venue_objects = models.ManyToManyField(
"competitions.Venue",
related_name="+",
blank=True,
)
can_delegate = models.BooleanField(default=False)
is_operator = models.BooleanField(default=False)

@property
def venues(self):
def venues(self) -> "Collection[Venue]":
if not self.id:
return []
return self.venue_objects.all()
Expand Down
Loading