diff --git a/mittab/apps/tab/auth_backends.py b/mittab/apps/tab/auth_backends.py
new file mode 100644
index 000000000..2ef3c686b
--- /dev/null
+++ b/mittab/apps/tab/auth_backends.py
@@ -0,0 +1,18 @@
+from django.contrib.auth.backends import ModelBackend
+
+from mittab.apps.tab.auth_roles import is_apda_board_access_open, is_apda_board_user
+
+
+class TabAuthenticationBackend(ModelBackend):
+ """Enforce APDA board access timing while preserving default auth behavior."""
+
+ def authenticate(self, request, username=None, password=None, **kwargs):
+ user = super().authenticate(
+ request,
+ username=username,
+ password=password,
+ **kwargs,
+ )
+ if user and is_apda_board_user(user) and not is_apda_board_access_open():
+ return None
+ return user
diff --git a/mittab/apps/tab/auth_roles.py b/mittab/apps/tab/auth_roles.py
new file mode 100644
index 000000000..43e318944
--- /dev/null
+++ b/mittab/apps/tab/auth_roles.py
@@ -0,0 +1,23 @@
+from mittab.apps.tab.models import Round, TabSettings
+
+APDA_BOARD_GROUP_NAME = "APDA Board"
+
+
+def is_apda_board_user(user):
+ if not user or not user.is_authenticated:
+ return False
+ if user.is_superuser:
+ return False
+ return user.groups.filter(name=APDA_BOARD_GROUP_NAME).exists()
+
+
+def is_apda_board_access_open():
+ """APDA board access opens once the final inround has been paired."""
+ try:
+ total_inrounds = int(TabSettings.get("tot_rounds"))
+ except (TypeError, ValueError):
+ return False
+
+ if total_inrounds < 1:
+ return False
+ return Round.objects.filter(round_number=total_inrounds).exists()
diff --git a/mittab/apps/tab/forms.py b/mittab/apps/tab/forms.py
index f444af369..c2fbab99e 100644
--- a/mittab/apps/tab/forms.py
+++ b/mittab/apps/tab/forms.py
@@ -33,6 +33,12 @@ class Meta:
fields = "__all__"
+class SchoolApdaIdForm(forms.ModelForm):
+ class Meta:
+ model = School
+ fields = ["apda_id"]
+
+
class RoomForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
entry = "first_entry" in kwargs
@@ -240,6 +246,12 @@ class Meta:
exclude = ["tiebreaker"]
+class DebaterApdaIdForm(forms.ModelForm):
+ class Meta:
+ model = Debater
+ fields = ["apda_id"]
+
+
def validate_speaks(value):
if not (TabSettings.get("min_speak", 0) <= value <= TabSettings.get(
"max_speak", 50)):
diff --git a/mittab/apps/tab/management/commands/initialize_tourney.py b/mittab/apps/tab/management/commands/initialize_tourney.py
index 63c140c5b..f997acbe2 100644
--- a/mittab/apps/tab/management/commands/initialize_tourney.py
+++ b/mittab/apps/tab/management/commands/initialize_tourney.py
@@ -3,8 +3,10 @@
from django.core.management import call_command
from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
from django.core.management.base import BaseCommand
+from mittab.apps.tab.auth_roles import APDA_BOARD_GROUP_NAME
from mittab.apps.tab.models import TabSettings
from mittab.libs.backup import backup_round, BEFORE_NEW_TOURNAMENT, INITIAL
@@ -26,6 +28,12 @@ def add_arguments(self, parser):
help="Password for the entry user",
nargs="?",
default=USER_MODEL.objects.make_random_password(length=8))
+ parser.add_argument(
+ "--apda-board-password",
+ dest="apda_board_password",
+ help="Password for the APDA Board user",
+ nargs="?",
+ default=None)
parser.add_argument(
"--first-init",
dest="first_init",
@@ -35,6 +43,12 @@ def add_arguments(self, parser):
default=False)
def handle(self, *args, **options):
+ apda_board_password = (
+ options.get("apda_board_password")
+ or os.environ.get("BOARD_PASSWORD")
+ or USER_MODEL.objects.make_random_password(length=16)
+ )
+
if not options["first_init"]:
self.stdout.write("Backing up the previous tournament data")
backup_round(btype=BEFORE_NEW_TOURNAMENT)
@@ -48,13 +62,16 @@ def handle(self, *args, **options):
print(why)
sys.exit(1)
- self.stdout.write("Creating tab/entry users")
+ self.stdout.write("Creating tab/entry/APDA Board users")
tab = USER_MODEL.objects.create_user("tab", None, options["tab_password"])
tab.is_staff = True
tab.is_admin = True
tab.is_superuser = True
tab.save()
USER_MODEL.objects.create_user("entry", None, options["entry_password"])
+ apda_board = USER_MODEL.objects.create_user("board", None, apda_board_password)
+ apda_board_group, _ = Group.objects.get_or_create(name=APDA_BOARD_GROUP_NAME)
+ apda_board.groups.add(apda_board_group)
self.stdout.write("Setting default tab settings")
TabSettings.set("tot_rounds", 5)
@@ -73,5 +90,9 @@ def handle(self, *args, **options):
self.stdout.write(
f"{'entry'.ljust(10, ' ')} | {options['entry_password'].ljust(10, ' ')}"
)
+ self.stdout.write(
+ f"{'board'.ljust(10, ' ')} | "
+ f"{apda_board_password.ljust(10, ' ')}"
+ )
if options["first_init"]:
backup_round(name="initial-tournament", btype=INITIAL)
diff --git a/mittab/apps/tab/middleware.py b/mittab/apps/tab/middleware.py
index 102cec105..4c3384111 100644
--- a/mittab/apps/tab/middleware.py
+++ b/mittab/apps/tab/middleware.py
@@ -1,8 +1,10 @@
import re
+from django.contrib.auth import logout
from django.contrib.auth.views import LoginView
from django.http import HttpResponse, JsonResponse
+from mittab.apps.tab.auth_roles import is_apda_board_access_open, is_apda_board_user
from mittab.apps.tab.helpers import redirect_and_flash_info
from mittab.apps.tab.public_rankings import get_standings_publication_setting
from mittab.libs.backup import is_backup_active
@@ -38,6 +40,19 @@
"team": "Team results not published",
"shared": "Standings data not published",
}
+APDA_BOARD_ALLOWED_PREFIXES = (
+ "/apda-board/",
+ "/public/",
+ "/accounts/logout/",
+ "/admin/logout/",
+ "/403/",
+ "/404/",
+ "/500/",
+ "/favicon.ico",
+ "/static/",
+ "/dynamic-media/",
+)
+APDA_BOARD_ALLOWED_EXACT_PATHS = ("/",)
class Login:
@@ -48,6 +63,22 @@ def __init__(self, get_response):
def __call__(self, request):
path = request.path
+ if request.user.is_authenticated and is_apda_board_user(request.user):
+ if not is_apda_board_access_open():
+ logout(request)
+ return redirect_and_flash_info(
+ request,
+ "APDA Board login activates after the final inround is paired.",
+ path="/public/",
+ )
+ if path not in APDA_BOARD_ALLOWED_EXACT_PATHS and \
+ not path.startswith(APDA_BOARD_ALLOWED_PREFIXES):
+ return redirect_and_flash_info(
+ request,
+ "APDA Board access is limited to APDA tools and public pages.",
+ path="/403/",
+ )
+
whitelisted = (
path in LOGIN_WHITELIST
or path.startswith("/public/")
diff --git a/mittab/apps/tab/templatetags/tags.py b/mittab/apps/tab/templatetags/tags.py
index 4d72ab428..7f156db93 100644
--- a/mittab/apps/tab/templatetags/tags.py
+++ b/mittab/apps/tab/templatetags/tags.py
@@ -3,6 +3,7 @@
from django import template
from django.forms.fields import FileField
+from mittab.apps.tab.auth_roles import is_apda_board_user
from mittab.apps.tab.helpers import get_redirect_target
from mittab.apps.tab.models import TabSettings
from mittab.apps.tab.public_rankings import get_public_display_flags
@@ -94,3 +95,8 @@ def public_display_flags():
def motions_enabled():
"""Returns True if motions feature is enabled."""
return bool(TabSettings.get("motions_enabled", 0))
+
+
+@register.simple_tag
+def is_apda_board(user):
+ return is_apda_board_user(user)
diff --git a/mittab/apps/tab/views/views.py b/mittab/apps/tab/views/views.py
index 1fb8a56e6..c8aa0ce8b 100644
--- a/mittab/apps/tab/views/views.py
+++ b/mittab/apps/tab/views/views.py
@@ -11,12 +11,15 @@
import yaml
from mittab.apps.tab.archive import ArchiveExporter
+from mittab.apps.tab.auth_roles import is_apda_board_user
from mittab.apps.tab.views.debater_views import get_speaker_rankings
from mittab.apps.tab.forms import (
MiniRankingGroupForm,
MiniRoomTagForm,
+ DebaterApdaIdForm,
RankingGroupForm,
RoomTagForm,
+ SchoolApdaIdForm,
SchoolForm,
RoomForm,
UploadDataForm,
@@ -50,6 +53,8 @@
def index(request):
if not request.user.is_authenticated:
return redirect("public_home")
+ if is_apda_board_user(request.user):
+ return redirect("apda_board_home")
schools_missing_apda_qs = School.objects.filter(
Q(apda_id__isnull=True) | Q(apda_id=-1)
@@ -101,6 +106,118 @@ def index(request):
return render(request, "common/index.html", context)
+def apda_board_home(request):
+ if not is_apda_board_user(request.user):
+ return redirect("index")
+
+ allowed_standing_slugs = ("speaker_results", "team_results")
+ if request.method == "POST":
+ for slug in allowed_standing_slugs:
+ published = bool(request.POST.get(f"standings_{slug}"))
+ set_standings_publication_setting(slug, published)
+ invalidate_public_rankings_cache()
+ return redirect_and_flash_success(
+ request,
+ "APDA standings publication settings saved.",
+ path=reverse("apda_board_home"),
+ )
+
+ standings_settings = [
+ setting for setting in get_all_standings_publication_settings()
+ if setting["slug"] in allowed_standing_slugs
+ ]
+ schools_missing_apda_qs = School.objects.filter(
+ Q(apda_id__isnull=True) | Q(apda_id=-1)
+ )
+ debaters_missing_apda_qs = Debater.objects.filter(
+ Q(apda_id__isnull=True) | Q(apda_id=-1)
+ )
+
+ context = {
+ "school_list": [(school.pk, school.name) for school in School.objects.all()],
+ "debater_list": [
+ (debater.pk, debater.display) for debater in Debater.objects.all()
+ ],
+ "standings_settings": standings_settings,
+ "schools_missing_apda_ids": [school.pk for school in schools_missing_apda_qs],
+ "debaters_missing_apda_ids": [
+ debater.pk for debater in debaters_missing_apda_qs
+ ],
+ "schools_missing_apda_count": schools_missing_apda_qs.count(),
+ "debaters_missing_apda_count": debaters_missing_apda_qs.count(),
+ }
+ return render(request, "apda_board/home.html", context)
+
+
+def apda_board_school_detail(request, school_id):
+ if not is_apda_board_user(request.user):
+ return redirect("index")
+
+ school = School.objects.filter(pk=int(school_id)).first()
+ if not school:
+ return redirect_and_flash_error(request, "School not found")
+
+ if request.method == "POST":
+ form = SchoolApdaIdForm(request.POST, instance=school)
+ if form.is_valid():
+ form.save()
+ return redirect_and_flash_success(
+ request,
+ f"APDA ID updated for {school.name}.",
+ path=reverse("apda_board_school_detail", args=[school.id]),
+ )
+ else:
+ form = SchoolApdaIdForm(instance=school)
+
+ teams = Team.objects.filter(school=school).prefetch_related("debaters")
+ hybrid_teams = Team.objects.filter(hybrid_school=school).prefetch_related("debaters")
+
+ return render(
+ request,
+ "apda_board/school_detail.html",
+ {
+ "form": form,
+ "school_obj": school,
+ "school_teams": teams,
+ "school_hybrid_teams": hybrid_teams,
+ "title": f"APDA Board: {school.name}",
+ },
+ )
+
+
+def apda_board_debater_detail(request, debater_id):
+ if not is_apda_board_user(request.user):
+ return redirect("index")
+
+ debater = Debater.objects.filter(pk=int(debater_id)).first()
+ if not debater:
+ return redirect_and_flash_error(request, "Debater not found")
+
+ if request.method == "POST":
+ form = DebaterApdaIdForm(request.POST, instance=debater)
+ if form.is_valid():
+ form.save()
+ return redirect_and_flash_success(
+ request,
+ f"APDA ID updated for {debater.name}.",
+ path=reverse("apda_board_debater_detail", args=[debater.id]),
+ )
+ else:
+ form = DebaterApdaIdForm(instance=debater)
+
+ teams = Team.objects.filter(debaters=debater).select_related("school", "hybrid_school")
+ return render(
+ request,
+ "apda_board/debater_detail.html",
+ {
+ "form": form,
+ "debater_obj": debater,
+ "teams": teams,
+ "title": f"APDA Board: {debater.name}",
+ },
+ )
+
+
def tab_logout(request, *args):
logout(request)
return redirect_and_flash_success(request,
diff --git a/mittab/libs/tests/views/test_apda_board_views.py b/mittab/libs/tests/views/test_apda_board_views.py
new file mode 100644
index 000000000..59f02b2a1
--- /dev/null
+++ b/mittab/libs/tests/views/test_apda_board_views.py
@@ -0,0 +1,131 @@
+import pytest
+
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
+from django.test import Client, TestCase
+from django.urls import reverse
+
+from mittab.apps.tab.auth_roles import APDA_BOARD_GROUP_NAME
+from mittab.apps.tab.models import Debater, Round, School, TabSettings
+from mittab.apps.tab.public_rankings import (
+ get_standings_publication_setting,
+ set_standings_publication_setting,
+)
+
+
+@pytest.mark.django_db(transaction=True)
+class TestApdaBoardViews(TestCase):
+ fixtures = ["testing_finished_db"]
+
+ def setUp(self):
+ super().setUp()
+ self.client = Client()
+ self.user = get_user_model().objects.create_user(
+ username="apda_board_tester",
+ email="apda@example.com",
+ password="password",
+ )
+ group, _ = Group.objects.get_or_create(name=APDA_BOARD_GROUP_NAME)
+ self.user.groups.add(group)
+ self.client.login(username="apda_board_tester", password="password")
+
+ def test_index_redirects_to_apda_board_home(self):
+ response = self.client.get(reverse("index"))
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("apda_board_home"))
+
+ def test_apda_board_allowed_pages_render(self):
+ school = School.objects.first()
+ debater = Debater.objects.first()
+
+ urls = [
+ reverse("apda_board_home"),
+ reverse("apda_board_school_detail", args=[school.id]),
+ reverse("apda_board_debater_detail", args=[debater.id]),
+ reverse("public_home"),
+ ]
+ for url in urls:
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ def test_apda_board_blocked_pages_redirect_to_403(self):
+ school = School.objects.first()
+ blocked_urls = [
+ reverse("view_teams"),
+ reverse("public_rankings_control"),
+ reverse("view_school", args=[school.id]),
+ ]
+
+ for url in blocked_urls:
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/403/")
+
+ def test_apda_board_home_updates_only_standings_published_settings(self):
+ set_standings_publication_setting("speaker_results", False)
+ set_standings_publication_setting("team_results", True)
+
+ response = self.client.post(
+ reverse("apda_board_home"),
+ {
+ "standings_speaker_results": "on",
+ },
+ )
+
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("apda_board_home"))
+ self.assertTrue(get_standings_publication_setting(
+ "speaker_results")["published"])
+ self.assertFalse(get_standings_publication_setting("team_results")["published"])
+
+ def test_apda_board_school_detail_updates_only_apda_id(self):
+ school = School.objects.first()
+ original_name = school.name
+
+ response = self.client.post(
+ reverse("apda_board_school_detail", args=[school.id]),
+ {
+ "apda_id": 24680,
+ "name": "Do Not Change",
+ },
+ )
+
+ self.assertEqual(response.status_code, 302)
+ school.refresh_from_db()
+ self.assertEqual(school.apda_id, 24680)
+ self.assertEqual(school.name, original_name)
+
+ def test_apda_board_debater_detail_updates_only_apda_id(self):
+ debater = Debater.objects.first()
+ original_name = debater.name
+
+ response = self.client.post(
+ reverse("apda_board_debater_detail", args=[debater.id]),
+ {
+ "apda_id": 13579,
+ "name": "Do Not Change",
+ },
+ )
+
+ self.assertEqual(response.status_code, 302)
+ debater.refresh_from_db()
+ self.assertEqual(debater.apda_id, 13579)
+ self.assertEqual(debater.name, original_name)
+
+ def test_apda_board_login_allowed_when_final_round_is_paired(self):
+ self.client.logout()
+ logged_in = self.client.login(
+ username="apda_board_tester",
+ password="password",
+ )
+ self.assertTrue(logged_in)
+
+ def test_apda_board_login_denied_before_final_round_is_paired(self):
+ total_inrounds = int(TabSettings.get("tot_rounds", 0) or 0)
+ Round.objects.filter(round_number=total_inrounds).delete()
+ self.client.logout()
+ logged_in = self.client.login(
+ username="apda_board_tester",
+ password="password",
+ )
+ self.assertFalse(logged_in)
diff --git a/mittab/libs/tests/views/test_initialize_tourney_command.py b/mittab/libs/tests/views/test_initialize_tourney_command.py
new file mode 100644
index 000000000..9332567ff
--- /dev/null
+++ b/mittab/libs/tests/views/test_initialize_tourney_command.py
@@ -0,0 +1,53 @@
+import os
+from unittest.mock import patch
+
+import pytest
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
+from django.core.management import call_command
+from django.test import TestCase
+
+from mittab.apps.tab.auth_roles import APDA_BOARD_GROUP_NAME
+
+
+@pytest.mark.django_db(transaction=True)
+class TestInitializeTourneyCommand(TestCase):
+ def test_board_user_uses_env_password_and_starts_active(self):
+ with patch.dict(os.environ, {"BOARD_PASSWORD": "board-from-env"}), patch(
+ "mittab.apps.tab.management.commands.initialize_tourney.backup_round"
+ ):
+ call_command(
+ "initialize_tourney",
+ tab_password="tab-password",
+ entry_password="entry-password",
+ first_init=True,
+ )
+
+ user_model = get_user_model()
+ board_user = user_model.objects.get(username="board")
+ self.assertTrue(board_user.check_password("board-from-env"))
+ self.assertTrue(board_user.is_active)
+ self.assertTrue(
+ board_user.groups.filter(name=APDA_BOARD_GROUP_NAME).exists()
+ )
+ self.assertTrue(Group.objects.filter(name=APDA_BOARD_GROUP_NAME).exists())
+ self.assertFalse(user_model.objects.filter(username="apda_board").exists())
+
+ def test_board_user_password_falls_back_to_random_when_env_missing(self):
+ with patch.dict(
+ os.environ,
+ {"BOARD_PASSWORD": "", "APDA_BOARD_PASSWORD": ""},
+ ), patch(
+ "mittab.apps.tab.management.commands.initialize_tourney."
+ "USER_MODEL.objects.make_random_password",
+ return_value="random-board-password",
+ ), patch("mittab.apps.tab.management.commands.initialize_tourney.backup_round"):
+ call_command(
+ "initialize_tourney",
+ tab_password="tab-password",
+ entry_password="entry-password",
+ first_init=True,
+ )
+
+ board_user = get_user_model().objects.get(username="board")
+ self.assertTrue(board_user.check_password("random-board-password"))
diff --git a/mittab/settings.py b/mittab/settings.py
index 2f0685293..5f95478d4 100644
--- a/mittab/settings.py
+++ b/mittab/settings.py
@@ -155,6 +155,9 @@
# Login/Logout redirects
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/public/login/"
+AUTHENTICATION_BACKENDS = (
+ "mittab.apps.tab.auth_backends.TabAuthenticationBackend",
+)
SETTING_YAML_PATH = os.path.join(BASE_DIR, "settings.yaml")
diff --git a/mittab/templates/apda_board/debater_detail.html b/mittab/templates/apda_board/debater_detail.html
new file mode 100644
index 000000000..144e942e3
--- /dev/null
+++ b/mittab/templates/apda_board/debater_detail.html
@@ -0,0 +1,54 @@
+{% extends "base/__wide.html" %}
+{% load bootstrap4 %}
+
+{% block title %}{{ title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+ | Team |
+ School |
+ Hybrid School |
+
+
+
+ {% for team in teams %}
+
+ | {{ team.name }} |
+
+ {{ team.school.name }}
+ |
+ {{ team.hybrid_school.name|default:"-" }} |
+
+ {% empty %}
+
+ | This debater is not assigned to a team. |
+
+ {% endfor %}
+
+
+
+
+
+{% endblock %}
diff --git a/mittab/templates/apda_board/home.html b/mittab/templates/apda_board/home.html
new file mode 100644
index 000000000..21ca87e03
--- /dev/null
+++ b/mittab/templates/apda_board/home.html
@@ -0,0 +1,102 @@
+{% extends "base/__wide.html" %}
+{% load tags %}
+
+{% block title %}APDA Board Dashboard{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+ {% for school_id, school_name in school_list %}
+ -
+ {{ school_name }}
+ {% if school_id in schools_missing_apda_ids %}
+ No ID
+ {% endif %}
+
+ {% empty %}
+ - No schools found.
+ {% endfor %}
+
+ {% if schools_missing_apda_count %}
+
{{ schools_missing_apda_count }} school(s) have no APDA ID.
+ {% endif %}
+
+
+
+
+
+
+
+
+
+ {% for debater_id, debater_name in debater_list %}
+ -
+ {{ debater_name }}
+ {% if debater_id in debaters_missing_apda_ids %}
+ No ID
+ {% endif %}
+
+ {% empty %}
+ - No debaters found.
+ {% endfor %}
+
+ {% if debaters_missing_apda_count %}
+
{{ debaters_missing_apda_count }} debater(s) have no APDA ID.
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/mittab/templates/apda_board/school_detail.html b/mittab/templates/apda_board/school_detail.html
new file mode 100644
index 000000000..545efa7e6
--- /dev/null
+++ b/mittab/templates/apda_board/school_detail.html
@@ -0,0 +1,88 @@
+{% extends "base/__wide.html" %}
+{% load bootstrap4 %}
+
+{% block title %}{{ title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+ | Team |
+ Debaters |
+
+
+
+ {% for team in school_teams %}
+
+ | {{ team.name }} |
+
+ {% for debater in team.debaters.all %}
+ {{ debater.name }}{% if not forloop.last %}, {% endif %}
+ {% endfor %}
+ |
+
+ {% empty %}
+
+ | No teams for this school. |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Team |
+ Primary School |
+
+
+
+ {% for team in school_hybrid_teams %}
+
+ | {{ team.name }} |
+ {{ team.school.name }} |
+
+ {% empty %}
+
+ | No hybrid teams for this school. |
+
+ {% endfor %}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/mittab/templates/base/_navigation.html b/mittab/templates/base/_navigation.html
index 67d1f2b42..1f4f3993b 100644
--- a/mittab/templates/base/_navigation.html
+++ b/mittab/templates/base/_navigation.html
@@ -1,6 +1,7 @@
{% load tags %}
{% url "index" as home %}
+{% url "apda_board_home" as apda_board_home %}
{% url "logout" as logout %}
{% url "enter_school" as enter_school %}
@@ -54,10 +55,11 @@
{% url "public_motions" as public_motions %}
{% public_display_flags as public_display_flags %}
{% motions_enabled as motions_enabled_flag %}
+{% is_apda_board user as is_apda_board_user %}