From 10d96a85136dd0b664576d1c505d7c007bb85a4a Mon Sep 17 00:00:00 2001 From: JoeyRubas Date: Tue, 24 Feb 2026 11:09:46 -0500 Subject: [PATCH] add seperate login level for apda board to manage results --- mittab/apps/tab/auth_backends.py | 18 +++ mittab/apps/tab/auth_roles.py | 23 +++ mittab/apps/tab/forms.py | 12 ++ .../management/commands/initialize_tourney.py | 23 ++- mittab/apps/tab/middleware.py | 31 +++++ mittab/apps/tab/templatetags/tags.py | 6 + mittab/apps/tab/views/views.py | 117 ++++++++++++++++ .../libs/tests/views/test_apda_board_views.py | 131 ++++++++++++++++++ .../views/test_initialize_tourney_command.py | 53 +++++++ mittab/settings.py | 3 + .../templates/apda_board/debater_detail.html | 54 ++++++++ mittab/templates/apda_board/home.html | 102 ++++++++++++++ .../templates/apda_board/school_detail.html | 88 ++++++++++++ mittab/templates/base/_navigation.html | 17 ++- mittab/urls.py | 11 ++ 15 files changed, 687 insertions(+), 2 deletions(-) create mode 100644 mittab/apps/tab/auth_backends.py create mode 100644 mittab/apps/tab/auth_roles.py create mode 100644 mittab/libs/tests/views/test_apda_board_views.py create mode 100644 mittab/libs/tests/views/test_initialize_tourney_command.py create mode 100644 mittab/templates/apda_board/debater_detail.html create mode 100644 mittab/templates/apda_board/home.html create mode 100644 mittab/templates/apda_board/school_detail.html 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 %} +
+
+
+

{{ debater_obj.name }}

+ Back +
+
+
+ {% csrf_token %} + {% bootstrap_form form %} + +
+
+
+ +
+
+
Teams
+
+
+ + + + + + + + + + {% for team in teams %} + + + + + + {% empty %} + + + + {% endfor %} + +
TeamSchoolHybrid School
{{ team.name }} + {{ team.school.name }} + {{ team.hybrid_school.name|default:"-" }}
This debater is not assigned to a team.
+
+
+
+{% 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 %} +
+ + +
+ {% csrf_token %} +
+
Standings Published
+ +
+
+
+ + + + + + + + + {% for standing in standings_settings %} + + + + + {% endfor %} + +
TypePublished
{{ standing.label }} + {% include "rankings/_toggle_switch.html" with name="standings_"|add:standing.slug switch_id="standings_"|add:standing.slug checked=standing.published label=standing.published|yesno:"Enabled,Disabled" %} +
+
+
+
+ +
+
+

+ + {{ school_list|length }} Schools + +

+
+
+
    + {% for school_id, school_name in school_list %} +
  1. + {{ school_name }} + {% if school_id in schools_missing_apda_ids %} + No ID + {% endif %} +
  2. + {% empty %} +
  3. No schools found.
  4. + {% endfor %} +
+ {% if schools_missing_apda_count %} + {{ schools_missing_apda_count }} school(s) have no APDA ID. + {% endif %} +
+
+
+ +
+

+ + {{ debater_list|length }} Debaters + +

+
+
+
    + {% for debater_id, debater_name in debater_list %} +
  1. + {{ debater_name }} + {% if debater_id in debaters_missing_apda_ids %} + No ID + {% endif %} +
  2. + {% empty %} +
  3. No debaters found.
  4. + {% 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 %} +
+
+
+

{{ school_obj.name }}

+ Back +
+
+
+ {% csrf_token %} + {% bootstrap_form form %} + +
+
+
+ +
+
+
+
+
Teams
+
+
+ + + + + + + + + {% for team in school_teams %} + + + + + {% empty %} + + + + {% endfor %} + +
TeamDebaters
{{ team.name }} + {% for debater in team.debaters.all %} + {{ debater.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
No teams for this school.
+
+
+
+ +
+
+
+
Hybrid Teams
+
+
+ + + + + + + + + {% for team in school_hybrid_teams %} + + + + + {% empty %} + + + + {% endfor %} + +
TeamPrimary School
{{ team.name }}{{ team.school.name }}
No hybrid teams for this school.
+
+
+
+
+
+{% 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 %}