@@ -114,12 +114,10 @@ Team list
{% endfor %}
- {% if not hide_venue %}
-
- {{ team.venue.name }}
- {{ team.venue.category.identifier|title }}
-
- {% endif %}
+
+ {{ team.venue.name }}
+ {{ team.venue.category.identifier|title }}
+
{% if team.status == team.status.UNCONFIRMED %}
Unconfirmed
diff --git a/bullet/bullet_admin/templates/bullet_admin/teams/restore.html b/bullet/bullet_admin/templates/bullet_admin/teams/restore.html
deleted file mode 100644
index a3b5838d..00000000
--- a/bullet/bullet_admin/templates/bullet_admin/teams/restore.html
+++ /dev/null
@@ -1,22 +0,0 @@
-{% extends "bullet_admin/base.html" %}
-
-{% block title %}
- Team restore
-{% endblock title %}
-
-{% block content %}
-
-
-
-
-{% endblock content %}
diff --git a/bullet/bullet_admin/templates/bullet_admin/teams/revert_team.html b/bullet/bullet_admin/templates/bullet_admin/teams/revert_team.html
deleted file mode 100644
index 3b3b3eee..00000000
--- a/bullet/bullet_admin/templates/bullet_admin/teams/revert_team.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{% extends "bullet_admin/base.html" %}
-
-{% block title %}
- Team revert
-{% endblock title %}
-
-{% block content %}
-
-
-
- Revert team changes?
-
-
-
-
-{% endblock content %}
diff --git a/bullet/bullet_admin/templates/bullet_admin/users/form.html b/bullet/bullet_admin/templates/bullet_admin/users/form.html
index 5ad54553..57686550 100644
--- a/bullet/bullet_admin/templates/bullet_admin/users/form.html
+++ b/bullet/bullet_admin/templates/bullet_admin/users/form.html
@@ -51,20 +51,6 @@
-
-
- {% bcheckbox bform.is_translator %} Allow access to content management
-
-
- This lets the user go to the "Content" section in the admin sidebar. The user can manage the pages, blocks and other content options.
-
-
-
-
- {% bcheckbox bform.is_photographer %} Photo management
-
- This will allow the user to upload and manage photo albums.
-
{% bcheckbox bform.is_admin %} Is branch administrator
@@ -105,14 +91,6 @@
{% bfield cform.countries %}
-
-
- {% bcheckbox cform.can_delegate %} Can delegate permissions
-
-
- This will allow the user to create new users with the same or lower permissions than themselves. Additionally, the user can grant those permissions to existing users.
-
-
{% bcheckbox cform.is_operator %} Limit to operator access
diff --git a/bullet/bullet_admin/templatetags/access.py b/bullet/bullet_admin/templatetags/access.py
index 351427ad..10676765 100644
--- a/bullet/bullet_admin/templatetags/access.py
+++ b/bullet/bullet_admin/templatetags/access.py
@@ -1,68 +1,78 @@
+from typing import Callable
+
from bullet_admin.access import (
- can_access_venue,
- is_any_admin,
+ CompetitionPermissionCallable,
+ VenuePermissionCallable,
+ is_admin,
+ is_admin_in,
is_branch_admin,
+ is_branch_admin_in,
is_country_admin,
is_country_admin_in,
+ is_operator,
+ is_operator_in,
)
from bullet_admin.utils import get_active_competition
+from competitions.models.venues import Venue
from django import template
+from django.http import HttpRequest
+from users.models.organizers import User
register = template.Library()
-@register.simple_tag(takes_context=True, name="is_any_admin")
-def is_any_admin_tag(context):
- request = context["request"]
- return is_any_admin(request.user, get_active_competition(request))
+def competition_check(perm: CompetitionPermissionCallable) -> Callable:
+ def fn(context):
+ request: HttpRequest = context["request"]
+ user = request.user
+ assert isinstance(user, User)
+ competition = get_active_competition(request)
-@register.simple_tag(takes_context=True, name="is_country_admin")
-def is_country_admin_tag(context):
- request = context["request"]
- return is_country_admin(request.user, get_active_competition(request))
+ return perm(user, competition)
+ return fn
-@register.simple_tag(takes_context=True, name="is_country_admin_in")
-def is_country_admin_in_tag(context, country):
- request = context["request"]
- return is_country_admin_in(request.user, get_active_competition(request), country)
+def venue_check(perm: VenuePermissionCallable) -> Callable:
+ def fn(context, venue: Venue | None = None):
+ request: HttpRequest = context["request"]
-@register.simple_tag(takes_context=True, name="is_venue_admin")
-def is_venue_admin_tag(context, venue):
- request = context["request"]
- return can_access_venue(request.user, venue)
+ if not venue:
+ if "venue" in context:
+ venue = context["venue"]
+ else:
+ raise ValueError(f"{perm.__name__} called without venue")
+ user = request.user
+ assert isinstance(user, User)
-@register.simple_tag(takes_context=True, name="is_branch_admin")
-def is_branch_admin_tag(context):
- request = context["request"]
- return is_branch_admin(request.user, request.BRANCH)
-
+ return perm(user, venue) # type:ignore
-@register.simple_tag(takes_context=True, name="is_any_operator")
-def is_any_operator_tag(context):
- request = context["request"]
- return is_any_admin(
- request.user, get_active_competition(request), allow_operator=True
- )
+ return fn
-@register.simple_tag(takes_context=True, name="is_country_operator")
-def is_country_operator_tag(context):
- request = context["request"]
- return is_country_admin(
- request.user, get_active_competition(request), allow_operator=True
- )
-
+register.simple_tag(
+ competition_check(is_branch_admin), takes_context=True, name="is_branch_admin"
+)
+register.simple_tag(
+ competition_check(is_country_admin), takes_context=True, name="is_country_admin"
+)
+register.simple_tag(competition_check(is_admin), takes_context=True, name="is_admin")
+register.simple_tag(
+ competition_check(is_operator), takes_context=True, name="is_operator"
+)
-@register.simple_tag(takes_context=True, name="is_country_operator_in")
-def is_country_operator_in_tag(context, country):
- request = context["request"]
- return is_country_admin_in(
- request.user, get_active_competition(request), country, allow_operator=True
- )
+register.simple_tag(
+ venue_check(is_branch_admin_in), takes_context=True, name="is_branch_admin_in"
+)
+register.simple_tag(
+ venue_check(is_country_admin_in), takes_context=True, name="is_country_admin_in"
+)
+register.simple_tag(venue_check(is_admin_in), takes_context=True, name="is_admin_in")
+register.simple_tag(
+ venue_check(is_operator_in), takes_context=True, name="is_operator_in"
+)
@register.simple_tag(takes_context=True, name="get_active_competition")
diff --git a/bullet/bullet_admin/templatetags/badmin.py b/bullet/bullet_admin/templatetags/badmin.py
index 954e61ef..80b3eb16 100644
--- a/bullet/bullet_admin/templatetags/badmin.py
+++ b/bullet/bullet_admin/templatetags/badmin.py
@@ -1,11 +1,9 @@
from bullet.search import DumbPage
-from bullet_admin.access import is_country_admin
+from bullet_admin.sidebar import get_sidebar
from bullet_admin.utils import get_active_competition
-from competitions.branches import Branch
from competitions.models import Competition
from django import template
from django.conf import settings
-from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from users.models import User
@@ -16,140 +14,10 @@
@register.inclusion_tag("bullet_admin/partials/sidebar_menu.html", takes_context=True)
def admin_sidebar(context):
user: User = context.request.user
- branch: Branch = context.request.BRANCH
competition: Competition = get_active_competition(context.request)
-
- menu_items = []
-
- branch_role = user.get_branch_role(branch)
- competition_role = user.get_competition_role(competition)
- any_admin = (
- branch_role.is_admin or competition_role.venues or competition_role.countries
- )
-
- country_admin = is_country_admin(user, competition)
-
- if any_admin:
- items = [("fa-users", "Teams", reverse("badmin:team_list"))]
-
- if not competition_role.is_operator:
- items.extend(
- [
- ("fa-envelope", "Emails", reverse("badmin:email_list")),
- ("fa-trash", "Deleted teams", reverse("badmin:recently_deleted")),
- ]
- )
-
- items.extend(
- [
- (
- "fa-barcode",
- "Problem scanning",
- reverse("badmin:scanning_problems"),
- ),
- (
- "fa-magnifying-glass",
- "Review",
- reverse("badmin:scanning_review"),
- ),
- ("fa-trophy", "Results", reverse("badmin:results")),
- ]
- )
-
- if country_admin:
- items.append(("fa-diamond", "Wildcards", reverse("badmin:wildcard_list")))
-
- menu_items.append(("Competition", items))
-
- if branch_role.is_translator:
- menu_items.append(
- (
- "Content",
- (
- ("fa-file-text", "Pages", reverse("badmin:page_list")),
- ("fa-cube", "Blocks", reverse("badmin:contentblock_list")),
- ("fa-bars", "Menu items", reverse("badmin:menu_list")),
- ("fa-folder", "File browser", reverse("badmin:file_tree")),
- ),
- )
- )
-
- if branch_role.is_photographer or branch_role.is_admin:
- menu_items.append(
- (
- "Photos",
- (("fa-book-open", "Albums", reverse("badmin:album_list")),),
- )
- )
-
- if (
- branch_role.is_admin
- or competition_role.can_delegate
- or competition_role.countries
- or competition_role.venues
- and not competition_role.is_operator
- ):
- items = []
-
- if competition_role.can_delegate or branch_role.is_admin:
- items.append(("fa-users", "Users", reverse("badmin:user_list")))
- if user.is_superuser or branch_role.is_admin or competition_role.countries:
- items.append(("fa-building", "Schools", reverse("badmin:school_list")))
- if (
- user.is_superuser
- or branch_role.is_admin
- or competition_role.countries
- or competition_role.venues
- ):
- items.append(("fa-location-pin", "Venues", reverse("badmin:venue_list")))
- items.append(
- ("fa-file-text", "TeX Templates", reverse("badmin:tex_template_list"))
- )
- if branch_role.is_admin:
- items.append(
- ("fa-gear", "Edit competition", reverse("badmin:competition_edit"))
- )
- items.append(
- ("fa-people-group", "Categories", reverse("badmin:category_list"))
- )
- items.append(
- ("fa-upload", "Import archive", reverse("badmin:archive_import"))
- )
- if branch_role.is_admin or competition_role.countries:
- items.append(
- (
- "fa-upload",
- "Upload solutions PDF",
- reverse("badmin:archive_problem_upload"),
- )
- )
- if not competition.results_public:
- items.append(
- (
- "fa-upload",
- "Upload tearoffs",
- reverse("badmin:competition_upload_tearoffs"),
- )
- )
- if branch_role.is_admin:
- items.append(
- (
- "fa-fast-forward",
- "Move waiting lists",
- reverse("badmin:competition_automove"),
- )
- )
-
- menu_items.append(
- (
- "Settings",
- items,
- )
- )
-
context.update(
{
- "menu_items": menu_items,
+ "menu_items": get_sidebar(user, competition),
"competition": get_active_competition(context.request),
"is_staging": settings.PARENT_HOST != "naboj.org",
}
diff --git a/bullet/bullet_admin/urls/__init__.py b/bullet/bullet_admin/urls/__init__.py
index 8a3fa636..5eeb7376 100644
--- a/bullet/bullet_admin/urls/__init__.py
+++ b/bullet/bullet_admin/urls/__init__.py
@@ -2,7 +2,7 @@
from bullet_admin.views import (
CompetitionSwitchView,
- albums,
+ album,
archive,
auth,
category,
@@ -141,7 +141,7 @@
),
path(
"content/menu/edit//",
- content.MenuItemEditView.as_view(),
+ content.MenuItemUpdateView.as_view(),
name="menu_edit",
),
path(
@@ -163,11 +163,6 @@
path("teams/export/", teams.TeamExportView.as_view(), name="team_export"),
path("teams/create/", teams.TeamCreateView.as_view(), name="team_create"),
path("teams//", teams.TeamEditView.as_view(), name="team_edit"),
- path(
- "teams//restore",
- teams.TeamRestoreView.as_view(),
- name="team_restore",
- ),
path(
"teams/recently_deleted",
teams.RecentlyDeletedTeamsView.as_view(),
@@ -198,11 +193,6 @@
teams.TeamDeleteView.as_view(),
name="team_delete",
),
- path(
- "teams//revert//",
- teams.TeamRevertView.as_view(),
- name="team_revert",
- ),
path("_school_input", teams.SchoolInputView.as_view(), name="school_input"),
path("users/", users.UserListView.as_view(), name="user_list"),
path("users/create/", users.UserCreateView.as_view(), name="user_create"),
@@ -276,14 +266,14 @@
education.SchoolCreateView.as_view(),
name="school_create",
),
- path("gallery/albums/", albums.AlbumListView.as_view(), name="album_list"),
- path("gallery/albums/new/", albums.AlbumCreateView.as_view(), name="album_create"),
+ path("gallery/albums/", album.AlbumListView.as_view(), name="album_list"),
+ path("gallery/albums/new/", album.AlbumCreateView.as_view(), name="album_create"),
path(
- "gallery/albums//", albums.AlbumUpdateView.as_view(), name="album_edit"
+ "gallery/albums//", album.AlbumUpdateView.as_view(), name="album_edit"
),
path(
"gallery/albums//delete",
- albums.AlbumDeleteView.as_view(),
+ album.AlbumDeleteView.as_view(),
name="album_delete",
),
path(
diff --git a/bullet/bullet_admin/utils.py b/bullet/bullet_admin/utils.py
index 008c2402..b16d0e0d 100644
--- a/bullet/bullet_admin/utils.py
+++ b/bullet/bullet_admin/utils.py
@@ -1,10 +1,11 @@
from typing import TYPE_CHECKING
+from competitions.branches import Branch
from competitions.models import Competition
+from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q
from django.http import HttpRequest
from django.utils.http import url_has_allowed_host_and_scheme
-from users.models import User
from bullet_admin.models import CompetitionRole
@@ -13,19 +14,31 @@
def get_active_competition(request: HttpRequest) -> Competition:
+ """Gets the currently active competition in admin interface."""
if not hasattr(request, "_badmin_competition"):
- session_key = f"badmin_{request.BRANCH.identifier}_competition"
+ branch = get_active_branch(request)
+ session_key = f"badmin_{branch.identifier}_competition"
if session_key not in request.session:
- request._badmin_competition = Competition.objects.get_current_competition(
- request.BRANCH
- )
+ competition = Competition.objects.get_current_competition(branch)
else:
stored_id = request.session.get(session_key)
- request._badmin_competition = Competition.objects.filter(
- branch=request.BRANCH, id=stored_id
+ competition = Competition.objects.filter(
+ branch=branch, id=stored_id
).first()
+ setattr(request, "_badmin_competition", competition)
+ return getattr(request, "_badmin_competition")
+
+
+def get_active_branch(request: HttpRequest) -> Branch:
+ """
+ Gets the branch of the request.
+
+ Use instead of request.BRANCH to avoid getting yelled at by the type checker.
+ """
+ if not hasattr(request, "BRANCH"):
+ raise ImproperlyConfigured("The request does not have an associated branch.")
- return request._badmin_competition
+ return getattr(request, "BRANCH")
def get_allowed_countries(request: HttpRequest):
@@ -36,35 +49,6 @@ def get_allowed_countries(request: HttpRequest):
return None
-def can_access_venue(request: HttpRequest, venue: "Venue") -> bool:
- brole = request.user.get_branch_role(request.BRANCH)
- if brole.is_admin and venue.category.competition.branch == request.BRANCH:
- return True
-
- competition = get_active_competition(request)
- if not competition or venue.category.competition != competition:
- return False
- crole = request.user.get_competition_role(competition)
- if crole.venues:
- return venue in crole.venues
- if crole.countries:
- return venue.country in crole.countries
-
- return False
-
-
-def is_admin(user: User, competition: Competition):
- if not user.is_authenticated:
- return False
-
- brole = user.get_branch_role(competition.branch)
- if brole.is_admin:
- return True
-
- crole = user.get_competition_role(competition)
- return (crole.venues or crole.countries) and not crole.is_operator
-
-
def get_venue_admin_emails(venue: "Venue"):
return list(
CompetitionRole.objects.filter(is_operator=False)
diff --git a/bullet/bullet_admin/views/__init__.py b/bullet/bullet_admin/views/__init__.py
index 8af7d6ea..8a34c259 100644
--- a/bullet/bullet_admin/views/__init__.py
+++ b/bullet/bullet_admin/views/__init__.py
@@ -3,6 +3,7 @@
from django.http import HttpResponseNotAllowed
from django.views.generic import TemplateView
from django.views.generic.edit import BaseDeleteView
+from django.views.generic.edit import DeleteView as DjDeleteView
from django_htmx.http import HttpResponseClientRefresh
from bullet_admin.mixins import MixinProtocol
@@ -34,7 +35,7 @@ def get_context_data(self, **kwargs):
return ctx
-class GenericDelete(MixinProtocol):
+class GenericDeleteView(DjDeleteView):
template_name = "bullet_admin/generic/delete.html"
model_name = None
object_name = None
diff --git a/bullet/bullet_admin/views/albums.py b/bullet/bullet_admin/views/album.py
similarity index 77%
rename from bullet/bullet_admin/views/albums.py
rename to bullet/bullet_admin/views/album.py
index 195cfac4..a967ff9b 100644
--- a/bullet/bullet_admin/views/albums.py
+++ b/bullet/bullet_admin/views/album.py
@@ -1,19 +1,20 @@
from datetime import datetime, timezone
from countries.logic.detection import get_country_language_from_request
+from countries.utils import country_reverse
from django.contrib import messages
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse, reverse_lazy
-from django.views.generic import CreateView, DeleteView, ListView, UpdateView
+from django.views.generic import CreateView, ListView, UpdateView
from gallery.models import Album, Photo
from PIL import Image
-from bullet_admin.access import PhotoUploadAccess
+from bullet_admin.access import PermissionCheckMixin, is_admin
from bullet_admin.forms.album import AlbumForm
from bullet_admin.mixins import RedirectBackMixin
-from bullet_admin.utils import get_active_competition
-from bullet_admin.views import GenericDelete, GenericForm
+from bullet_admin.utils import get_active_branch, get_active_competition
+from bullet_admin.views import GenericDeleteView, GenericForm
from bullet_admin.views.generic.links import (
DeleteIcon,
EditIcon,
@@ -24,7 +25,8 @@
from bullet_admin.views.generic.list import GenericList
-class AlbumListView(PhotoUploadAccess, GenericList, ListView):
+class AlbumListView(PermissionCheckMixin, GenericList, ListView):
+ required_permissions = [is_admin]
list_links = [
NewLink("album", reverse_lazy("badmin:album_create")),
]
@@ -40,13 +42,9 @@ def get_queryset(self):
return Album.objects.filter(competition=get_active_competition(self.request))
def get_row_links(self, obj) -> list[Link]:
- assert self.detection
- country, language = self.detection
- view = reverse(
+ view = country_reverse(
"archive_album",
kwargs={
- "b_country": country,
- "b_language": language,
"competition_number": obj.competition.number,
"slug": obj.slug,
},
@@ -71,8 +69,8 @@ class AlbumFormMixin(RedirectBackMixin, GenericForm):
form_multipart = True
def get_form_kwargs(self):
- kw = super().get_form_kwargs()
- kw["branch"] = self.request.BRANCH
+ kw = super().get_form_kwargs() # type:ignore
+ kw["branch"] = get_active_branch(self.request)
return kw
def form_valid(self, form):
@@ -97,11 +95,12 @@ def get_default_success_url(self):
return reverse("badmin:album_edit", kwargs={"pk": self.object.id})
-class AlbumUpdateView(PhotoUploadAccess, AlbumFormMixin, UpdateView):
+class AlbumUpdateView(PermissionCheckMixin, AlbumFormMixin, UpdateView):
+ required_permissions = [is_admin]
form_title = "Edit album"
def get_queryset(self):
- return Album.objects.filter(competition__branch=self.request.BRANCH)
+ return Album.objects.filter(competition=get_active_competition(self.request))
def form_valid(self, form):
ret = super().form_valid(form)
@@ -109,7 +108,8 @@ def form_valid(self, form):
return ret
-class AlbumCreateView(PhotoUploadAccess, AlbumFormMixin, CreateView):
+class AlbumCreateView(PermissionCheckMixin, AlbumFormMixin, CreateView):
+ required_permissions = [is_admin]
form_title = "New album"
def form_valid(self, form):
@@ -118,7 +118,8 @@ def form_valid(self, form):
return ret
-class AlbumDeleteView(PhotoUploadAccess, RedirectBackMixin, GenericDelete, DeleteView):
+class AlbumDeleteView(PermissionCheckMixin, RedirectBackMixin, GenericDeleteView):
+ required_permissions = [is_admin]
model = Album
default_success_url = reverse_lazy("badmin:album_list")
diff --git a/bullet/bullet_admin/views/archive.py b/bullet/bullet_admin/views/archive.py
index a805c6a6..e9c8912f 100644
--- a/bullet/bullet_admin/views/archive.py
+++ b/bullet/bullet_admin/views/archive.py
@@ -3,13 +3,18 @@
from django.views.generic import FormView
from problems.logic.upload import ProblemImportError
-from bullet_admin.access import BranchAdminAccess, CountryAdminAccess
+from bullet_admin.access import (
+ PermissionCheckMixin,
+ is_branch_admin,
+ is_country_admin,
+)
from bullet_admin.forms.archive import ProblemImportForm, ProblemUploadForm
from bullet_admin.utils import get_active_competition
from bullet_admin.views import GenericForm
-class ProblemImportView(BranchAdminAccess, GenericForm, FormView):
+class ProblemImportView(PermissionCheckMixin, GenericForm, FormView):
+ required_permissions = [is_branch_admin]
form_class = ProblemImportForm
template_name = "bullet_admin/archive/import.html"
@@ -23,11 +28,12 @@ def form_valid(self, form):
form.save()
messages.success(self.request, "Successfully imported problems.")
except ProblemImportError as e:
- messages.error(self.request, e)
+ messages.error(self.request, str(e))
return redirect("badmin:archive_import")
-class ProblemPDFUploadView(CountryAdminAccess, GenericForm, FormView):
+class ProblemPDFUploadView(PermissionCheckMixin, GenericForm, FormView):
+ required_permissions = [is_country_admin]
form_class = ProblemUploadForm
form_title = "Upload problems"
form_multipart = True
diff --git a/bullet/bullet_admin/views/category.py b/bullet/bullet_admin/views/category.py
index d7991bdf..e04fdd88 100644
--- a/bullet/bullet_admin/views/category.py
+++ b/bullet/bullet_admin/views/category.py
@@ -4,7 +4,7 @@
from django.utils.safestring import mark_safe
from django.views.generic import CreateView, ListView, UpdateView
-from bullet_admin.access import BranchAdminAccess
+from bullet_admin.access import PermissionCheckMixin, is_branch_admin
from bullet_admin.forms.category import CategoryForm
from bullet_admin.utils import get_active_competition
from bullet_admin.views import GenericForm
@@ -12,7 +12,8 @@
from bullet_admin.views.generic.list import GenericList
-class CategoryUpdateView(BranchAdminAccess, GenericForm, UpdateView):
+class CategoryUpdateView(PermissionCheckMixin, GenericForm, UpdateView):
+ required_permissions = [is_branch_admin]
form_title = "Edit category"
form_class = CategoryForm
@@ -24,7 +25,8 @@ def get_success_url(self):
return reverse("badmin:category_list")
-class CategoryCreateView(BranchAdminAccess, GenericForm, CreateView):
+class CategoryCreateView(PermissionCheckMixin, GenericForm, CreateView):
+ required_permissions = [is_branch_admin]
form_title = "New category"
form_class = CategoryForm
@@ -32,11 +34,13 @@ def form_valid(self, form):
category: Category = form.save(commit=False)
category.competition = get_active_competition(self.request)
category.save()
+ form.save_m2m()
return redirect("badmin:category_list")
-class CategoryListView(GenericList, ListView):
+class CategoryListView(PermissionCheckMixin, GenericList, ListView):
+ required_permissions = [is_branch_admin]
list_title = "Categories"
list_links = [
NewLink("category", reverse_lazy("badmin:category_create")),
diff --git a/bullet/bullet_admin/views/competition.py b/bullet/bullet_admin/views/competition.py
index a412ac6f..48c83399 100644
--- a/bullet/bullet_admin/views/competition.py
+++ b/bullet/bullet_admin/views/competition.py
@@ -15,16 +15,18 @@
from users.logic import move_all_eligible_teams
from bullet_admin.access import (
- BranchAdminAccess,
- CountryAdminAccess,
- UnlockedCompetitionMixin,
+ PermissionCheckMixin,
+ is_branch_admin,
+ is_competition_unlocked,
+ is_country_admin,
)
from bullet_admin.forms.competition import CompetitionForm, TearoffUploadForm
-from bullet_admin.utils import get_active_competition
+from bullet_admin.utils import get_active_branch, get_active_competition
from bullet_admin.views import GenericForm
-class CompetitionUpdateView(BranchAdminAccess, UpdateView):
+class CompetitionUpdateView(PermissionCheckMixin, GenericForm, UpdateView):
+ required_permissions = [is_branch_admin]
form_class = CompetitionForm
form_title = "Edit competition"
template_name = "bullet_admin/competition/form.html"
@@ -37,11 +39,12 @@ def get_success_url(self):
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
- kwargs["branch"] = self.request.BRANCH
+ kwargs["branch"] = get_active_branch(self.request)
return kwargs
-class CompetitionCreateView(BranchAdminAccess, GenericForm, CreateView):
+class CompetitionCreateView(PermissionCheckMixin, GenericForm, CreateView):
+ required_permissions = [is_branch_admin]
form_class = CompetitionForm
form_title = "New competition"
@@ -50,13 +53,18 @@ def get_success_url(self):
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
- kwargs["branch"] = self.request.BRANCH
+ kwargs["branch"] = get_active_branch(self.request)
return kwargs
-class CompetitionFinalizeView(UnlockedCompetitionMixin, BranchAdminAccess, FormView):
+class CompetitionFinalizeView(PermissionCheckMixin, GenericForm, FormView):
+ required_permissions = [is_branch_admin, is_competition_unlocked]
form_class = Form
- template_name = "bullet_admin/competition/confirm.html"
+ form_title = "Finalize competition?"
+ form_submit_label = "Finalize"
+ form_submit_color = "red"
+ form_submit_icon = "mdi:check"
+ template_name = "bullet_admin/competition/finalize.html"
def form_valid(self, form):
competition = get_active_competition(self.request)
@@ -73,9 +81,8 @@ def finalize(competition: "Competition"):
competition.save()
-class CompetitionAutomoveView(
- UnlockedCompetitionMixin, BranchAdminAccess, GenericForm, FormView
-):
+class CompetitionAutomoveView(PermissionCheckMixin, GenericForm, FormView):
+ required_permissions = [is_branch_admin, is_competition_unlocked]
form_class = Form
form_title = "Move waiting lists automatically"
form_submit_label = "Move automatically"
@@ -92,7 +99,8 @@ def form_valid(self, form):
return redirect("badmin:home")
-class CompetitionTearoffUploadView(CountryAdminAccess, GenericForm, FormView):
+class CompetitionTearoffUploadView(PermissionCheckMixin, GenericForm, FormView):
+ required_permissions = [is_country_admin, is_competition_unlocked]
require_unlocked_competition = True
form_class = TearoffUploadForm
form_title = "Tearoff upload"
diff --git a/bullet/bullet_admin/views/content.py b/bullet/bullet_admin/views/content.py
index ce6ccdca..ac799a56 100644
--- a/bullet/bullet_admin/views/content.py
+++ b/bullet/bullet_admin/views/content.py
@@ -10,6 +10,7 @@
from django.views.generic import CreateView, DeleteView, FormView, ListView, UpdateView
from web.models import ContentBlock, Menu, Page, PageBlock
+from bullet_admin.access import PermissionCheckMixin, is_country_admin
from bullet_admin.forms.content import (
ContentBlockForm,
ContentBlockWithRefForm,
@@ -19,9 +20,13 @@
PageCopyForm,
PageForm,
)
-from bullet_admin.mixins import RedirectBackMixin, TranslatorRequiredMixin
+from bullet_admin.mixins import (
+ MixinProtocol,
+ RedirectBackMixin,
+)
+from bullet_admin.utils import get_active_branch
from bullet_admin.views import DeleteView as BDeleteView
-from bullet_admin.views import GenericDelete, GenericForm
+from bullet_admin.views import GenericDeleteView, GenericForm
from bullet_admin.views.generic.links import (
DeleteIcon,
EditIcon,
@@ -32,14 +37,15 @@
from bullet_admin.views.generic.list import GenericList
-class PageQuerySetMixin:
+class PageQuerySetMixin(MixinProtocol):
def get_queryset(self):
- return Page.objects.filter(branch=self.request.BRANCH).order_by(
+ return Page.objects.filter(branch=get_active_branch(self.request)).order_by(
"slug", "language"
)
-class PageListView(TranslatorRequiredMixin, PageQuerySetMixin, GenericList, ListView):
+class PageListView(PermissionCheckMixin, PageQuerySetMixin, GenericList, ListView):
+ required_permissions = [is_country_admin]
list_links = [NewLink("page", reverse_lazy("badmin:page_create"))]
table_labels = {"slug": "URL"}
@@ -69,7 +75,8 @@ def get_row_links(self, obj) -> list[Link]:
]
-class PageCopyView(TranslatorRequiredMixin, PageQuerySetMixin, GenericForm, FormView):
+class PageCopyView(PermissionCheckMixin, PageQuerySetMixin, GenericForm, FormView):
+ required_permissions = [is_country_admin]
template_name = "bullet_admin/content/page_copy.html"
form_title = "Copy content"
form_class = PageCopyForm
@@ -106,18 +113,19 @@ def form_valid(self, form):
class PageEditView(
- TranslatorRequiredMixin,
+ PermissionCheckMixin,
PageQuerySetMixin,
RedirectBackMixin,
GenericForm,
UpdateView,
):
+ required_permissions = [is_country_admin]
form_class = PageForm
template_name = "bullet_admin/content/page_edit.html"
def get_form_kwargs(self):
kw = super().get_form_kwargs()
- kw["branch"] = self.request.BRANCH
+ kw["branch"] = get_active_branch(self.request)
return kw
def get_default_success_url(self):
@@ -125,18 +133,19 @@ def get_default_success_url(self):
class PageCreateView(
- TranslatorRequiredMixin,
+ PermissionCheckMixin,
PageQuerySetMixin,
RedirectBackMixin,
GenericForm,
CreateView,
):
+ required_permissions = [is_country_admin]
form_title = "Create page"
form_class = PageForm
def get_form_kwargs(self):
kw = super().get_form_kwargs()
- kw["branch"] = self.request.BRANCH
+ kw["branch"] = get_active_branch(self.request)
return kw
def get_initial(self):
@@ -150,22 +159,24 @@ def get_default_success_url(self):
def form_valid(self, form):
page = form.save(commit=False)
- page.branch = self.request.BRANCH
+ page.branch = get_active_branch(self.request)
page.save()
return HttpResponseRedirect(self.get_success_url())
class PageDeleteView(
- TranslatorRequiredMixin, PageQuerySetMixin, RedirectBackMixin, DeleteView
+ PermissionCheckMixin, PageQuerySetMixin, RedirectBackMixin, DeleteView
):
+ required_permissions = [is_country_admin]
template_name = "bullet_admin/content/page_delete.html"
def get_default_success_url(self):
return reverse("badmin:page_list")
-class PageBlockListView(TranslatorRequiredMixin, ListView):
+class PageBlockListView(PermissionCheckMixin, ListView):
+ required_permissions = [is_country_admin]
template_name = "bullet_admin/content/page_block_list.html"
def get_queryset(self):
@@ -179,8 +190,9 @@ def get_context_data(self, *, object_list=None, **kwargs):
class PageBlockUpdateView(
- TranslatorRequiredMixin, FormAndFormsetMixin, GenericForm, FormView
+ PermissionCheckMixin, FormAndFormsetMixin, GenericForm, FormView
):
+ required_permissions = [is_country_admin]
form_title = "Page block content"
@cached_property
@@ -200,7 +212,7 @@ def get_formset_class(self):
def save_forms(self, form, formset):
block = self.page_block
if block.data is None:
- block.data = {}
+ block.data = {} # type:ignore
block.data.update(form.cleaned_data)
if formset is not None:
items = []
@@ -214,7 +226,7 @@ def save_forms(self, form, formset):
block.data["items"] = items
block.save()
- def get_initial(self):
+ def get_initial(self): # type:ignore
return self.page_block.data
def get_formset_kwargs(self):
@@ -229,7 +241,8 @@ def get_success_url(self):
)
-class PageBlockSettingsView(TranslatorRequiredMixin, GenericForm, UpdateView):
+class PageBlockSettingsView(PermissionCheckMixin, GenericForm, UpdateView):
+ required_permissions = [is_country_admin]
form_title = "Page block settings"
form_class = PageBlockUpdateForm
@@ -246,7 +259,8 @@ def get_success_url(self):
)
-class PageBlockCreateView(TranslatorRequiredMixin, GenericForm, CreateView):
+class PageBlockCreateView(PermissionCheckMixin, GenericForm, CreateView):
+ required_permissions = [is_country_admin]
form_title = "New page block"
form_class = PageBlockCreateForm
@@ -264,7 +278,8 @@ def form_valid(self, form):
return HttpResponseRedirect(self.get_success_url())
-class PageBlockDeleteView(TranslatorRequiredMixin, DeleteView):
+class PageBlockDeleteView(PermissionCheckMixin, DeleteView):
+ required_permissions = [is_country_admin]
template_name = "bullet_admin/content/page_block_delete.html"
def get_object(self, queryset=None):
@@ -280,28 +295,30 @@ def get_success_url(self):
)
-class ContentBlockQuerySetMixin:
+class ContentBlockQuerySetMixin(MixinProtocol):
def get_queryset(self):
- return ContentBlock.objects.filter(branch=self.request.BRANCH).order_by(
- "group", "reference", "language"
- )
+ return ContentBlock.objects.filter(
+ branch=get_active_branch(self.request)
+ ).order_by("group", "reference", "language")
-class ContentBlockListView(TranslatorRequiredMixin, ListView):
+class ContentBlockListView(PermissionCheckMixin, ListView):
+ required_permissions = [is_country_admin]
template_name = "bullet_admin/content/contentblock_list.html"
paginate_by = 50
- def get_queryset(self):
+ def get_queryset(self): # type:ignore
return (
ContentBlock.objects.filter(
- Q(branch=self.request.BRANCH) | Q(branch__isnull=True)
+ Q(branch=get_active_branch(self.request)) | Q(branch__isnull=True)
)
.values("group", "reference")
.distinct()
)
-class ContentBlockTranslationListView(TranslatorRequiredMixin, ListView):
+class ContentBlockTranslationListView(PermissionCheckMixin, ListView):
+ required_permissions = [is_country_admin]
template_name = "bullet_admin/content/contentblock_trans.html"
def get_context_data(self, *args, **kwargs):
@@ -315,22 +332,22 @@ def get_context_data(self, *args, **kwargs):
def get_queryset(self):
return (
ContentBlock.objects.filter(
- Q(branch=self.request.BRANCH) | Q(branch__isnull=True)
+ Q(branch=get_active_branch(self.request)) | Q(branch__isnull=True)
)
.filter(group=self.kwargs["group"], reference=self.kwargs["reference"])
.order_by("branch", "language", "country")
)
-class ContentBlockEditView(
- TranslatorRequiredMixin, ContentBlockQuerySetMixin, UpdateView
-):
+class ContentBlockEditView(PermissionCheckMixin, ContentBlockQuerySetMixin, UpdateView):
+ required_permissions = [is_country_admin]
template_name = "bullet_admin/content/contentblock_form.html"
form_class = ContentBlockForm
+ object: ContentBlock
def get_form_kwargs(self):
kw = super().get_form_kwargs()
- kw["branch"] = self.request.BRANCH
+ kw["branch"] = get_active_branch(self.request)
return kw
def get_success_url(self):
@@ -341,8 +358,11 @@ def get_success_url(self):
class ContentBlockDeleteView(
- TranslatorRequiredMixin, ContentBlockQuerySetMixin, BDeleteView
+ PermissionCheckMixin, ContentBlockQuerySetMixin, BDeleteView
):
+ required_permissions = [is_country_admin]
+ object: ContentBlock
+
def get_success_url(self):
return reverse(
"badmin:contentblock_trans",
@@ -351,14 +371,15 @@ def get_success_url(self):
class ContentBlockCreateView(
- TranslatorRequiredMixin, ContentBlockQuerySetMixin, CreateView
+ PermissionCheckMixin, ContentBlockQuerySetMixin, CreateView
):
+ required_permissions = [is_country_admin]
template_name = "bullet_admin/content/contentblock_form.html"
form_class = ContentBlockWithRefForm
def get_form_kwargs(self):
kw = super().get_form_kwargs()
- kw["branch"] = self.request.BRANCH
+ kw["branch"] = get_active_branch(self.request)
return kw
def get_initial(self):
@@ -369,7 +390,7 @@ def get_initial(self):
def form_valid(self, form):
content_block = form.save(commit=False)
- content_block.branch = self.request.BRANCH
+ content_block.branch = get_active_branch(self.request)
content_block.save()
return HttpResponseRedirect(
@@ -383,7 +404,8 @@ def form_valid(self, form):
)
-class MenuItemListView(TranslatorRequiredMixin, GenericList, ListView):
+class MenuItemListView(PermissionCheckMixin, GenericList, ListView):
+ required_permissions = [is_country_admin]
list_links = [NewLink("menu item", reverse_lazy("badmin:menu_create"))]
table_labels = {"url": "URL"}
@@ -393,7 +415,7 @@ class MenuItemListView(TranslatorRequiredMixin, GenericList, ListView):
}
def get_queryset(self):
- return Menu.objects.filter(branch=self.request.BRANCH).order_by(
+ return Menu.objects.filter(branch=get_active_branch(self.request)).order_by(
"language", "order"
)
@@ -408,34 +430,36 @@ def get_row_links(self, obj) -> list[Link]:
]
-class MenuItemEditView(TranslatorRequiredMixin, UpdateView):
+class MenuItemUpdateView(PermissionCheckMixin, UpdateView):
+ required_permissions = [is_country_admin]
template_name = "bullet_admin/content/menu_form.html"
form_class = MenuItemForm
def get_form_kwargs(self):
kw = super().get_form_kwargs()
- kw["branch"] = self.request.BRANCH
+ kw["branch"] = get_active_branch(self.request)
return kw
def get_queryset(self):
- return Menu.objects.filter(branch=self.request.BRANCH)
+ return Menu.objects.filter(branch=get_active_branch(self.request))
def get_success_url(self):
return reverse("badmin:menu_list")
-class MenuItemCreateView(TranslatorRequiredMixin, CreateView):
+class MenuItemCreateView(PermissionCheckMixin, CreateView):
+ required_permissions = [is_country_admin]
template_name = "bullet_admin/content/menu_form.html"
form_class = MenuItemForm
def get_form_kwargs(self):
kw = super().get_form_kwargs()
- kw["branch"] = self.request.BRANCH
+ kw["branch"] = get_active_branch(self.request)
return kw
def form_valid(self, form):
obj = form.save(commit=False)
- obj.branch = self.request.BRANCH.id
+ obj.branch = get_active_branch(self.request)
obj.save()
return HttpResponseRedirect(self.get_success_url())
@@ -444,7 +468,6 @@ def get_success_url(self):
return reverse("badmin:menu_list")
-class MenuItemDeleteView(
- TranslatorRequiredMixin, RedirectBackMixin, GenericDelete, DeleteView
-):
+class MenuItemDeleteView(PermissionCheckMixin, RedirectBackMixin, GenericDeleteView):
+ required_permissions = [is_country_admin]
model = Menu
diff --git a/bullet/bullet_admin/views/documentation.py b/bullet/bullet_admin/views/documentation.py
index 6b0d5d10..c0b84f57 100644
--- a/bullet/bullet_admin/views/documentation.py
+++ b/bullet/bullet_admin/views/documentation.py
@@ -1,28 +1,34 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404
from django.views.generic import TemplateView
-
+from users.models.organizers import User
+
+from bullet_admin.access import (
+ is_admin,
+ is_branch_admin,
+ is_country_admin,
+ is_operator,
+)
from bullet_admin.documentation import Access, get_page, get_pages
+from bullet_admin.mixins import MixinProtocol
from bullet_admin.utils import get_active_competition
-class DocumentationAccessMixin:
+class DocumentationAccessMixin(MixinProtocol):
def get_user_access(self) -> Access:
access = Access(0)
user = self.request.user
+ assert isinstance(user, User)
competition = get_active_competition(self.request)
- brole = user.get_branch_role(self.request.BRANCH)
- if brole.is_admin:
+ if is_branch_admin(user, competition):
access |= Access.BRANCH
-
- crole = user.get_competition_role(competition)
- if crole.is_operator:
- access |= Access.OPERATOR
- if bool(crole.venues):
- access |= Access.VENUE
- if bool(crole.countries):
+ if is_country_admin(user, competition):
access |= Access.COUNTRY
+ if is_admin(user, competition):
+ access |= Access.VENUE
+ if is_operator(user, competition):
+ access |= Access.OPERATOR
return access
diff --git a/bullet/bullet_admin/views/education.py b/bullet/bullet_admin/views/education.py
index f98304e8..ccfb039f 100644
--- a/bullet/bullet_admin/views/education.py
+++ b/bullet/bullet_admin/views/education.py
@@ -5,22 +5,26 @@
from education.models import School
from bullet import search
-from bullet_admin.access import CountryAdminAccess, CountryAdminInAccess
+from bullet_admin.access import PermissionCheckMixin, is_country_admin
from bullet_admin.forms.education import SchoolForm
-from bullet_admin.mixins import RedirectBackMixin
-from bullet_admin.utils import get_allowed_countries
+from bullet_admin.mixins import MixinProtocol, RedirectBackMixin
+from bullet_admin.utils import get_active_competition, get_allowed_countries
from bullet_admin.views import GenericForm
from bullet_admin.views.generic.links import EditIcon, Link, NewLink
from bullet_admin.views.generic.list import GenericList
-class SchoolQuerySetMixin:
+class SchoolQuerySetMixin(MixinProtocol):
def get_queryset(self):
- return School.objects.filter(is_legacy=False)
+ qs = School.objects.filter(is_legacy=False)
+ allowed_countries = get_allowed_countries(self.request)
+ if allowed_countries is not None:
+ qs = qs.filter(country__in=allowed_countries)
+ return qs
-class SchoolListView(CountryAdminAccess, SchoolQuerySetMixin, GenericList, ListView):
- require_unlocked_competition = False
+class SchoolListView(PermissionCheckMixin, SchoolQuerySetMixin, GenericList, ListView):
+ required_permissions = [is_country_admin]
list_links = [NewLink("school", reverse_lazy("badmin:school_create"))]
table_fields = ["name", "address", "country"]
@@ -31,11 +35,6 @@ class SchoolListView(CountryAdminAccess, SchoolQuerySetMixin, GenericList, ListV
def get_queryset(self):
qs = super().get_queryset()
-
- allowed_countries = get_allowed_countries(self.request)
- if allowed_countries is not None:
- qs = qs.filter(country__in=allowed_countries)
-
qs = qs.order_by("country", "name", "address")
return qs
@@ -69,20 +68,24 @@ def get_row_links(self, obj) -> list[Link]:
class SchoolUpdateView(
- CountryAdminInAccess,
+ PermissionCheckMixin,
SchoolQuerySetMixin,
RedirectBackMixin,
GenericForm,
UpdateView,
):
+ required_permissions = [is_country_admin]
form_class = SchoolForm
template_name = "bullet_admin/education/school_form.html"
form_title = "Edit school"
require_unlocked_competition = False
default_success_url = reverse_lazy("badmin:school_list")
- def get_permission_country(self):
- return self.get_object().country
+ def get_form_kwargs(self):
+ kw = super().get_form_kwargs()
+ kw["competition"] = get_active_competition(self.request)
+ kw["user"] = self.request.user
+ return kw
def form_valid(self, form):
school: School = form.save(commit=False)
@@ -96,13 +99,24 @@ def form_valid(self, form):
class SchoolCreateView(
- CountryAdminAccess, SchoolQuerySetMixin, RedirectBackMixin, GenericForm, CreateView
+ PermissionCheckMixin,
+ SchoolQuerySetMixin,
+ RedirectBackMixin,
+ GenericForm,
+ CreateView,
):
+ required_permissions = [is_country_admin]
require_unlocked_competition = False
form_class = SchoolForm
form_title = "New school"
default_success_url = reverse_lazy("badmin:school_list")
+ def get_form_kwargs(self):
+ kw = super().get_form_kwargs()
+ kw["competition"] = get_active_competition(self.request)
+ kw["user"] = self.request.user
+ return kw
+
def form_valid(self, form):
school: School = form.save(commit=False)
self.object = school
diff --git a/bullet/bullet_admin/views/emails.py b/bullet/bullet_admin/views/emails.py
index b3321127..304a03e9 100644
--- a/bullet/bullet_admin/views/emails.py
+++ b/bullet/bullet_admin/views/emails.py
@@ -15,8 +15,8 @@
)
from users.models import EmailCampaign, TeamStatus, User
+from bullet_admin.access import PermissionCheckMixin, is_admin
from bullet_admin.forms.emails import EmailCampaignForm
-from bullet_admin.mixins import AdminRequiredMixin
from bullet_admin.utils import get_active_competition
from bullet_admin.views.generic.links import Link, NewLink, ViewIcon
from bullet_admin.views.generic.list import GenericList
@@ -56,7 +56,8 @@ def can_edit_campaign(request, campaign: EmailCampaign):
return True
-class CampaignListView(AdminRequiredMixin, GenericList, ListView):
+class CampaignListView(PermissionCheckMixin, GenericList, ListView):
+ required_permissions = [is_admin]
list_links = [NewLink("campaign", reverse_lazy("badmin:email_create"))]
table_fields = ["subject", "last_sent"]
@@ -69,7 +70,8 @@ def get_row_links(self, obj) -> list[Link]:
return [ViewIcon(reverse("badmin:email_detail", args=[obj.pk]))]
-class CampaignCreateView(AdminRequiredMixin, CreateView):
+class CampaignCreateView(PermissionCheckMixin, CreateView):
+ required_permissions = [is_admin]
form_class = EmailCampaignForm
template_name = "bullet_admin/emails/form.html"
@@ -90,7 +92,8 @@ def form_valid(self, form):
)
-class CampaignUpdateView(AdminRequiredMixin, UpdateView):
+class CampaignUpdateView(PermissionCheckMixin, UpdateView):
+ required_permissions = [is_admin]
form_class = EmailCampaignForm
template_name = "bullet_admin/emails/form.html"
@@ -115,7 +118,8 @@ def get_success_url(self):
return reverse("badmin:email_detail", kwargs={"pk": self.object.id})
-class CampaignDetailView(AdminRequiredMixin, DetailView):
+class CampaignDetailView(PermissionCheckMixin, DetailView):
+ required_permissions = [is_admin]
template_name = "bullet_admin/emails/detail.html"
def get_queryset(self):
@@ -137,7 +141,8 @@ def get_context_data(self, **kwargs):
return ctx
-class CampaignTeamListView(AdminRequiredMixin, TemplateView):
+class CampaignTeamListView(PermissionCheckMixin, TemplateView):
+ required_permissions = [is_admin]
template_name = "bullet_admin/emails/teams.html"
def dispatch(self, request, *args, **kwargs):
@@ -176,7 +181,9 @@ def post(self, request, *args, **kwargs):
)
-class CampaignSendTestView(AdminRequiredMixin, View):
+class CampaignSendTestView(PermissionCheckMixin, View):
+ required_permissions = [is_admin]
+
def post(self, request, *args, **kwargs):
campaign = get_object_or_404(
EmailCampaign,
@@ -194,7 +201,9 @@ def post(self, request, *args, **kwargs):
)
-class CampaignSendView(AdminRequiredMixin, View):
+class CampaignSendView(PermissionCheckMixin, View):
+ required_permissions = [is_admin]
+
def post(self, request, *args, **kwargs):
campaign = get_object_or_404(
EmailCampaign,
diff --git a/bullet/bullet_admin/views/files.py b/bullet/bullet_admin/views/files.py
index a030f980..114ae097 100644
--- a/bullet/bullet_admin/views/files.py
+++ b/bullet/bullet_admin/views/files.py
@@ -8,12 +8,12 @@
from django.urls import reverse
from django.views.generic import FormView, TemplateView
+from bullet_admin.access import PermissionCheckMixin, is_country_admin
from bullet_admin.forms.files import (
FileDeleteForm,
FileUploadForm,
FolderCreateForm,
)
-from bullet_admin.mixins import TranslatorRequiredMixin
from bullet_admin.views import GenericForm
@@ -27,7 +27,8 @@ def get_path(request, path):
return branch_root, abs_path
-class FileTreeView(TranslatorRequiredMixin, TemplateView):
+class FileTreeView(PermissionCheckMixin, TemplateView):
+ required_permissions = [is_country_admin]
template_name = "bullet_admin/files/tree.html"
def get_parents(self):
@@ -77,7 +78,8 @@ def get_context_data(self, **kwargs):
return ctx
-class FolderCreateView(TranslatorRequiredMixin, GenericForm, FormView):
+class FolderCreateView(PermissionCheckMixin, GenericForm, FormView):
+ required_permissions = [is_country_admin]
form_title = "Create new folder"
form_class = FolderCreateForm
@@ -100,7 +102,8 @@ def form_valid(self, form):
)
-class FileUploadView(TranslatorRequiredMixin, GenericForm, FormView):
+class FileUploadView(PermissionCheckMixin, GenericForm, FormView):
+ required_permissions = [is_country_admin]
form_title = "Upload file"
form_multipart = True
form_class = FileUploadForm
@@ -130,7 +133,8 @@ def form_valid(self, form):
)
-class FileDeleteView(TranslatorRequiredMixin, GenericForm, FormView):
+class FileDeleteView(PermissionCheckMixin, GenericForm, FormView):
+ required_permissions = [is_country_admin]
form_title = "Delete file"
form_class = FileDeleteForm
diff --git a/bullet/bullet_admin/views/results.py b/bullet/bullet_admin/views/results.py
index 006f5fe9..25cf4b7d 100644
--- a/bullet/bullet_admin/views/results.py
+++ b/bullet/bullet_admin/views/results.py
@@ -9,15 +9,15 @@
from django_countries.fields import Country
from problems.models import ResultRow
from users.models import Team
+from users.models.organizers import User
-from bullet_admin.access import AdminAccess, CountryAdminInAccess, VenueAccess
+from bullet_admin.access import PermissionCheckMixin, is_operator, is_operator_in
from bullet_admin.utils import get_active_competition
-class ResultsHomeView(AdminAccess, TemplateView):
+class ResultsHomeView(PermissionCheckMixin, TemplateView):
+ required_permissions = [is_operator]
template_name = "bullet_admin/results/select.html"
- allow_operator = True
- require_unlocked_competition = False
def dispatch(self, request, *args, **kwargs):
self.detection = get_country_language_from_request(self.request)
@@ -27,6 +27,7 @@ def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
+ assert self.detection
ctx = super().get_context_data(**kwargs)
competition = get_active_competition(self.request)
ctx["country"], ctx["language"] = self.detection
@@ -52,6 +53,7 @@ def get_context_data(self, **kwargs):
class ResultsAnnouncementView(TemplateView):
template_name = "bullet_admin/results/announce.html"
+ url: str
def get_team(self, position):
raise NotImplementedError()
@@ -86,25 +88,24 @@ def get_context_data(self, **kwargs):
return ctx
-class VenueResultsAnnouncementView(VenueAccess, ResultsAnnouncementView):
- allow_operator = True
- require_unlocked_competition = False
+class VenueResultsAnnouncementView(PermissionCheckMixin, ResultsAnnouncementView):
+ required_permissions = [is_operator_in]
url = "badmin:results_announce"
@cached_property
def venue(self):
return get_object_or_404(Venue, id=self.kwargs["venue"])
- def get_permission_venue(self) -> "Venue":
- return self.venue
+ def check_custom_permission(self, user: User) -> bool | None:
+ """The venue must be reviewed."""
+ return self.venue.is_reviewed
def get_team(self, position):
return Team.objects.filter(venue=self.venue, rank_venue=position).first()
-class CountryResultsAnnouncementView(CountryAdminInAccess, ResultsAnnouncementView):
- allow_operator = True
- require_unlocked_competition = False
+class CountryResultsAnnouncementView(PermissionCheckMixin, ResultsAnnouncementView):
+ required_permissions = [is_operator]
url = "badmin:results_announce_country"
@cached_property
@@ -115,8 +116,20 @@ def country(self):
def category(self):
return get_object_or_404(Category.objects.filter(id=self.kwargs["category"]))
- def get_permission_country(self) -> "Country":
- return self.country
+ def check_custom_permission(self, user: User) -> bool:
+ """
+ You must be a country administrator in the selected country,
+ or at least a venue operator of a venue inside the selected country.
+ """
+ # TODO: The country should be reviewed.
+ crole = user.get_competition_role(get_active_competition(self.request))
+ if crole.countries:
+ return self.country in crole.countries
+ if crole.venues:
+ return any(filter(lambda v: v.country == self.country, crole.venues)) # type:ignore
+ # The fallback here is True -> you don't have neither countries or venues,
+ # therefore you must be a higher admin.
+ return True
def get_team(self, position):
return Team.objects.filter(
diff --git a/bullet/bullet_admin/views/scanning.py b/bullet/bullet_admin/views/scanning.py
index 25502413..cada5e1c 100644
--- a/bullet/bullet_admin/views/scanning.py
+++ b/bullet/bullet_admin/views/scanning.py
@@ -1,5 +1,6 @@
import json
from datetime import datetime, timedelta
+from typing import Any
from django.conf import settings
from django.contrib import messages
@@ -12,6 +13,7 @@
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
+from django.utils.functional import cached_property
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView, TemplateView
@@ -25,13 +27,17 @@
from problems.logic.scanner import ScannedBarcode, parse_barcode, save_scan
from problems.models import Problem, ScannerLog, SolvedProblem
from users.models import Team
+from users.models.organizers import User
+from bullet_admin.access import PermissionCheckMixin, is_operator, is_operator_in
from bullet_admin.forms.review import get_review_formset
-from bullet_admin.mixins import OperatorRequiredMixin, VenueMixin
-from bullet_admin.utils import can_access_venue, get_active_competition
+from bullet_admin.mixins import VenueMixin
+from bullet_admin.utils import get_active_competition
-class ProblemScanView(OperatorRequiredMixin, View):
+class ProblemScanView(PermissionCheckMixin, View):
+ required_permissions = [is_operator]
+
def dispatch(self, request, *args, **kwargs):
self.competition = get_active_competition(request)
return super().dispatch(request, *args, **kwargs)
@@ -56,9 +62,11 @@ def get(self, request, *args, **kwargs):
self.get_context_data(),
)
- def scan(self, barcode) -> tuple[ScannerLog, ScannedBarcode]:
+ def scan(self, barcode) -> tuple[ScannerLog, ScannedBarcode | None]:
+ user = self.request.user
+ assert isinstance(user, User)
ts = timezone.now()
- log = ScannerLog(timestamp=ts, user=self.request.user, barcode=barcode)
+ log = ScannerLog(timestamp=ts, user=user, barcode=barcode)
try:
scanned_barcode = parse_barcode(self.competition, barcode)
except ValueError as e:
@@ -67,7 +75,7 @@ def scan(self, barcode) -> tuple[ScannerLog, ScannedBarcode]:
log.save()
return log, None
- if not can_access_venue(self.request, scanned_barcode.venue):
+ if not is_operator_in(user, scanned_barcode.venue):
log.result = ScannerLog.Result.SCAN_ERR
log.message = (
f"You don't have the required permissions to scan "
@@ -113,7 +121,7 @@ def post(self, request, *args, **kwargs):
log, barcode = self.scan(barcode)
template = "bullet_admin/scanning/problem.html"
- if self.request.htmx:
+ if getattr(self.request, "htmx", False):
template = "bullet_admin/scanning/problem/_response.html"
ctx = self.get_context_data()
@@ -123,7 +131,8 @@ def post(self, request, *args, **kwargs):
return trigger_client_event(response, "scan-complete", {"result": log.result})
-class VenueReviewView(OperatorRequiredMixin, VenueMixin, TemplateView):
+class VenueReviewView(PermissionCheckMixin, VenueMixin, TemplateView):
+ required_permissions = [is_operator]
template_name = "bullet_admin/scanning/review.html"
def get_teams(self):
@@ -201,7 +210,8 @@ def post(self, request, *args, **kwargs):
)
-class UndoScanView(OperatorRequiredMixin, TemplateView):
+class UndoScanView(PermissionCheckMixin, TemplateView):
+ required_permissions = [is_operator]
template_name = "bullet_admin/scanning/undo.html"
def redirect(self, team: Team | None = None):
@@ -212,6 +222,8 @@ def redirect(self, team: Team | None = None):
)
def post(self, request, *args, **kwargs):
+ user = request.user
+ assert isinstance(user, User)
try:
scanned_barcode = parse_barcode(
get_active_competition(request),
@@ -221,7 +233,7 @@ def post(self, request, *args, **kwargs):
messages.error(request, str(e))
return self.redirect()
- if not can_access_venue(request, scanned_barcode.venue):
+ if not is_operator_in(user, scanned_barcode.venue):
raise PermissionDenied()
if scanned_barcode.team.is_reviewed:
@@ -231,7 +243,7 @@ def post(self, request, *args, **kwargs):
mark_problem_unsolved(scanned_barcode.team, scanned_barcode.problem)
ScannerLog.objects.create(
- user=request.user,
+ user=user,
barcode=f"*{request.GET.get('barcode')}",
result=ScannerLog.Result.OK,
message="Scan undone.",
@@ -241,14 +253,16 @@ def post(self, request, *args, **kwargs):
return self.redirect(scanned_barcode.team)
-class TeamToggleReviewedView(OperatorRequiredMixin, VenueMixin, View):
+class TeamToggleReviewedView(PermissionCheckMixin, View):
+ required_permissions = [is_operator]
+
def post(self, request, *args, **kwargs):
team: Team = get_object_or_404(Team, id=kwargs["pk"])
if team.venue.is_reviewed:
raise PermissionDenied()
- if not can_access_venue(request, team.venue):
+ if not is_operator_in(request.user, team.venue):
raise PermissionDenied()
team.is_reviewed = not team.is_reviewed
@@ -267,28 +281,31 @@ def post(self, request, *args, **kwargs):
)
-class TeamReviewView(OperatorRequiredMixin, FormView):
+class TeamReviewView(PermissionCheckMixin, FormView):
+ required_permissions = [is_operator_in]
model = Team
template_name = "bullet_admin/scanning/review_team.html"
- def dispatch(self, request, *args, **kwargs):
- self.team: Team = (
- Team.objects.select_related("venue", "venue__category")
- .prefetch_related("solved_problems")
- .get(pk=kwargs["pk"])
+ @cached_property
+ def team(self) -> Team:
+ return get_object_or_404(
+ Team.objects.select_related("venue", "venue__category").prefetch_related(
+ "solved_problems"
+ ),
+ pk=self.kwargs["pk"],
)
- if not can_access_venue(request, self.team.venue):
- raise PermissionDenied()
- if self.team.is_reviewed:
- raise PermissionDenied()
+ def get_permission_venue(self):
+ return self.team.venue
- return super().dispatch(request, *args, **kwargs)
+ def check_custom_permission(self, user: User) -> bool | None:
+ """The team and its venue cannot be reviewed."""
+ return not self.team.is_reviewed and not self.team.venue.is_reviewed
def get_form_class(self):
return get_review_formset(self.team)
- def get_initial(self):
+ def get_initial(self): # type:ignore
competition = get_active_competition(self.request)
problems = Problem.objects.filter(competition=competition)
@@ -301,7 +318,7 @@ def get_initial(self):
if problem.number < self.team.venue.category.first_problem:
continue
- row = {"number": problem.number}
+ row: dict[str, Any] = {"number": problem.number}
if problem.id in solved_timestamps:
row["is_solved"] = True
row["competition_time"] = solved_timestamps[problem.id]
diff --git a/bullet/bullet_admin/views/teams.py b/bullet/bullet_admin/views/teams.py
index 5736a873..8ad4e196 100644
--- a/bullet/bullet_admin/views/teams.py
+++ b/bullet/bullet_admin/views/teams.py
@@ -1,21 +1,21 @@
import csv
import json
from collections import defaultdict
-from datetime import datetime, timedelta
+from datetime import datetime
from functools import partial
import yaml
from bullet.views import FormAndFormsetMixin
from competitions.forms.registration import ContestantForm
from django.contrib import messages
-from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Count
from django.forms import inlineformset_factory
-from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect
+from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import reverse
+from django.utils.functional import cached_property
from django.views import View
from django.views.generic import (
CreateView,
@@ -34,36 +34,48 @@
)
from users.logic import get_school_symbol
from users.models import Contestant, Team
+from users.models.organizers import User
from bullet import search, settings
-from bullet_admin.access import AdminAccess
+from bullet_admin.access import (
+ PermissionCheckMixin,
+ is_admin,
+ is_admin_in,
+ is_competition_unlocked,
+ is_operator,
+ is_operator_in,
+)
from bullet_admin.forms.teams import TeamExportForm, TeamFilterForm, TeamForm
from bullet_admin.forms.tex import TexTeamRenderForm
from bullet_admin.mixins import (
- AdminRequiredMixin,
IsOperatorContext,
- OperatorRequiredMixin,
RedirectBackMixin,
VenueMixin,
)
-from bullet_admin.utils import can_access_venue, get_active_competition
+from bullet_admin.utils import (
+ get_active_branch,
+ get_active_competition,
+)
from bullet_admin.views import GenericForm
-class TeamListView(OperatorRequiredMixin, IsOperatorContext, ListView):
+class TeamListView(PermissionCheckMixin, IsOperatorContext, ListView):
+ required_permissions = [is_operator]
template_name = "bullet_admin/teams/list.html"
paginate_by = 100
def get_form(self):
+ assert isinstance(self.request.user, User)
return TeamFilterForm(
get_active_competition(self.request),
self.request.user,
data=self.request.GET,
)
- def get_queryset(self):
+ def get_queryset(self): # type:ignore
competition = get_active_competition(self.request)
qs = Team.objects.filter(venue__category__competition=competition)
+ ids = []
if self.request.GET.get("q"):
ids = search.client.index("teams").search(
@@ -96,19 +108,14 @@ def get_queryset(self):
return qs
def get_context_data(self, *args, **kwargs):
+ assert isinstance(self.request.user, User)
ctx = super().get_context_data(*args, **kwargs)
- brole = self.request.user.get_branch_role(self.request.BRANCH)
- crole = self.request.user.get_competition_role(
- get_active_competition(self.request)
- )
- ctx["hide_venue"] = (
- not brole.is_admin and not crole.countries and len(crole.venues) < 2
- )
ctx["search_form"] = self.get_form()
return ctx
-class TeamExportView(AdminRequiredMixin, FormView):
+class TeamExportView(PermissionCheckMixin, FormView):
+ required_permissions = [is_admin]
template_name = "bullet_admin/teams/export.html"
form_class = TeamExportForm
@@ -138,7 +145,7 @@ def form_valid(self, form):
for team in qs
]
- response = None
+ response = HttpResponse()
if form.cleaned_data["format"] == TeamExportForm.Format.JSON:
response = HttpResponse(content_type="application/json")
json.dump(data, response)
@@ -154,29 +161,40 @@ def form_valid(self, form):
return response
-class TeamToCompetitionView(AdminRequiredMixin, RedirectBackMixin, TemplateView):
+class TeamToCompetitionView(PermissionCheckMixin, RedirectBackMixin, TemplateView):
+ required_permissions = [is_admin_in, is_competition_unlocked]
model = Team
template_name = "bullet_admin/teams/to_competition.html"
+ def get_permission_venue(self):
+ return self.team.venue
+
+ @cached_property
+ def team(self) -> Team:
+ return get_object_or_404(Team, id=self.kwargs["pk"], is_waiting=True)
+
def get_default_success_url(self):
return reverse("badmin:team_edit", kwargs={"pk": self.kwargs["pk"]})
def post(self, request, *args, **kwargs):
- team = get_object_or_404(Team, id=self.kwargs["pk"], is_waiting=True)
- if not can_access_venue(request, team.venue):
- return HttpResponseForbidden()
+ self.team.to_competition()
+ self.team.save()
- team.to_competition()
- team.save()
-
- send_to_competition_email.delay(team.id)
+ send_to_competition_email.delay(self.team.id)
return HttpResponseRedirect(self.get_success_url())
-class TeamGenerateDocumentView(AdminAccess, GenericForm, FormView):
- require_unlocked_competition = False
+class TeamGenerateDocumentView(PermissionCheckMixin, GenericForm, FormView):
+ required_permissions = [is_operator_in]
form_class = TexTeamRenderForm
- form_title = "Generate TeX Document"
+ form_title = "Generate TeX document"
+
+ def get_permission_venue(self):
+ return self.team.venue
+
+ @cached_property
+ def team(self) -> Team:
+ return get_object_or_404(Team, id=self.kwargs["pk"])
def get_form_kwargs(self):
kw = super().get_form_kwargs()
@@ -184,7 +202,7 @@ def get_form_kwargs(self):
return kw
def form_valid(self, form):
- team = get_object_or_404(Team, id=self.kwargs["pk"]).to_export()
+ team = self.team.to_export()
job = TexJob.objects.create(
creator=self.request.user,
template=form.cleaned_data["template"],
@@ -195,34 +213,40 @@ def form_valid(self, form):
return redirect("badmin:tex_job_detail", pk=job.id)
-class TeamResendConfirmationView(AdminRequiredMixin, View):
- def post(self, request, *args, **kwargs):
- team = get_object_or_404(Team, id=self.kwargs["pk"], confirmed_at__isnull=True)
- if not can_access_venue(request, team.venue):
- return HttpResponseForbidden()
+class TeamResendConfirmationView(PermissionCheckMixin, View):
+ required_permissions = [is_admin_in, is_competition_unlocked]
+
+ def get_permission_venue(self):
+ return self.team.venue
- send_confirmation_email.delay(team.id)
+ @cached_property
+ def team(self) -> Team:
+ return get_object_or_404(Team, id=self.kwargs["pk"], is_waiting=True)
+
+ def post(self, request, *args, **kwargs):
+ send_confirmation_email.delay(self.team.id)
messages.success(request, "The confirmation email was re-sent.")
- return HttpResponseRedirect(reverse("badmin:team_edit", kwargs={"pk": team.id}))
+ return HttpResponseRedirect(
+ reverse("badmin:team_edit", kwargs={"pk": self.team.id})
+ )
class TeamEditView(
- OperatorRequiredMixin,
+ PermissionCheckMixin,
IsOperatorContext,
RedirectBackMixin,
FormAndFormsetMixin,
UpdateView,
):
+ required_permissions = [is_operator_in]
template_name = "bullet_admin/teams/edit.html"
model = Team
- def get_object(self, queryset=None):
- obj = super().get_object(queryset)
- if not can_access_venue(self.request, obj.venue):
- raise PermissionDenied()
- return obj
+ def get_permission_venue(self):
+ return self.get_object().venue
def get_form_class(self):
+ assert isinstance(self.request.user, User)
competition = get_active_competition(self.request)
crole = self.request.user.get_competition_role(competition)
@@ -267,7 +291,7 @@ def get_context_data(self, *args, **kwargs):
ctx = super().get_context_data(*args, **kwargs)
country = str(ctx["object"].venue.country.code).lower()
language = ctx["object"].language
- branch = self.request.BRANCH
+ branch = get_active_branch(self.request)
ctx["root_url"] = (
f"https://{branch.identifier}.{settings.PARENT_HOST}/{country}/{language}/teams/"
)
@@ -292,15 +316,17 @@ def get_default_success_url(self):
return reverse("badmin:team_list")
-class TeamDeleteView(AdminRequiredMixin, DeleteView):
+class TeamDeleteView(PermissionCheckMixin, DeleteView):
+ required_permissions = [is_admin_in, is_competition_unlocked]
model = Team
template_name = "bullet_admin/teams/delete.html"
+ def get_permission_venue(self):
+ return self.get_object().venue
+
def post(self, request, *args, **kwargs):
send_mail = "send_mail" in self.request.POST
obj = self.get_object()
- if not can_access_venue(request, obj.venue):
- return HttpResponseForbidden()
if send_mail:
send_deletion_email.delay(obj)
@@ -311,7 +337,9 @@ def get_success_url(self):
return reverse("badmin:team_list")
-class SchoolInputView(AdminRequiredMixin, View):
+class SchoolInputView(PermissionCheckMixin, View):
+ required_permissions = [is_admin]
+
def get(self, request, *args, **kwargs):
schools = []
if "q" in request.GET:
@@ -335,7 +363,8 @@ def post(self, request, *args, **kwargs):
)
-class AssignTeamNumbersView(AdminRequiredMixin, VenueMixin, TemplateView):
+class AssignTeamNumbersView(PermissionCheckMixin, VenueMixin, TemplateView):
+ required_permissions = [is_admin]
template_name = "bullet_admin/teams/assign_numbers.html"
@transaction.atomic
@@ -415,16 +444,10 @@ def post(self, request, *args, **kwargs):
return HttpResponseRedirect(f"{u}?venue={self.venue.id}")
-class TeamCreateView(AdminAccess, CreateView):
+class TeamCreateView(PermissionCheckMixin, CreateView):
+ required_permissions = [is_admin]
template_name = "bullet_admin/teams/create.html"
-
- def get_form_class(self):
- competition = get_active_competition(self.request)
- crole = self.request.user.get_competition_role(competition)
-
- if crole.is_operator:
- return HttpResponseForbidden()
- return TeamForm
+ form_class = TeamForm
def get_form_kwargs(self):
kw = super().get_form_kwargs()
@@ -448,14 +471,22 @@ def get_team_members(team, time):
return Contestant.history.as_of(time).filter(team=team)
-class TeamHistoryView(AdminAccess, ListView):
+class TeamHistoryView(PermissionCheckMixin, ListView):
+ required_permissions = [is_admin_in]
model = Team
template_name = "bullet_admin/teams/history.html"
paginate_by = 20
+ def get_permission_venue(self):
+ return self.team.venue
+
+ @cached_property
+ def team(self) -> Team:
+ return get_object_or_404(Team, id=self.kwargs["pk"])
+
def get_queryset(self, *args, **kwargs):
qs = []
- team = Team.objects.get(id=self.kwargs["pk"])
+ team = self.team
current = team.history.first()
prev_members = get_team_members(team, datetime.now())
@@ -512,58 +543,23 @@ def get_context_data(self, *args, **kwargs):
return ctx
-class TeamRevertView(AdminRequiredMixin, RedirectBackMixin, TemplateView):
- model = Team
- template_name = "bullet_admin/teams/revert_team.html"
-
- def get_default_success_url(self):
- return reverse("badmin:team_history", kwargs={"pk": self.kwargs["pk"]})
-
- def post(self, request, *args, **kwargs):
- team = get_object_or_404(Team, id=self.kwargs["pk"])
- team_time, contestant_time = kwargs["team_time"], kwargs["contestant_time"]
- team.history.filter(history_date__lte=team_time).first().instance.save()
- current_members = get_team_members(team, datetime.now())
- previous_members = get_team_members(team, contestant_time)
-
- for member in current_members:
- if member not in previous_members:
- member.delete()
- for member in previous_members:
- member.history.as_of(contestant_time).save()
-
- return HttpResponseRedirect(self.get_success_url())
-
-
-class RecentlyDeletedTeamsView(AdminAccess, ListView):
+class RecentlyDeletedTeamsView(PermissionCheckMixin, ListView):
+ required_permissions = [is_admin]
template_name = "bullet_admin/teams/deleted.html"
paginate_by = 20
def get_queryset(self, *args, **kwargs):
+ assert isinstance(self.request.user, User)
+ competition = get_active_competition(self.request)
qs = Team.history.filter(
- history_type="-", history_date__gte=datetime.now() - timedelta(days=100)
+ history_type="-",
+ venue__category__competition=competition,
).order_by("-history_date")
- return qs
+ crole = self.request.user.get_competition_role(competition)
+ if crole.venues:
+ qs = qs.filter(venue__in=crole.venues)
+ if crole.countries:
+ qs = qs.filter(venue__country__in=crole.countries)
-class TeamRestoreView(AdminRequiredMixin, RedirectBackMixin, TemplateView):
- model = Team
- template_name = "bullet_admin/teams/restore.html"
-
- def get_default_success_url(self):
- return reverse("badmin:recently_deleted")
-
- def post(self, request, *args, **kwargs):
- team = Team.history.filter(id=kwargs["pk"]).first()
- prev = team.prev_record.instance
- time = team.history_date
-
- previous_members = get_team_members(prev, time)
- for member in previous_members:
- member.instance.save()
-
- team.delete()
- prev.save()
- prev.history.first().prev_record.delete()
-
- return HttpResponseRedirect(self.get_success_url())
+ return qs
diff --git a/bullet/bullet_admin/views/tex.py b/bullet/bullet_admin/views/tex.py
index f1c12a2d..a9c37c1d 100644
--- a/bullet/bullet_admin/views/tex.py
+++ b/bullet/bullet_admin/views/tex.py
@@ -11,7 +11,7 @@
from django_htmx.http import HTMX_STOP_POLLING
from documents.models import TexJob, TexTemplate
-from bullet_admin.access import AdminAccess
+from bullet_admin.access import PermissionCheckMixin, is_admin
from bullet_admin.forms.tex import LetterCallbackForm, TexRenderForm, TexTemplateForm
from bullet_admin.utils import get_active_competition
from bullet_admin.views import GenericForm
@@ -38,7 +38,7 @@ def post(self, *args, **kwargs):
class JobDetailView(LoginRequiredMixin, DetailView):
- require_unlocked_competition = False
+ object: TexJob
def get_queryset(self):
return TexJob.objects.filter(Q(creator=self.request.user) | Q(creator=None))
@@ -46,19 +46,19 @@ def get_queryset(self):
def render_to_response(self, context, **response_kwargs):
resp = super().render_to_response(context, **response_kwargs)
- if self.request.htmx and self.object.completed:
+ if getattr(self.request, "htmx") and self.object.completed:
resp.status_code = HTMX_STOP_POLLING
return resp
def get_template_names(self):
- if self.request.htmx:
+ if getattr(self.request, "htmx"):
return ["bullet_admin/tex/job_output.html"]
return ["bullet_admin/tex/job.html"]
-class TemplateListView(AdminAccess, GenericList, ListView):
- require_unlocked_competition = False
+class TemplateListView(PermissionCheckMixin, GenericList, ListView):
+ required_permissions = [is_admin]
list_title = "TeX templates"
table_fields = ["name", "type"]
@@ -91,11 +91,11 @@ def get_row_links(self, obj) -> list[Link]:
return links
-class TemplateCreateView(AdminAccess, GenericForm, CreateView):
- form_title = "Create TeX Template"
+class TemplateCreateView(PermissionCheckMixin, GenericForm, CreateView):
+ required_permissions = [is_admin]
+ form_title = "Create TeX template"
form_class = TexTemplateForm
form_multipart = True
- require_unlocked_competition = False
def form_valid(self, form):
self.object = form.save(commit=False)
@@ -105,11 +105,11 @@ def form_valid(self, form):
return redirect("badmin:tex_template_list")
-class TemplateUpdateView(AdminAccess, GenericForm, UpdateView):
- form_title = "Update TeX Template"
+class TemplateUpdateView(PermissionCheckMixin, GenericForm, UpdateView):
+ required_permissions = [is_admin]
+ form_title = "Update TeX template"
form_class = TexTemplateForm
form_multipart = True
- require_unlocked_competition = False
def get_queryset(self):
competition = get_active_competition(self.request)
@@ -119,10 +119,10 @@ def get_success_url(self):
return reverse("badmin:tex_template_list")
-class TemplateRenderView(AdminAccess, GenericForm, FormView):
- form_title = "Generate TeX Document"
+class TemplateRenderView(PermissionCheckMixin, GenericForm, FormView):
+ required_permissions = [is_admin]
+ form_title = "Generate TeX document"
form_class = TexRenderForm
- require_unlocked_competition = False
@cached_property
def tex_template(self):
diff --git a/bullet/bullet_admin/views/users.py b/bullet/bullet_admin/views/users.py
index 4fa2ffc6..54e1b6e3 100644
--- a/bullet/bullet_admin/views/users.py
+++ b/bullet/bullet_admin/views/users.py
@@ -15,8 +15,8 @@
from users.emails.users import send_onboarding_email
from users.models import User
+from bullet_admin.access import PermissionCheckMixin, is_admin
from bullet_admin.forms.users import BranchRoleForm, CompetitionRoleForm, UserForm
-from bullet_admin.mixins import DelegateRequiredMixin
from bullet_admin.models import BranchRole, CompetitionRole
from bullet_admin.utils import get_active_competition
from bullet_admin.views.generic.links import EditIcon, Link, NewLink
@@ -25,7 +25,8 @@
PASSWORD_ALPHABET = "346789ABCDEFGHJKLMNPQRTUVWXY"
-class UserListView(DelegateRequiredMixin, GenericList, ListView):
+class UserListView(PermissionCheckMixin, GenericList, ListView):
+ required_permissions = [is_admin]
list_links = [NewLink("user", reverse_lazy("badmin:user_create"))]
table_fields = ["get_full_name", "email", "has_branch_role"]
@@ -35,7 +36,7 @@ class UserListView(DelegateRequiredMixin, GenericList, ListView):
def get_queryset(self):
branch_role = BranchRole.objects.filter(
branch=self.request.BRANCH, user=OuterRef("pk")
- ).filter(Q(is_admin=True) | Q(is_translator=True))
+ ).filter(is_admin=True)
competition_role = CompetitionRole.objects.filter(
competition=get_active_competition(self.request), user=OuterRef("pk")
).filter(Q(venue_objects__isnull=False) | Q(countries__len__gt=0))
@@ -93,7 +94,9 @@ def get_forms(self, request, user=None):
return form, bform, cform
-class UserCreateView(DelegateRequiredMixin, UserFormsMixin, View):
+class UserCreateView(PermissionCheckMixin, UserFormsMixin, View):
+ required_permissions = [is_admin]
+
def get(self, request, *args, **kwargs):
form, bform, cform = self.get_forms(request)
@@ -144,7 +147,9 @@ def post(self, request, *args, **kwargs):
return HttpResponseRedirect(reverse("badmin:user_list"))
-class UserEditView(DelegateRequiredMixin, UserFormsMixin, View):
+class UserEditView(PermissionCheckMixin, UserFormsMixin, View):
+ required_permissions = [is_admin]
+
def can_edit_user(self, target: User):
user: User = self.request.user
competition = get_active_competition(self.request)
diff --git a/bullet/bullet_admin/views/venues.py b/bullet/bullet_admin/views/venues.py
index 73280f15..2d9274cb 100644
--- a/bullet/bullet_admin/views/venues.py
+++ b/bullet/bullet_admin/views/venues.py
@@ -32,23 +32,24 @@
from users.models import Team
from bullet_admin.access import (
- AdminAccess,
- CountryAdminAccess,
- VenueAccess,
+ PermissionCheckMixin,
+ is_admin,
+ is_admin_in,
+ is_competition_unlocked,
is_country_admin,
)
from bullet_admin.forms.documents import CertificateForm, TearoffForm
from bullet_admin.forms.venues import TeamListForm, VenueForm
-from bullet_admin.mixins import AdminRequiredMixin, AuthedHttpRequest, RedirectBackMixin
+from bullet_admin.mixins import AuthedHttpRequest, RedirectBackMixin
from bullet_admin.utils import get_active_competition
from bullet_admin.views import GenericForm
from bullet_admin.views.generic.links import Link, NewLink, ViewIcon
from bullet_admin.views.generic.list import GenericList
-class VenueListView(AdminAccess, GenericList, ListView):
+class VenueListView(PermissionCheckMixin, GenericList, ListView):
+ required_permissions = [is_admin]
request: AuthedHttpRequest # type: ignore
- require_unlocked_competition = False
table_fields = ["name", "category", "team_count", "capacity", "local_start"]
table_labels = {
@@ -92,7 +93,8 @@ def get_form_kwargs(self):
return kw
-class VenueDetailView(VenueAccess, DetailView):
+class VenueDetailView(PermissionCheckMixin, DetailView):
+ required_permissions = [is_admin_in]
require_unlocked_competition = False
template_name = "bullet_admin/venues/detail.html"
@@ -103,7 +105,8 @@ def get_queryset(self):
return Venue.objects.for_request(self.request)
-class VenueUpdateView(VenueAccess, VenueFormMixin, UpdateView):
+class VenueUpdateView(PermissionCheckMixin, VenueFormMixin, UpdateView):
+ required_permissions = [is_admin_in, is_competition_unlocked]
form_title = "Edit venue"
template_name = "bullet_admin/venues/form.html"
@@ -118,7 +121,8 @@ def get_success_url(self):
return reverse("badmin:venue_list")
-class VenueCreateView(CountryAdminAccess, VenueFormMixin, CreateView):
+class VenueCreateView(PermissionCheckMixin, VenueFormMixin, CreateView):
+ required_permissions = [is_country_admin]
form_title = "New venue"
def get_success_url(self):
@@ -126,7 +130,8 @@ def get_success_url(self):
return reverse("badmin:venue_list")
-class VenueMixin(VenueAccess):
+class VenueMixin(PermissionCheckMixin):
+ required_permissions = [is_admin_in]
template_name = "bullet_admin/venues/form.html"
def get_permission_venue(self) -> "Venue":
@@ -145,7 +150,6 @@ def get_context_data(self, **kwargs):
class CertificateView(VenueMixin, GenericForm, FormView):
- require_unlocked_competition = False
form_class = CertificateForm
def get_form_kwargs(self):
@@ -209,7 +213,6 @@ def form_valid(self, form):
class TeamListView(VenueMixin, GenericForm, FormView):
- require_unlocked_competition = False
form_class = TeamListForm
def form_valid(self, form):
@@ -222,15 +225,15 @@ def form_valid(self, form):
return FileResponse(data, as_attachment=True, filename="team_list.pdf")
-class WaitingListView(AdminRequiredMixin, VenueMixin, ListView):
- require_unlocked_competition = False
+class WaitingListView(VenueMixin, ListView):
template_name = "bullet_admin/venues/waiting_list.html"
def get_queryset(self):
return get_venue_waiting_list(self.venue)
-class WaitingListAutomoveView(AdminRequiredMixin, VenueMixin, GenericForm, FormView):
+class WaitingListAutomoveView(VenueMixin, GenericForm, FormView):
+ required_permissions = [is_admin_in, is_competition_unlocked]
form_class = Form
form_title = "Move waiting lists automatically"
form_submit_label = "Move automatically"
@@ -248,7 +251,6 @@ def get_redirect_url(self):
class TearoffView(VenueMixin, GenericForm, FormView):
form_class = TearoffForm
- form_multipart = True
template_name = "bullet_admin/venues/tearoffs.html"
def get_form_kwargs(self):
diff --git a/bullet/bullet_admin/views/wildcards.py b/bullet/bullet_admin/views/wildcards.py
index 3b2b9141..11418ace 100644
--- a/bullet/bullet_admin/views/wildcards.py
+++ b/bullet/bullet_admin/views/wildcards.py
@@ -2,7 +2,11 @@
from django.urls import reverse, reverse_lazy
from django.views.generic import CreateView, DeleteView, ListView
-from bullet_admin.access import CountryAdminAccess
+from bullet_admin.access import (
+ PermissionCheckMixin,
+ is_competition_unlocked,
+ is_country_admin,
+)
from bullet_admin.forms.teams import WildcardForm
from bullet_admin.utils import get_active_competition
from bullet_admin.views import GenericForm
@@ -10,7 +14,9 @@
from bullet_admin.views.generic.list import GenericList
-class WildcardQuerySetMixin:
+class WildcardViewMixin(PermissionCheckMixin):
+ required_permissions = [is_country_admin, is_competition_unlocked]
+
def get_queryset(self):
competition = get_active_competition(self.request)
qs = Wildcard.objects.filter(competition=competition).order_by(
@@ -19,9 +25,7 @@ def get_queryset(self):
return qs
-class WildcardListView(
- CountryAdminAccess, WildcardQuerySetMixin, GenericList, ListView
-):
+class WildcardListView(WildcardViewMixin, GenericList, ListView):
list_subtitle = (
"Wildcards allow schools to register additional teams during "
"the first round of the registration."
@@ -39,9 +43,7 @@ def get_row_links(self, obj) -> list[Link]:
return [DeleteIcon(reverse("badmin:wildcard_delete", args=[obj.pk]))]
-class WildcardCreateView(
- CountryAdminAccess, WildcardQuerySetMixin, GenericForm, CreateView
-):
+class WildcardCreateView(WildcardViewMixin, GenericForm, CreateView):
form_title = "New wildcard"
form_class = WildcardForm
@@ -54,7 +56,7 @@ def get_success_url(self):
return reverse("badmin:wildcard_list")
-class WildcardDeleteView(CountryAdminAccess, WildcardQuerySetMixin, DeleteView):
+class WildcardDeleteView(WildcardViewMixin, DeleteView):
template_name = "bullet_admin/wildcards/delete.html"
def get_success_url(self):
diff --git a/bullet/competitions/models/competitions.py b/bullet/competitions/models/competitions.py
index 5d0d98a4..d0f6e274 100644
--- a/bullet/competitions/models/competitions.py
+++ b/bullet/competitions/models/competitions.py
@@ -18,7 +18,7 @@ def get_random_string():
class CompetitionQuerySet(models.QuerySet):
- def get_current_competition(self, branch):
+ def get_current_competition(self, branch) -> "Competition|None":
return (
self.filter(branch=branch, web_start__lt=timezone.now())
.order_by("-web_start")
@@ -52,26 +52,9 @@ def for_user(self, user: "User", branch: "Branch"):
).values("competition")
return qs.filter(id__in=roles)
- def for_photos(self, user: "User", branch: "Branch"):
- """
- Filters competitions that should be visible for a given user.
- """
- qs = self.filter(branch=branch).order_by("-web_start")
-
- if not user.is_authenticated:
- return qs.filter(results_public=True)
-
- branch_role = user.get_branch_role(branch)
- if branch_role.is_admin or branch_role.is_photographer:
- return qs
-
- roles = CompetitionRole.objects.filter(
- user=user, competition__branch=branch
- ).values("competition")
- return qs.filter(id__in=roles)
-
class Competition(models.Model):
+ id: int
branch = BranchField()
number = models.IntegerField(
null=True, blank=True
@@ -95,7 +78,7 @@ class Competition(models.Model):
is_cancelled = models.BooleanField(default=False)
- objects = CompetitionQuerySet.as_manager()
+ objects: CompetitionQuerySet = CompetitionQuerySet.as_manager() # type:ignore
class Meta:
constraints = [
@@ -164,10 +147,12 @@ def state(self) -> State:
class Category(models.Model):
+ id: int
competition = models.ForeignKey(
"competitions.Competition",
on_delete=models.CASCADE,
)
+ competition_id: int
identifier = models.SlugField()
order = models.IntegerField(default=0)
diff --git a/bullet/competitions/models/venues.py b/bullet/competitions/models/venues.py
index 8f3385b4..8633a739 100644
--- a/bullet/competitions/models/venues.py
+++ b/bullet/competitions/models/venues.py
@@ -92,6 +92,7 @@ class RegistrationFlowType(models.IntegerChoices):
DEFAULT = 0, "Default"
NJ_SPAIN = 1, "Spain (NJ)"
+ id: int
name = models.CharField(max_length=256)
shortcode = models.CharField(max_length=6)
email = models.EmailField(blank=True, default="")
@@ -99,6 +100,7 @@ class RegistrationFlowType(models.IntegerChoices):
country = CountryField()
category = models.ForeignKey("competitions.Category", on_delete=models.CASCADE)
+ category_id: int
capacity = models.PositiveIntegerField(default=0)
accepted_languages = ChoiceArrayField(LanguageField())
@@ -114,7 +116,7 @@ class RegistrationFlowType(models.IntegerChoices):
choices=RegistrationFlowType.choices, default=RegistrationFlowType.DEFAULT
)
- objects = VenueManager.from_queryset(VenueQuerySet)()
+ objects: VenueQuerySet = VenueManager.from_queryset(VenueQuerySet)() # type:ignore
class Meta:
unique_together = ("category", "shortcode")
diff --git a/bullet/competitions/views/register.py b/bullet/competitions/views/register.py
index d83aa750..4cd5d2ae 100644
--- a/bullet/competitions/views/register.py
+++ b/bullet/competitions/views/register.py
@@ -1,7 +1,7 @@
from enum import IntEnum
from functools import partial
-from bullet_admin.utils import is_admin
+from bullet_admin.access import is_admin
from countries.utils import country_reverse
from django.contrib import messages
from django.db import transaction
diff --git a/bullet/gallery/models.py b/bullet/gallery/models.py
index f6d77dfc..5109bbc0 100644
--- a/bullet/gallery/models.py
+++ b/bullet/gallery/models.py
@@ -5,6 +5,7 @@
class Album(models.Model):
+ id: int
title = models.CharField(max_length=256)
slug = models.SlugField(max_length=256)
competition = models.ForeignKey(
@@ -12,6 +13,7 @@ class Album(models.Model):
on_delete=models.CASCADE,
related_name="albums",
)
+ competition_id: int
country = CountryField()
def __str__(self):
@@ -25,11 +27,13 @@ class Meta:
class Photo(models.Model):
+ id: int
album = models.ForeignKey(
"gallery.Album",
on_delete=models.CASCADE,
related_name="photos",
)
+ album_id: int
image_width = models.PositiveIntegerField()
image_height = models.PositiveIntegerField()
image = PictureField(
diff --git a/bullet/gallery/views/album.py b/bullet/gallery/views/album.py
index 20618213..706829e6 100644
--- a/bullet/gallery/views/album.py
+++ b/bullet/gallery/views/album.py
@@ -1,3 +1,5 @@
+from bullet_admin.access import MixinProtocol
+from bullet_admin.utils import get_active_branch
from competitions.models import Competition
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
@@ -7,11 +9,11 @@
from gallery.models import Album, Photo
-class GalleryCompetitionMixin(ArchiveCompetitionMixin):
+class GalleryCompetitionMixin(ArchiveCompetitionMixin, MixinProtocol):
@cached_property
def competition(self):
return get_object_or_404(
- Competition.objects.for_photos(self.request.user, self.request.BRANCH),
+ Competition.objects.filter(branch=get_active_branch(self.request)),
number=self.kwargs["competition_number"],
)
diff --git a/bullet/problems/models.py b/bullet/problems/models.py
index 87e833b2..f0ac337e 100644
--- a/bullet/problems/models.py
+++ b/bullet/problems/models.py
@@ -4,17 +4,22 @@
class Problem(models.Model):
+ id: int
competition = models.ForeignKey(
"competitions.Competition", on_delete=models.CASCADE, related_name="+"
)
+ competition_id: int
number = models.PositiveIntegerField()
class SolvedProblem(models.Model):
+ id: int
team = models.ForeignKey(
"users.Team", on_delete=models.CASCADE, related_name="solved_problems"
)
+ team_id: int
problem = models.ForeignKey(Problem, on_delete=models.RESTRICT, related_name="+")
+ problem_id: int
competition_time = models.DurationField()
class Meta:
diff --git a/bullet/problems/views/results.py b/bullet/problems/views/results.py
index b476d424..5124edf0 100644
--- a/bullet/problems/views/results.py
+++ b/bullet/problems/views/results.py
@@ -1,5 +1,8 @@
-from bullet_admin.access import is_any_admin
+from bullet_admin.access import is_operator
+from bullet_admin.mixins import MixinProtocol
+from bullet_admin.utils import get_active_branch
from competitions.models import Category, Competition, Venue
+from django.core.exceptions import ImproperlyConfigured
from django.http import Http404, HttpRequest
from django.shortcuts import get_object_or_404
from django.utils import timezone
@@ -8,6 +11,7 @@
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.generic import ListView, TemplateView
from django_countries.fields import Country
+from users.models.organizers import User
from problems.logic.results import (
ResultsTime,
@@ -18,13 +22,18 @@
)
-class CompetitionMixin:
+class CompetitionMixin(MixinProtocol):
+ _competition: Competition
+
@property
def competition(self) -> Competition:
if not hasattr(self, "_competition"):
- self._competition = Competition.objects.get_current_competition(
- self.request.BRANCH
+ competition = Competition.objects.get_current_competition(
+ get_active_branch(self.request)
)
+ if not competition:
+ raise ImproperlyConfigured("No active competition.")
+ self._competition = competition
return self._competition
def get_context_data(self, **kwargs):
@@ -62,9 +71,8 @@ def get_venue_timer(self) -> Venue | None:
def get_results_time(self) -> ResultsTime:
admin = False
if self.request.GET.get("admin") == "1" and self.request.user.is_authenticated:
- admin = is_any_admin(
- self.request.user, self.competition, allow_operator=True
- )
+ assert isinstance(self.request.user, User)
+ admin = is_operator(self.request.user, self.competition)
start_time = None
venue = self.get_venue_timer()
diff --git a/bullet/users/factories/user.py b/bullet/users/factories/user.py
index 6f532e8e..ff5a09f6 100644
--- a/bullet/users/factories/user.py
+++ b/bullet/users/factories/user.py
@@ -33,8 +33,7 @@ def post(self, create, extracted, **kwargs):
venues = list(Venue.objects.all())
seed = ord(self.user.email[0].upper()) - ord("A")
random.seed(seed)
- self.can_delegate = random.random() > 0.5
- self.is_operator = not self.can_delegate
+ self.is_operator = random.random() > 0.5
if random.random() > 0.5:
self.venue_objects.set(
random.sample(venues, random.randint(0, len(venues)))
@@ -53,7 +52,6 @@ class Meta:
model = BranchRole
django_get_or_create = ["user", "branch"]
- is_translator = factory.Faker("boolean")
is_admin = factory.Faker("boolean")
branch = factory.Faker("random_element", elements=[b.id for b in Branches])
user = factory.Faker("random_element", elements=User.objects.all())
diff --git a/bullet/users/models/contestants.py b/bullet/users/models/contestants.py
index 3d9ccc1b..1975509e 100644
--- a/bullet/users/models/contestants.py
+++ b/bullet/users/models/contestants.py
@@ -1,7 +1,7 @@
import os
import secrets
import string
-from typing import Iterable
+from typing import TYPE_CHECKING, Iterable
from bullet.utils.string import shorten
from django.conf import settings
@@ -14,6 +14,10 @@
from bullet import search
+if TYPE_CHECKING:
+ from django.db.models.manager import RelatedManager
+ from problems.models import SolvedProblem
+
class TeamStatus(models.TextChoices):
UNCONFIRMED = "U", "Unconfirmed"
@@ -60,6 +64,7 @@ def has_status(self, status: str | Iterable[str]):
class Team(models.Model):
+ id: int
contact_name = models.CharField(max_length=256)
contact_email = models.EmailField()
contact_phone = PhoneNumberField(null=True, blank=True)
@@ -69,6 +74,7 @@ class Team(models.Model):
school = models.ForeignKey(
"education.School", on_delete=models.CASCADE, blank=True, null=True
)
+ school_id: int
name = models.CharField(max_length=128, blank=True, null=True)
language = models.TextField(choices=settings.LANGUAGES)
@@ -76,6 +82,7 @@ class Team(models.Model):
confirmed_at = models.DateTimeField(null=True, blank=True)
venue = models.ForeignKey("competitions.Venue", on_delete=models.CASCADE)
+ venue_id: int
number = models.IntegerField(null=True, blank=True)
in_school_symbol = models.CharField(max_length=3, null=True, blank=True)
@@ -89,8 +96,11 @@ class Team(models.Model):
rank_country = models.IntegerField(null=True, blank=True)
rank_international = models.IntegerField(null=True, blank=True)
- objects = TeamQuerySet.as_manager()
+ objects: TeamQuerySet = TeamQuerySet.as_manager() # type:ignore
history = HistoricalRecords()
+ _change_reason: str
+
+ solved_problems: "RelatedManager[SolvedProblem]"
class Meta:
unique_together = [
diff --git a/bullet/web/models.py b/bullet/web/models.py
index 232931f3..df05f129 100644
--- a/bullet/web/models.py
+++ b/bullet/web/models.py
@@ -1,3 +1,5 @@
+from typing import TYPE_CHECKING
+
from competitions.branches import Branches
from competitions.models import Competition
from django.conf import settings
@@ -13,8 +15,12 @@
)
from web.page_blocks.types import PAGE_BLOCK_TYPES, get_page_block_choices
+if TYPE_CHECKING:
+ from django.db.models.manager import RelatedManager
+
class Page(models.Model):
+ id: int
slug = models.SlugField(max_length=128)
branch = BranchField()
language = LanguageField()
@@ -22,6 +28,8 @@ class Page(models.Model):
title = models.CharField(max_length=128)
content = models.TextField(blank=True)
+ pageblock_set: "RelatedManager[PageBlock]"
+
def __str__(self):
return self.title
diff --git a/bullet/web/templatetags/page_blocks.py b/bullet/web/templatetags/page_blocks.py
index 8e92b3ec..0ae1385a 100644
--- a/bullet/web/templatetags/page_blocks.py
+++ b/bullet/web/templatetags/page_blocks.py
@@ -1,5 +1,8 @@
from typing import TYPE_CHECKING
+from bullet_admin.access import is_admin
+from bullet_admin.utils import get_active_branch
+from competitions.models.competitions import Competition
from django import template
from django.conf import settings
@@ -22,8 +25,13 @@ def page_block(context, block: "PageBlock"):
user = context.request.user
ctx["can_edit_block"] = False
if user.is_authenticated:
- is_translator = user.get_branch_role(request.BRANCH).is_translator
- if user.is_superuser or is_translator:
- ctx["can_edit_block"] = True
+ if context["competition"]:
+ competition = context["competition"]
+ else:
+ competition = Competition.objects.get_current_competition(
+ get_active_branch(request)
+ )
+
+ ctx["can_edit_block"] = is_admin(user, competition) # type:ignore
return block.block.render(context.request, ctx)
diff --git a/css/app.css b/css/app.css
index 7b85368a..8ec8c5a6 100644
--- a/css/app.css
+++ b/css/app.css
@@ -80,7 +80,7 @@
}
.checkbox {
- @apply rounded bg-gray-200 border-transparent focus:border-transparent focus:bg-gray-200 text-primary focus:ring-1 focus:ring-offset-2 focus:ring-gray-500;
+ @apply rounded bg-gray-200 border-transparent focus:border-transparent focus:ring-1 focus:ring-offset-2 focus:ring-gray-500 checked:bg-primary;
}
.radio {
diff --git a/pyproject.toml b/pyproject.toml
index 3235b615..5c5a230b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
[project]
-name = ""
+name = "bullet"
version = "0.0.1"
requires-python = ">=3.12"
dependencies = [
diff --git a/uv.lock b/uv.lock
index cca56686..6ff583ee 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,105 +2,6 @@ version = 1
revision = 3
requires-python = ">=3.12"
-[[package]]
-name = ""
-version = "0.0.1"
-source = { virtual = "." }
-dependencies = [
- { name = "beautifulsoup4" },
- { name = "django" },
- { name = "django-address" },
- { name = "django-cleanup" },
- { name = "django-countries" },
- { name = "django-debug-toolbar" },
- { name = "django-environ" },
- { name = "django-htmx" },
- { name = "django-ipware" },
- { name = "django-minify-html" },
- { name = "django-phonenumber-field" },
- { name = "django-pictures" },
- { name = "django-probes" },
- { name = "django-recaptcha" },
- { name = "django-rq" },
- { name = "django-silk" },
- { name = "django-simple-history" },
- { name = "django-timezone-field" },
- { name = "django-types" },
- { name = "django-web-components" },
- { name = "django-widget-tweaks" },
- { name = "fontawesomefree" },
- { name = "geoip2" },
- { name = "gunicorn" },
- { name = "jinja2" },
- { name = "markdown" },
- { name = "meilisearch" },
- { name = "phonenumbers" },
- { name = "pikepdf" },
- { name = "pillow" },
- { name = "psycopg", extra = ["binary"] },
- { name = "pyyaml" },
- { name = "reportlab" },
- { name = "sentry-sdk" },
- { name = "tzdata" },
-]
-
-[package.dev-dependencies]
-dev = [
- { name = "bumpver" },
- { name = "factory-boy" },
- { name = "mdgen" },
- { name = "pre-commit" },
- { name = "ruff" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "beautifulsoup4", specifier = ">=4.12.3" },
- { name = "django", specifier = ">=5.1.5" },
- { name = "django-address", specifier = ">=0.2.8" },
- { name = "django-cleanup", specifier = ">=9.0.0" },
- { name = "django-countries", specifier = ">=7.6.1" },
- { name = "django-debug-toolbar", specifier = ">=5.0.1" },
- { name = "django-environ", specifier = ">=0.12.0" },
- { name = "django-htmx", specifier = ">=1.21.0" },
- { name = "django-ipware", specifier = ">=7.0.1" },
- { name = "django-minify-html", specifier = ">=1.11.0" },
- { name = "django-phonenumber-field", specifier = ">=8.0.0" },
- { name = "django-pictures", specifier = ">=1.4.0" },
- { name = "django-probes", specifier = ">=1.7.0" },
- { name = "django-recaptcha", specifier = ">=4.0.0" },
- { name = "django-rq", specifier = ">=3.0.0" },
- { name = "django-silk", specifier = ">=5.3.2" },
- { name = "django-simple-history", specifier = ">=3.7.0" },
- { name = "django-timezone-field", specifier = ">=7.1" },
- { name = "django-types", specifier = ">=0.20.0" },
- { name = "django-web-components", specifier = ">=0.2.0" },
- { name = "django-widget-tweaks", specifier = ">=1.5.0" },
- { name = "fontawesomefree", specifier = ">=6.6.0" },
- { name = "geoip2", specifier = ">=4.8.1" },
- { name = "gunicorn", specifier = ">=23.0.0" },
- { name = "jinja2", specifier = ">=3.1.5" },
- { name = "markdown", specifier = ">=3.7" },
- { name = "meilisearch", specifier = ">=0.33.1" },
- { name = "phonenumbers", specifier = ">=8.13.53" },
- { name = "pikepdf", specifier = ">=9.5.1" },
- { name = "pillow", specifier = ">=11.1.0" },
- { name = "psycopg", extras = ["binary"], specifier = ">=3.2.4" },
- { name = "pyyaml", specifier = ">=6.0.2" },
- { name = "reportlab", specifier = ">=4.2.5" },
- { name = "sentry-sdk", specifier = ">=2.20.0" },
- { name = "tzdata", specifier = ">=2024.2" },
-]
-
-[package.metadata.requires-dev]
-dev = [
- { name = "bumpver", specifier = ">=2024.1130" },
- { name = "factory-boy", specifier = ">=3.3.1" },
- { name = "mdgen", specifier = ">=0.1.10" },
- { name = "pre-commit", specifier = ">=4.1.0" },
- { name = "ruff", specifier = ">=0.9.2" },
-]
-
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
@@ -248,6 +149,105 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
]
+[[package]]
+name = "bullet"
+version = "0.0.1"
+source = { virtual = "." }
+dependencies = [
+ { name = "beautifulsoup4" },
+ { name = "django" },
+ { name = "django-address" },
+ { name = "django-cleanup" },
+ { name = "django-countries" },
+ { name = "django-debug-toolbar" },
+ { name = "django-environ" },
+ { name = "django-htmx" },
+ { name = "django-ipware" },
+ { name = "django-minify-html" },
+ { name = "django-phonenumber-field" },
+ { name = "django-pictures" },
+ { name = "django-probes" },
+ { name = "django-recaptcha" },
+ { name = "django-rq" },
+ { name = "django-silk" },
+ { name = "django-simple-history" },
+ { name = "django-timezone-field" },
+ { name = "django-types" },
+ { name = "django-web-components" },
+ { name = "django-widget-tweaks" },
+ { name = "fontawesomefree" },
+ { name = "geoip2" },
+ { name = "gunicorn" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "meilisearch" },
+ { name = "phonenumbers" },
+ { name = "pikepdf" },
+ { name = "pillow" },
+ { name = "psycopg", extra = ["binary"] },
+ { name = "pyyaml" },
+ { name = "reportlab" },
+ { name = "sentry-sdk" },
+ { name = "tzdata" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "bumpver" },
+ { name = "factory-boy" },
+ { name = "mdgen" },
+ { name = "pre-commit" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "beautifulsoup4", specifier = ">=4.12.3" },
+ { name = "django", specifier = ">=5.1.5" },
+ { name = "django-address", specifier = ">=0.2.8" },
+ { name = "django-cleanup", specifier = ">=9.0.0" },
+ { name = "django-countries", specifier = ">=7.6.1" },
+ { name = "django-debug-toolbar", specifier = ">=5.0.1" },
+ { name = "django-environ", specifier = ">=0.12.0" },
+ { name = "django-htmx", specifier = ">=1.21.0" },
+ { name = "django-ipware", specifier = ">=7.0.1" },
+ { name = "django-minify-html", specifier = ">=1.11.0" },
+ { name = "django-phonenumber-field", specifier = ">=8.0.0" },
+ { name = "django-pictures", specifier = ">=1.4.0" },
+ { name = "django-probes", specifier = ">=1.7.0" },
+ { name = "django-recaptcha", specifier = ">=4.0.0" },
+ { name = "django-rq", specifier = ">=3.0.0" },
+ { name = "django-silk", specifier = ">=5.3.2" },
+ { name = "django-simple-history", specifier = ">=3.7.0" },
+ { name = "django-timezone-field", specifier = ">=7.1" },
+ { name = "django-types", specifier = ">=0.20.0" },
+ { name = "django-web-components", specifier = ">=0.2.0" },
+ { name = "django-widget-tweaks", specifier = ">=1.5.0" },
+ { name = "fontawesomefree", specifier = ">=6.6.0" },
+ { name = "geoip2", specifier = ">=4.8.1" },
+ { name = "gunicorn", specifier = ">=23.0.0" },
+ { name = "jinja2", specifier = ">=3.1.5" },
+ { name = "markdown", specifier = ">=3.7" },
+ { name = "meilisearch", specifier = ">=0.33.1" },
+ { name = "phonenumbers", specifier = ">=8.13.53" },
+ { name = "pikepdf", specifier = ">=9.5.1" },
+ { name = "pillow", specifier = ">=11.1.0" },
+ { name = "psycopg", extras = ["binary"], specifier = ">=3.2.4" },
+ { name = "pyyaml", specifier = ">=6.0.2" },
+ { name = "reportlab", specifier = ">=4.2.5" },
+ { name = "sentry-sdk", specifier = ">=2.20.0" },
+ { name = "tzdata", specifier = ">=2024.2" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "bumpver", specifier = ">=2024.1130" },
+ { name = "factory-boy", specifier = ">=3.3.1" },
+ { name = "mdgen", specifier = ">=0.1.10" },
+ { name = "pre-commit", specifier = ">=4.1.0" },
+ { name = "ruff", specifier = ">=0.9.2" },
+]
+
[[package]]
name = "bumpver"
version = "2025.1131"