From ce3055d179edf8b9c1be1d545690b4f7d6cd0d7b Mon Sep 17 00:00:00 2001 From: JoeyRubas Date: Mon, 6 Oct 2025 15:11:53 -0400 Subject: [PATCH 1/5] MVP --- assets/js/registration/index.js | 3 + assets/js/registration/portal.js | 677 ++++++++++++++++++ mittab/apps/registration/__init__.py | 0 mittab/apps/registration/admin.py | 42 ++ mittab/apps/registration/apps.py | 7 + mittab/apps/registration/forms.py | 270 +++++++ .../registration/migrations/0001_initial.py | 61 ++ .../apps/registration/migrations/__init__.py | 0 mittab/apps/registration/models.py | 66 ++ mittab/apps/registration/tests/__init__.py | 0 .../tests/test_registration_flow.py | 123 ++++ mittab/apps/registration/urls.py | 12 + mittab/apps/registration/views.py | 470 ++++++++++++ mittab/apps/tab/middleware.py | 7 +- .../tab/migrations/0026_debater_qualified.py | 15 + mittab/apps/tab/models.py | 1 + mittab/settings.py | 3 +- .../templates/registration/_judge_form.html | 28 + .../registration/_school_selector.html | 35 + mittab/templates/registration/_team_form.html | 98 +++ mittab/templates/registration/portal.html | 194 +++++ mittab/urls.py | 1 + webpack.config.js | 3 +- 23 files changed, 2112 insertions(+), 4 deletions(-) create mode 100644 assets/js/registration/index.js create mode 100644 assets/js/registration/portal.js create mode 100644 mittab/apps/registration/__init__.py create mode 100644 mittab/apps/registration/admin.py create mode 100644 mittab/apps/registration/apps.py create mode 100644 mittab/apps/registration/forms.py create mode 100644 mittab/apps/registration/migrations/0001_initial.py create mode 100644 mittab/apps/registration/migrations/__init__.py create mode 100644 mittab/apps/registration/models.py create mode 100644 mittab/apps/registration/tests/__init__.py create mode 100644 mittab/apps/registration/tests/test_registration_flow.py create mode 100644 mittab/apps/registration/urls.py create mode 100644 mittab/apps/registration/views.py create mode 100644 mittab/apps/tab/migrations/0026_debater_qualified.py create mode 100644 mittab/templates/registration/_judge_form.html create mode 100644 mittab/templates/registration/_school_selector.html create mode 100644 mittab/templates/registration/_team_form.html create mode 100644 mittab/templates/registration/portal.html diff --git a/assets/js/registration/index.js b/assets/js/registration/index.js new file mode 100644 index 000000000..dac8907b7 --- /dev/null +++ b/assets/js/registration/index.js @@ -0,0 +1,3 @@ +import initRegistrationPortal from "./portal"; + +document.addEventListener("DOMContentLoaded", initRegistrationPortal); diff --git a/assets/js/registration/portal.js b/assets/js/registration/portal.js new file mode 100644 index 000000000..40295777e --- /dev/null +++ b/assets/js/registration/portal.js @@ -0,0 +1,677 @@ +const NEW = "__new__"; +// Use our proxy endpoints to avoid CORS issues +const DEB_URL = id => `/registration/api/debaters/${id}/`; +const SCHOOLS_ALL_URL = "/registration/api/schools/all/"; +let root; +let allSchoolsLoaded = false; + +const queryAll = (selector, scope = root) => + Array.from(scope.querySelectorAll(selector)); +const byId = value => document.getElementById(value); +const debaterName = person => + person.name || + person.full_name || + `${person.first_name || ""} ${person.last_name || ""}`.trim(); + +const toggleNew = select => { + queryAll(`[data-new-school-container="${select.id}"]`).forEach(container => { + const show = select.value === NEW; + container.classList.toggle("d-none", !show); + const inputs = queryAll("input", container); + for (let index = 0; index < inputs.length; index += 1) { + const field = inputs[index]; + if (show) { + field.classList.remove("d-none"); + } else { + field.classList.add("d-none"); + } + } + }); +}; + +const loadAllSchools = () => { + if (allSchoolsLoaded) { + return Promise.resolve(); + } + + // Update the "See more" link to show loading state + queryAll('[data-load-all-schools]').forEach(link => { + link.textContent = 'Loading...'; + }); + + return fetch(SCHOOLS_ALL_URL) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + const schools = Array.isArray(data) ? data : data.schools || []; + const schoolSelects = queryAll('[data-school-select]'); + + schoolSelects.forEach(select => { + const currentValue = select.value; + const existingValues = new Set(); + + // Collect existing school IDs + queryAll('option', select).forEach(option => { + if (option.value && option.value.startsWith('apda:')) { + existingValues.add(option.value); + } + }); + + // Add new schools + const newSchoolOption = select.querySelector(`option[value="${NEW}"]`); + schools.forEach(school => { + const value = `apda:${school.id || school.apda_id}`; + if (!existingValues.has(value)) { + const option = document.createElement('option'); + option.value = value; + option.textContent = school.name; + if (newSchoolOption) { + select.insertBefore(option, newSchoolOption); + } else { + select.appendChild(option); + } + } + }); + + // Restore selected value + select.value = currentValue; + }); + + allSchoolsLoaded = true; + + // Update the "See more" link + queryAll('[data-load-all-schools]').forEach(link => { + link.textContent = 'All schools loaded'; + link.style.pointerEvents = 'none'; + link.style.color = '#6c757d'; + }); + }) + .catch(error => { + console.error('Failed to load all schools:', error); + // Update the "See more" link to show error and allow retry + queryAll('[data-load-all-schools]').forEach(link => { + link.textContent = 'Failed to load. Click to retry.'; + link.style.color = '#dc3545'; + }); + allSchoolsLoaded = false; // Allow retry + }); +}; +const syncDebater = input => { + const container = input.closest('[data-debater]'); + if (!container) return; + + const prefix = input.dataset.debaterInput; + if (!prefix) return; + + // Find the matching option in the select itself + const selectedOption = input.selectedOptions && input.selectedOptions[0]; + + if (selectedOption && selectedOption.dataset.debater) { + // Parse the debater data stored in the option + try { + const debaterData = JSON.parse(selectedOption.dataset.debater); + + // Populate all the hidden fields + const apdaIdField = byId(input.dataset.apdaTarget); + const noviceField = container.querySelector(`input[name$="${prefix}_novice_status"]`); + const qualifiedField = container.querySelector(`input[name$="${prefix}_qualified"]`); + + if (apdaIdField) { + apdaIdField.value = debaterData.apda_id || debaterData.id || ''; + } + if (noviceField) { + // Convert status to novice value: "Novice" = 1, anything else = 0 + noviceField.value = debaterData.status === 'Novice' ? '1' : '0'; + } + if (qualifiedField) { + // Assume qualified if they have an APDA ID + qualifiedField.value = debaterData.apda_id ? 'on' : ''; + } + } catch (e) { + console.error('Error parsing debater data:', e); + } + } else { + // Clear the fields if no match + const apdaIdField = byId(input.dataset.apdaTarget); + const noviceField = container.querySelector(`input[name$="${prefix}_novice_status"]`); + const qualifiedField = container.querySelector(`input[name$="${prefix}_qualified"]`); + + if (apdaIdField) apdaIdField.value = ''; + if (noviceField) noviceField.value = '0'; + if (qualifiedField) qualifiedField.value = ''; + } +}; +const clearDebaterList = (list, selectElement) => { + const targetList = list; + const target = selectElement; + if (targetList) { + targetList.innerHTML = ""; + } + if (target) { + target.innerHTML = ''; + target.disabled = true; + } +}; + +const updateDebaters = select => { + const listId = select.dataset.listId; + const nameId = select.dataset.nameId; + + console.log('updateDebaters called', { listId, nameId, selectValue: select.value }); + + // Get or create the datalist element + let list = byId(listId); + if (!list) { + console.log('Creating datalist with id:', listId); + list = document.createElement('datalist'); + list.id = listId; + list.style.display = 'none'; + document.body.appendChild(list); + } + + const selectElement = byId(nameId); + + console.log('Found elements:', { list: !!list, selectElement: !!selectElement }); + + if (!selectElement) { + console.log('Missing select element'); + return; + } + + // Handle custom schools - they only have manually created debaters + if (select.value && select.value.startsWith('custom:')) { + const selectedOption = select.selectedOptions && select.selectedOptions[0]; + if (selectedOption) { + const schoolName = selectedOption.textContent; + // Find the associated school_name hidden field + const schoolNameField = select.closest('[data-debater]')?.querySelector(`input[name$="_school_name"]`); + if (schoolNameField) { + schoolNameField.value = schoolName; + } + } + + // Clear and add any custom debaters for this school + selectElement.innerHTML = ''; + const schoolValue = select.value; + if (window.customDebaters && window.customDebaters[schoolValue]) { + window.customDebaters[schoolValue].forEach(debater => { + const option = document.createElement("option"); + option.value = `custom:${debater.id}`; + option.textContent = debater.name; + option.dataset.debater = JSON.stringify(debater); + selectElement.appendChild(option); + }); + selectElement.disabled = false; + } else { + selectElement.disabled = true; + } + return; + } + + if (!select.value.startsWith("apda:")) { + clearDebaterList(list, selectElement); + return; + } + const apdaId = select.value.split(":")[1]; + if (!apdaId) { + clearDebaterList(list, selectElement); + return; + } + + // Disable select while loading + selectElement.disabled = true; + selectElement.innerHTML = ''; + + fetch(DEB_URL(apdaId)) + .then(response => (response.ok ? response.json() : { debaters: [] })) + .then(data => { + const entries = Array.isArray(data) ? data : data.debaters || []; + + console.log('Loaded debaters:', entries.length); + + // Clear the select and add options + selectElement.innerHTML = ""; + + // Add an empty option first + const emptyOption = document.createElement("option"); + emptyOption.value = ""; + emptyOption.textContent = "Select a debater"; + selectElement.appendChild(emptyOption); + + // Add debater options from API + entries.forEach(person => { + const option = document.createElement("option"); + const name = debaterName(person); + option.value = name; + option.textContent = name; + + // Store the full debater data in the option + option.dataset.debater = JSON.stringify({ + id: person.id || person.apda_id, + apda_id: person.apda_id || person.id, + name: name, + first_name: person.first_name, + last_name: person.last_name, + status: person.status + }); + + selectElement.appendChild(option); + }); + + // Add custom debaters for this school if any exist + const schoolValue = select.value; + if (window.customDebaters && window.customDebaters[schoolValue]) { + window.customDebaters[schoolValue].forEach(debater => { + const option = document.createElement("option"); + option.value = `custom:${debater.id}`; + option.textContent = debater.name; + option.dataset.debater = JSON.stringify(debater); + selectElement.appendChild(option); + }); + } + + // Also store in the datalist for reference (though we don't use it visually) + list.innerHTML = ""; + entries.forEach(person => { + const option = document.createElement("option"); + const name = debaterName(person); + option.value = name; + option.dataset.debater = JSON.stringify({ + id: person.id || person.apda_id, + apda_id: person.apda_id || person.id, + name: name, + first_name: person.first_name, + last_name: person.last_name, + status: person.status + }); + list.appendChild(option); + }); + + // Enable the select + selectElement.disabled = false; + + console.log('Debaters loaded successfully'); + + // Trigger sync to populate hidden fields if there's a pre-selected value + syncDebater(selectElement); + }) + .catch((error) => { + console.error('Error loading debaters:', error); + clearDebaterList(list, selectElement); + }); +}; +const addForm = (type, maxTeams) => { + const prefix = type === "team" ? "teams" : "judges"; + const totalInput = root.querySelector(`input[name="${prefix}-TOTAL_FORMS"]`); + const total = parseInt(totalInput.value, 10) || 0; + if (type === "team" && total >= maxTeams) { + return; + } + const template = byId(`${type}-empty-form`); + if (!template) { + return; + } + const wrapper = document.createElement("div"); + wrapper.innerHTML = template.innerHTML.replace(/__prefix__/g, String(total)); + const element = wrapper.firstElementChild; + + // Also replace __prefix__ in data attributes + queryAll('[data-name-id*="__prefix__"]', element).forEach(el => { + el.dataset.nameId = el.dataset.nameId.replace(/__prefix__/g, String(total)); + }); + queryAll('[data-list-id*="__prefix__"]', element).forEach(el => { + el.dataset.listId = el.dataset.listId.replace(/__prefix__/g, String(total)); + }); + + root.querySelector(`[data-formset-container="${type}"]`).appendChild(element); + totalInput.value = total + 1; + + // If it's a team form, populate ALL school selects (including debater schools) from main registration school + if (type === "team") { + const mainSchoolSelect = root.querySelector('[data-school-select="registration"]'); + + // First, copy all options from main school select to all new school selects in the team form + const allSchoolSelects = queryAll('[data-school-select]', element); + allSchoolSelects.forEach(select => { + // Copy options from main school select + if (mainSchoolSelect) { + // Clear existing options first + select.innerHTML = ''; + + // Clone all options from main school select + queryAll('option', mainSchoolSelect).forEach(option => { + const newOption = option.cloneNode(true); + select.appendChild(newOption); + }); + } + + // Now set the value if main school is selected + if (mainSchoolSelect && mainSchoolSelect.value && mainSchoolSelect.value !== NEW && mainSchoolSelect.value !== "") { + select.value = mainSchoolSelect.value; + toggleNew(select); + // Only update debaters if it's a team school select (has listId) + if (select.dataset.listId) { + updateDebaters(select); + } + } else { + toggleNew(select); + const listId = select.dataset.listId; + const nameId = select.dataset.nameId; + if (listId && nameId) { + const input = byId(nameId); + if (input) { + input.disabled = true; + input.placeholder = "Select a school first"; + } + } + } + }); + } + + queryAll("[data-debater-input]", element).forEach(field => { + // Debater fields should now be select elements + const debaterContainer = field.closest('[data-debater]'); + const schoolSelect = debaterContainer ? debaterContainer.querySelector('[data-school-select]') : null; + + if (!schoolSelect || !schoolSelect.value || !schoolSelect.value.startsWith("apda:")) { + field.innerHTML = ''; + field.disabled = true; + } else { + // If school is already selected, sync the debater data + syncDebater(field); + } + }); +}; +export default function initRegistrationPortal() { + root = document.getElementById("registration-app"); + if (!root) { + return; + } + const maxTeams = parseInt(root.dataset.maxTeams || "200", 10); + + // Initialize existing school selects + queryAll("[data-school-select]").forEach(select => { + toggleNew(select); + updateDebaters(select); + }); + + // Initialize existing debater selects - disable if no school selected + queryAll("[data-debater-input]").forEach(field => { + const schoolSelectId = field.closest('[data-debater]')?.querySelector('[data-school-select]')?.id; + if (schoolSelectId) { + const schoolSelect = byId(schoolSelectId); + if (!schoolSelect || !schoolSelect.value || !schoolSelect.value.startsWith("apda:")) { + field.innerHTML = ''; + field.disabled = true; + } + } + }); + + // Enable/disable Add Team button based on main school selection + const updateAddTeamButton = () => { + const mainSchoolSelect = root.querySelector('[data-school-select="registration"]'); + const addTeamButton = root.querySelector('[data-add-form="team"]'); + if (addTeamButton) { + if (mainSchoolSelect && mainSchoolSelect.value && mainSchoolSelect.value !== "") { + addTeamButton.disabled = false; + } else { + addTeamButton.disabled = true; + } + } + }; + + updateAddTeamButton(); + + root.addEventListener("change", event => { + const { target } = event; + + // Handle debater select change + if (target.matches("[data-debater-input]")) { + syncDebater(target); + return; + } + + if (target.matches("[data-school-select]")) { + toggleNew(target); + updateDebaters(target); + + // Update Add Team button and propagate school to all teams when main school changes + if (target.matches('[data-school-select="registration"]')) { + updateAddTeamButton(); + + // Propagate main school options and value to all team school selects + queryAll('[data-form="team"]').forEach(teamForm => { + queryAll('[data-school-select]', teamForm).forEach(schoolSelect => { + // First, sync the options from main school select + schoolSelect.innerHTML = ''; + queryAll('option', target).forEach(option => { + const newOption = option.cloneNode(true); + schoolSelect.appendChild(newOption); + }); + + // Then update value if the school select doesn't already have a different value + if (!schoolSelect.value || schoolSelect.value === "") { + if (target.value && target.value !== NEW && target.value !== "") { + schoolSelect.value = target.value; + toggleNew(schoolSelect); + if (schoolSelect.dataset.listId) { + updateDebaters(schoolSelect); + } + } + } + }); + }); + } + } + }); + root.addEventListener( + "input", + event => + event.target.matches("[data-debater-input]") && syncDebater(event.target) + ); + root.addEventListener("click", event => { + // Handle "See more schools" link + if (event.target.matches('[data-load-all-schools]')) { + event.preventDefault(); + loadAllSchools(); + return; + } + + // Handle "Create New School" button + if (event.target.matches('[data-trigger-new]')) { + event.preventDefault(); + const selectId = event.target.getAttribute('data-trigger-new'); + const select = document.getElementById(selectId); + if (select) { + select.value = NEW; + toggleNew(select); + } + return; + } + + const addType = event.target.getAttribute("data-add-form"); + if (addType) { + event.preventDefault(); + addForm(addType, maxTeams); + return; + } + const removeType = event.target.getAttribute("data-remove-form"); + if (!removeType) { + return; + } + event.preventDefault(); + const form = event.target.closest(`[data-form="${removeType}"]`); + if (!form) { + return; + } + const deleteField = form.querySelector('input[name$="-DELETE"]'); + if (deleteField) { + deleteField.value = "on"; + } + form.classList.add("d-none"); + }); + + // Store for custom schools and debaters (make global for access in updateDebaters) + window.customSchools = []; + window.customDebaters = {}; + + // Create new school handler + const createSchoolBtn = byId('create-school-btn'); + const newSchoolNameInput = byId('new-school-name'); + + if (createSchoolBtn && newSchoolNameInput) { + createSchoolBtn.addEventListener('click', () => { + const schoolName = newSchoolNameInput.value.trim(); + if (!schoolName) { + alert('Please enter a school name'); + return; + } + + // Create a custom school with id = -1 (or negative incrementing IDs) + const customId = -1 - window.customSchools.length; + const schoolValue = `custom:${customId}`; + const school = { + id: customId, + name: schoolName, + value: schoolValue + }; + + window.customSchools.push(school); + + // Add to all school dropdowns + queryAll('[data-school-select]').forEach(select => { + const option = document.createElement('option'); + option.value = schoolValue; + option.textContent = schoolName; + option.dataset.schoolName = schoolName; // Store name for form submission + select.appendChild(option); + }); + + // Clear the input + newSchoolNameInput.value = ''; + + // Show success message + alert(`School "${schoolName}" added successfully!`); + }); + } + + // Create new debater handler + const createDebaterBtn = byId('create-debater-btn'); + const newDebaterSchool = byId('new-debater-school'); + const newDebaterName = byId('new-debater-name'); + const newDebaterStatus = byId('new-debater-status'); + + if (createDebaterBtn && newDebaterSchool && newDebaterName && newDebaterStatus) { + createDebaterBtn.addEventListener('click', () => { + const schoolValue = newDebaterSchool.value; + const debaterName = newDebaterName.value.trim(); + const noviceStatus = newDebaterStatus.value; + + if (!schoolValue) { + alert('Please select a school'); + return; + } + if (!debaterName) { + alert('Please enter a debater name'); + return; + } + + // Extract school ID + let schoolId; + if (schoolValue.startsWith('apda:')) { + schoolId = schoolValue.split(':')[1]; + } else if (schoolValue.startsWith('custom:')) { + schoolId = schoolValue.split(':')[1]; + } else if (schoolValue.startsWith('pk:')) { + schoolId = schoolValue.split(':')[1]; + } else { + alert('Invalid school selection'); + return; + } + + // Create custom debater with id = -1 + const customId = -1; + const debater = { + id: customId, + apda_id: customId, + name: debaterName, + full_name: debaterName, + status: noviceStatus === '1' ? 'Novice' : 'Varsity', + qualified: false + }; + + // Store debater for this school + if (!window.customDebaters[schoolValue]) { + window.customDebaters[schoolValue] = []; + } + window.customDebaters[schoolValue].push(debater); + + // Add to any active debater dropdowns for this school + queryAll('[data-school-select]').forEach(schoolSelect => { + if (schoolSelect.value === schoolValue) { + // Find associated debater select + const nameId = schoolSelect.dataset.nameId; + if (nameId) { + const debaterSelect = byId(nameId); + if (debaterSelect) { + const option = document.createElement('option'); + option.value = `custom:${customId}`; + option.textContent = debaterName; + option.dataset.debater = JSON.stringify(debater); + debaterSelect.appendChild(option); + + // Enable the select if it was disabled + if (debaterSelect.disabled) { + debaterSelect.disabled = false; + debaterSelect.innerHTML = ''; + debaterSelect.appendChild(option); + } + } + } + } + }); + + // Clear the inputs + newDebaterName.value = ''; + newDebaterStatus.value = '0'; + + // Show success message + alert(`Debater "${debaterName}" added successfully!`); + }); + } + + // Populate new-debater-school with schools from main dropdown on load + const mainSchoolSelect = queryAll('[data-school-select="registration"]')[0]; + if (mainSchoolSelect && newDebaterSchool) { + const syncNewDebaterSchools = () => { + const currentValue = newDebaterSchool.value; + newDebaterSchool.innerHTML = ''; + + queryAll('option', mainSchoolSelect).forEach(opt => { + if (opt.value && opt.value !== '') { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.textContent; + newDebaterSchool.appendChild(option); + } + }); + + // Restore value if still available + if (currentValue) { + newDebaterSchool.value = currentValue; + } + }; + + // Sync on load + syncNewDebaterSchools(); + + // Re-sync when schools are added + const observer = new MutationObserver(syncNewDebaterSchools); + observer.observe(mainSchoolSelect, { childList: true }); + } +} + diff --git a/mittab/apps/registration/__init__.py b/mittab/apps/registration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mittab/apps/registration/admin.py b/mittab/apps/registration/admin.py new file mode 100644 index 000000000..3e0c931b1 --- /dev/null +++ b/mittab/apps/registration/admin.py @@ -0,0 +1,42 @@ +from django.contrib import admin + +from .models import ( + RegistrationConfig, + Registration, + RegistrationJudge, + RegistrationTeam, + RegistrationTeamMember, +) + + +class RegistrationTeamInline(admin.TabularInline): + model = RegistrationTeam + extra = 0 + + +class RegistrationJudgeInline(admin.TabularInline): + model = RegistrationJudge + extra = 0 + + +class RegistrationTeamMemberInline(admin.TabularInline): + model = RegistrationTeamMember + extra = 0 + + +@admin.register(Registration) +class RegistrationAdmin(admin.ModelAdmin): + list_display = ("school", "email", "herokunator_code", "created_at") + search_fields = ("school__name", "email", "herokunator_code") + inlines = (RegistrationTeamInline, RegistrationJudgeInline) + + +@admin.register(RegistrationConfig) +class RegistrationConfigAdmin(admin.ModelAdmin): + list_display = ("registration_open", "registration_close", "tournament_start") + + +@admin.register(RegistrationTeam) +class RegistrationTeamAdmin(admin.ModelAdmin): + list_display = ("registration", "team", "is_free_seed") + inlines = (RegistrationTeamMemberInline,) diff --git a/mittab/apps/registration/apps.py b/mittab/apps/registration/apps.py new file mode 100644 index 000000000..556acd747 --- /dev/null +++ b/mittab/apps/registration/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class RegistrationAppConfig(AppConfig): + default_auto_field = "django.db.models.AutoField" + name = "mittab.apps.registration" + label = "registration" diff --git a/mittab/apps/registration/forms.py b/mittab/apps/registration/forms.py new file mode 100644 index 000000000..eebb73c04 --- /dev/null +++ b/mittab/apps/registration/forms.py @@ -0,0 +1,270 @@ +from django import forms + +NEW_CHOICE_VALUE = "__new__" +NOVICE_CHOICES = ((0, "Varsity"), (1, "Novice")) + + +def parse_school(value, name): + if not value: + raise forms.ValidationError("Select a school") + if value.startswith("pk:"): + return {"pk": int(value.split(":", 1)[1])} + if value.startswith("apda:"): + label = (name or "").strip() + data = {"apda_id": int(value.split(":", 1)[1])} + if label: + data["name"] = label + return data + if value.startswith("custom:"): + # Custom school created via the form - extract the name from the label + label = (name or "").strip() + custom_id = int(value.split(":", 1)[1]) + data = {"apda_id": custom_id} # Use negative ID as apda_id + if label: + data["name"] = label + return data + if value == NEW_CHOICE_VALUE: + label = (name or "").strip() + if not label: + raise forms.ValidationError("Enter a school name") + return {"name": label} + raise forms.ValidationError("Invalid school choice") + + +class RegistrationForm(forms.Form): + school = forms.CharField() + school_name = forms.CharField(required=False, max_length=50) + email = forms.EmailField(max_length=254) + + def __init__(self, *args, school_choices=None, **kwargs): + super().__init__(*args, **kwargs) + choices = school_choices or [] + choice_values = [value for value, _ in choices] + current = self._current_value("school") + if current and current not in choice_values: + label = self._current_value("school_name") or "Selected School" + choices = [(current, label)] + choices + final = [("", "Select school")] + choices + self.fields["school"].widget = forms.Select(choices=final) + self.choice_map = dict(choices) + self.fields["school"].widget.attrs.update({ + "class": "form-control", + "data-school-select": "registration", + }) + self.fields["school_name"].widget.attrs.update({ + "class": "form-control d-none", + "placeholder": "School name", + }) + self.fields["school_name"].widget.attrs.setdefault( + "data-related-select", self.fields["school"].widget.attrs.get("id") + ) + if current == NEW_CHOICE_VALUE: + self._reveal_input(self.fields["school_name"].widget) + self.fields["email"].widget.attrs.update({"class": "form-control"}) + + def _current_value(self, field_name): + if self.is_bound: + return self.data.get(self.add_prefix(field_name), "") + return self.initial.get(field_name, "") + + def _reveal_input(self, widget): + classes = widget.attrs.get("class", "") + widget.attrs["class"] = " ".join( + part for part in classes.split() if part != "d-none" + ).strip() + + def get_school(self): + value = self.cleaned_data["school"] + label = self.cleaned_data.get("school_name") or self.choice_map.get(value) + return parse_school(value, label) + + +class TeamForm(forms.Form): + registration_team_id = forms.IntegerField(required=False, widget=forms.HiddenInput) + team_id = forms.IntegerField(required=False, widget=forms.HiddenInput) + name = forms.CharField(max_length=30) + is_free_seed = forms.BooleanField(required=False) + debater_one_id = forms.IntegerField(required=False, widget=forms.HiddenInput) + debater_one_name = forms.CharField(max_length=30) + debater_one_apda_id = forms.IntegerField(required=False, widget=forms.HiddenInput) + debater_one_novice_status = forms.IntegerField(required=False, widget=forms.HiddenInput) + debater_one_qualified = forms.BooleanField(required=False, widget=forms.HiddenInput) + debater_one_school = forms.CharField() + debater_one_school_name = forms.CharField(required=False, max_length=50) + debater_two_id = forms.IntegerField(required=False, widget=forms.HiddenInput) + debater_two_name = forms.CharField(max_length=30) + debater_two_apda_id = forms.IntegerField(required=False, widget=forms.HiddenInput) + debater_two_novice_status = forms.IntegerField(required=False, widget=forms.HiddenInput) + debater_two_qualified = forms.BooleanField(required=False, widget=forms.HiddenInput) + debater_two_school = forms.CharField() + debater_two_school_name = forms.CharField(required=False, max_length=50) + DELETE = forms.BooleanField(required=False, widget=forms.HiddenInput) + + def __init__(self, *args, school_choices=None, **kwargs): + super().__init__(*args, **kwargs) + school_choices = school_choices or [] + self.choice_map = dict(school_choices) + base_choices = [("", "Select school")] + school_choices + control_fields = [ + "name", + ] + for field in control_fields: + self.fields[field].widget.attrs.setdefault("class", "form-control") + + # Configure debater name fields as Select widgets + self.fields["debater_one_name"].widget = forms.Select(choices=[("", "Select a school first")]) + self.fields["debater_one_name"].widget.attrs.update({ + "class": "form-control", + }) + self.fields["debater_two_name"].widget = forms.Select(choices=[("", "Select a school first")]) + self.fields["debater_two_name"].widget.attrs.update({ + "class": "form-control", + }) + + self.fields["is_free_seed"].widget.attrs.setdefault("class", "form-check-input") + self.fields["debater_one_qualified"].widget.attrs.setdefault("value", "") + self.fields["debater_two_qualified"].widget.attrs.setdefault("value", "") + self.fields["debater_one_novice_status"].initial = 0 + self.fields["debater_two_novice_status"].initial = 0 + + first_value = self._current_value("debater_one_school") + second_value = self._current_value("debater_two_school") + + self._configure_school_field( + "debater_one_school", + first_value, + base_choices, + self.fields["debater_one_school_name"].widget, + ) + self._configure_school_field( + "debater_two_school", + second_value, + base_choices, + self.fields["debater_two_school_name"].widget, + ) + + self._configure_debater_inputs("debater_one") + self._configure_debater_inputs("debater_two") + + def clean(self): + data = super().clean() + if data.get("DELETE"): + return data + if not data.get("debater_one_name") or not data.get("debater_two_name"): + raise forms.ValidationError("Each team needs two debaters") + return data + + def get_members(self): + return [ + { + "id": self.cleaned_data.get("debater_one_id"), + "name": self.cleaned_data["debater_one_name"], + "apda_id": self.cleaned_data.get("debater_one_apda_id"), + "novice_status": int(self.cleaned_data["debater_one_novice_status"]), + "qualified": bool(self.cleaned_data.get("debater_one_qualified")), + "school": parse_school( + self.cleaned_data["debater_one_school"], + self.cleaned_data.get("debater_one_school_name") + or self.choice_map.get(self.cleaned_data["debater_one_school"]), + ), + }, + { + "id": self.cleaned_data.get("debater_two_id"), + "name": self.cleaned_data["debater_two_name"], + "apda_id": self.cleaned_data.get("debater_two_apda_id"), + "novice_status": int(self.cleaned_data["debater_two_novice_status"]), + "qualified": bool(self.cleaned_data.get("debater_two_qualified")), + "school": parse_school( + self.cleaned_data["debater_two_school"], + self.cleaned_data.get("debater_two_school_name") + or self.choice_map.get(self.cleaned_data["debater_two_school"]), + ), + }, + ] + + def get_payload(self): + return { + "registration_team_id": self.cleaned_data.get("registration_team_id"), + "team_id": self.cleaned_data.get("team_id"), + "name": self.cleaned_data["name"], + "is_free_seed": bool(self.cleaned_data.get("is_free_seed")), + "members": self.get_members(), + } + + def _current_value(self, field_name): + if self.is_bound: + return self.data.get(self.add_prefix(field_name), "") + return self.initial.get(field_name, "") + + def _configure_school_field(self, field_name, current, base_choices, name_widget): + field = self.fields[field_name] + choices_list = list(base_choices) + if current and current not in [value for value, _ in choices_list]: + label = self._current_value(field_name.replace("_school", "_school_name")) or self.choice_map.get(current) or "Selected School" + choices_list.insert(1, (current, label)) + # Don't add "Add New School" option - we have a dedicated form for that + field.widget = forms.Select(choices=choices_list) + field.widget.attrs.update({ + "class": "form-control", + "data-school-select": "team", + }) + list_id = f"{self.prefix}-{field_name.replace('_school', '')}-options" + field.widget.attrs["data-list-id"] = list_id + name_id = self[field_name.replace("_school", "_name")].auto_id + field.widget.attrs["data-name-id"] = name_id + name_widget.attrs.update({ + "class": "form-control mt-2 d-none", + "placeholder": "School name", + }) + name_widget.attrs.setdefault("data-related-select", field.widget.attrs.get("id")) + if current == NEW_CHOICE_VALUE: + self._reveal_input(name_widget) + + def _configure_debater_inputs(self, prefix): + name_field = f"{prefix}_name" + apda_field = f"{prefix}_apda_id" + list_id = f"{self.prefix}-{prefix.replace('_', '-')}-options" + name_widget = self.fields[name_field].widget + # For Select widget, we don't need autocomplete or list attributes for functionality, + # but we set them so the template can access the list ID + name_widget.attrs.update({ + "data-debater-input": prefix, + "data-apda-target": self[apda_field].auto_id, + "data-list": list_id, + "list": list_id, # Also set without data- prefix for template access + }) + self.fields[apda_field].widget.attrs.setdefault("id", self[apda_field].auto_id) + + def _reveal_input(self, widget): + classes = widget.attrs.get("class", "") + widget.attrs["class"] = " ".join( + part for part in classes.split() if part != "d-none" + ).strip() + + +class JudgeForm(forms.Form): + registration_judge_id = forms.IntegerField(required=False, widget=forms.HiddenInput) + judge_id = forms.IntegerField(required=False, widget=forms.HiddenInput) + name = forms.CharField(max_length=30) + experience = forms.IntegerField( + min_value=0, + max_value=10, + widget=forms.NumberInput(attrs={'min': '0', 'max': '10', 'step': '1'}) + ) + DELETE = forms.BooleanField(required=False, widget=forms.HiddenInput) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["name"].widget.attrs.setdefault("class", "form-control") + self.fields["experience"].widget.attrs.update({ + "class": "form-control", + "placeholder": "0-10" + }) + + def get_payload(self): + return { + "registration_judge_id": self.cleaned_data.get("registration_judge_id"), + "judge_id": self.cleaned_data.get("judge_id"), + "name": self.cleaned_data["name"], + "experience": self.cleaned_data["experience"], + } diff --git a/mittab/apps/registration/migrations/0001_initial.py b/mittab/apps/registration/migrations/0001_initial.py new file mode 100644 index 000000000..8b2db3e2e --- /dev/null +++ b/mittab/apps/registration/migrations/0001_initial.py @@ -0,0 +1,61 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("tab", "0025_alter_tabsettings_key"), + ] + + operations = [ + migrations.CreateModel( + name="RegistrationConfig", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("registration_open", models.DateField(blank=True, null=True)), + ("registration_close", models.DateField(blank=True, null=True)), + ("tournament_start", models.DateField(blank=True, null=True)), + ("extra_information", models.TextField(blank=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name="Registration", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("email", models.EmailField(max_length=254)), + ("herokunator_code", models.CharField(blank=True, max_length=255, unique=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("school", models.ForeignKey(on_delete=models.deletion.CASCADE, to="tab.school")), + ], + ), + migrations.CreateModel( + name="RegistrationTeam", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("is_free_seed", models.BooleanField(default=False)), + ("registration", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="teams", to="registration.registration")), + ("team", models.ForeignKey(on_delete=models.deletion.CASCADE, to="tab.team")), + ], + ), + migrations.CreateModel( + name="RegistrationTeamMember", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("position", models.IntegerField(default=0)), + ("debater", models.ForeignKey(on_delete=models.deletion.CASCADE, to="tab.debater")), + ("registration_team", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="members", to="registration.registrationteam")), + ("school", models.ForeignKey(on_delete=models.deletion.CASCADE, to="tab.school")), + ], + ), + migrations.CreateModel( + name="RegistrationJudge", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("judge", models.ForeignKey(on_delete=models.deletion.CASCADE, to="tab.judge")), + ("registration", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="judges", to="registration.registration")), + ], + ), + ] diff --git a/mittab/apps/registration/migrations/__init__.py b/mittab/apps/registration/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mittab/apps/registration/models.py b/mittab/apps/registration/models.py new file mode 100644 index 000000000..be0361f4b --- /dev/null +++ b/mittab/apps/registration/models.py @@ -0,0 +1,66 @@ +from haikunator import Haikunator +from django.db import models +from django.utils import timezone + +from mittab.apps.tab.models import School, Team, Judge, Debater + + +class RegistrationConfig(models.Model): + registration_open = models.DateField(null=True, blank=True) + registration_close = models.DateField(null=True, blank=True) + tournament_start = models.DateField(null=True, blank=True) + extra_information = models.TextField(blank=True) + updated_at = models.DateTimeField(auto_now=True) + + @classmethod + def get_active(cls): + return cls.objects.first() + + def is_open(self): + today = timezone.now().date() + if self.registration_open and today < self.registration_open: + return False + if self.registration_close and today > self.registration_close: + return False + return True + + +class Registration(models.Model): + school = models.ForeignKey(School, on_delete=models.CASCADE) + email = models.EmailField() + herokunator_code = models.CharField(max_length=255, unique=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def save(self, *args, **kwargs): + if not self.herokunator_code: + haikunator = Haikunator() + code = haikunator.haikunate(token_length=0) + while Registration.objects.filter(herokunator_code=code).exists(): + code = haikunator.haikunate(token_length=0) + self.herokunator_code = code + super().save(*args, **kwargs) + + +class RegistrationTeam(models.Model): + registration = models.ForeignKey(Registration, + related_name="teams", + on_delete=models.CASCADE) + team = models.ForeignKey(Team, on_delete=models.CASCADE) + is_free_seed = models.BooleanField(default=False) + + +class RegistrationJudge(models.Model): + registration = models.ForeignKey(Registration, + related_name="judges", + on_delete=models.CASCADE) + judge = models.ForeignKey(Judge, on_delete=models.CASCADE) + + +class RegistrationTeamMember(models.Model): + registration_team = models.ForeignKey(RegistrationTeam, + related_name="members", + on_delete=models.CASCADE) + debater = models.ForeignKey(Debater, on_delete=models.CASCADE) + school = models.ForeignKey(School, on_delete=models.CASCADE) + position = models.IntegerField(default=0) diff --git a/mittab/apps/registration/tests/__init__.py b/mittab/apps/registration/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mittab/apps/registration/tests/test_registration_flow.py b/mittab/apps/registration/tests/test_registration_flow.py new file mode 100644 index 000000000..4877debe8 --- /dev/null +++ b/mittab/apps/registration/tests/test_registration_flow.py @@ -0,0 +1,123 @@ +from decimal import Decimal + +import pytest + +from mittab.apps.registration.models import Registration, RegistrationTeamMember +from mittab.apps.registration.views import MAX_TEAMS +from mittab.apps.tab.models import Team + + +def base_management(prefix, total, initial=0): + max_value = str(MAX_TEAMS if prefix == "teams" else 1000) + return { + f"{prefix}-TOTAL_FORMS": str(total), + f"{prefix}-INITIAL_FORMS": str(initial), + f"{prefix}-MIN_NUM_FORMS": "0", + f"{prefix}-MAX_NUM_FORMS": max_value, + } + + +@pytest.mark.django_db +def test_registration_flow_creates_objects(client): + data = { + "school": "apda:123", + "school_name": "Registration U", + "email": "contact@example.com", + } + data.update(base_management("teams", 1)) + data.update(base_management("judges", 1)) + data.update( + { + "teams-0-registration_team_id": "", + "teams-0-team_id": "", + "teams-0-name": "Registration U A", + "teams-0-is_free_seed": "on", + "teams-0-debater_one_id": "", + "teams-0-debater_one_name": "Registration U Speaker 1", + "teams-0-debater_one_apda_id": "1000", + "teams-0-debater_one_novice_status": "0", + "teams-0-debater_one_school": "apda:123", + "teams-0-debater_one_school_name": "Registration U", + "teams-0-debater_two_id": "", + "teams-0-debater_two_name": "Registration U Speaker 2", + "teams-0-debater_two_apda_id": "", + "teams-0-debater_two_novice_status": "0", + "teams-0-debater_two_school": "__new__", + "teams-0-debater_two_school_name": "Hybrid School", + "teams-0-DELETE": "", + "judges-0-registration_judge_id": "", + "judges-0-judge_id": "", + "judges-0-name": "Reg Judge", + "judges-0-experience": "7", + "judges-0-DELETE": "", + } + ) + response = client.post("/registration/", data=data, follow=True) + assert response.status_code == 200 + registration = Registration.objects.first() + assert registration.herokunator_code in response.request["PATH_INFO"] + assert registration.email == "contact@example.com" + reg_team = registration.teams.select_related("team").first() + team = reg_team.team + assert team.name == "Registration U A" + assert team.seed == Team.FREE_SEED + members = list( + RegistrationTeamMember.objects.filter(registration_team=reg_team) + .select_related("school") + .order_by("position") + ) + assert members[0].school == registration.school + assert members[1].school.name == "Hybrid School" + judge_relation = registration.judges.select_related("judge").first() + assert judge_relation.judge.name == "Reg Judge" + assert judge_relation.judge.rank == Decimal("7") + + +@pytest.mark.django_db +def test_registration_requires_single_free_seed(client): + data = { + "school": "apda:50", + "school_name": "Test School", + "email": "team@example.com", + } + data.update(base_management("teams", 2)) + data.update(base_management("judges", 0)) + data.update( + { + "teams-0-registration_team_id": "", + "teams-0-team_id": "", + "teams-0-name": "Team One", + "teams-0-debater_one_id": "", + "teams-0-debater_one_name": "Speaker 1", + "teams-0-debater_one_apda_id": "", + "teams-0-debater_one_novice_status": "0", + "teams-0-debater_one_school": "apda:50", + "teams-0-debater_one_school_name": "Test School", + "teams-0-debater_two_id": "", + "teams-0-debater_two_name": "Speaker 2", + "teams-0-debater_two_apda_id": "", + "teams-0-debater_two_novice_status": "0", + "teams-0-debater_two_school": "apda:50", + "teams-0-debater_two_school_name": "Test School", + "teams-0-DELETE": "", + "teams-1-registration_team_id": "", + "teams-1-team_id": "", + "teams-1-name": "Team Two", + "teams-1-debater_one_id": "", + "teams-1-debater_one_name": "Speaker 3", + "teams-1-debater_one_apda_id": "", + "teams-1-debater_one_novice_status": "0", + "teams-1-debater_one_school": "apda:50", + "teams-1-debater_one_school_name": "Test School", + "teams-1-debater_two_id": "", + "teams-1-debater_two_name": "Speaker 4", + "teams-1-debater_two_apda_id": "", + "teams-1-debater_two_novice_status": "0", + "teams-1-debater_two_school": "apda:50", + "teams-1-debater_two_school_name": "Test School", + "teams-1-DELETE": "", + } + ) + response = client.post("/registration/", data=data) + assert response.status_code == 200 + assert b"Select exactly one free seed" in response.content diff --git a/mittab/apps/registration/urls.py b/mittab/apps/registration/urls.py new file mode 100644 index 000000000..7b919db69 --- /dev/null +++ b/mittab/apps/registration/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("", views.registration_portal, name="registration_portal"), + path("/", views.registration_portal, name="registration_portal_edit"), + # API proxy endpoints to avoid CORS issues + path("api/schools/", views.proxy_schools_active, name="api_schools_active"), + path("api/schools/all/", views.proxy_schools_all, name="api_schools_all"), + path("api/debaters//", views.proxy_debaters, name="api_debaters"), +] diff --git a/mittab/apps/registration/views.py b/mittab/apps/registration/views.py new file mode 100644 index 000000000..1bb4e7e53 --- /dev/null +++ b/mittab/apps/registration/views.py @@ -0,0 +1,470 @@ +from django import forms +from django.db import transaction +from django.db.models import Prefetch +from django.forms import BaseFormSet, formset_factory +from django.http import Http404, JsonResponse +from django.shortcuts import redirect, render +from django.urls import reverse +from django.views.decorators.http import require_http_methods +import requests +from requests.exceptions import RequestException + +from mittab.apps.registration.forms import ( + JudgeForm, + NEW_CHOICE_VALUE, + RegistrationForm, + TeamForm, + parse_school, +) +from mittab.apps.tab.models import Debater, Judge, School, Team +from .models import ( + Registration, + RegistrationConfig, + RegistrationJudge, + RegistrationTeam, + RegistrationTeamMember, +) + +SCHOOL_ACTIVE_URL = "https://results.apda.online/api/schools/" +SCHOOL_ALL_URL = "https://results.apda.online/api/schools/all/" +MAX_TEAMS = 200 + + +class RegistrationTeamFormSet(BaseFormSet): + def __init__(self, *args, school_choices=None, **kwargs): + self.school_choices = school_choices or [] + super().__init__(*args, **kwargs) + + def _construct_form(self, i, **kwargs): + kwargs["school_choices"] = self.school_choices + return super()._construct_form(i, **kwargs) + + def clean(self): + super().clean() + if any(self.errors): + return + active = [form for form in self.forms if form.cleaned_data and not form.cleaned_data.get("DELETE")] + if not active: + raise forms.ValidationError("Add at least one team") + free = sum(1 for form in active if form.cleaned_data.get("is_free_seed")) + if free != 1: + raise forms.ValidationError("Select exactly one free seed") + + +class RegistrationJudgeFormSet(BaseFormSet): + pass + + +TeamFormSet = formset_factory( + TeamForm, + formset=RegistrationTeamFormSet, + extra=0, + can_delete=True, + max_num=MAX_TEAMS, +) +JudgeFormSet = formset_factory( + JudgeForm, + formset=RegistrationJudgeFormSet, + extra=0, + can_delete=True, +) + + +def fetch_remote_schools(active_only=True): + """ + Fetch schools from the API. + If active_only is True, only fetches from the active schools endpoint. + If False, fetches from both active and all schools endpoints. + """ + results = [] + seen = set() + urls = (SCHOOL_ACTIVE_URL,) if active_only else (SCHOOL_ACTIVE_URL, SCHOOL_ALL_URL) + for url in urls: + try: + response = requests.get(url, timeout=5) + if not response.ok: + continue + data = response.json() + except (ValueError, RequestException): + continue + items = data if isinstance(data, list) else data.get("schools", []) + for item in items: + name = item.get("name") + apda_id = item.get("id") if "id" in item else item.get("apda_id") + if not name or apda_id is None or apda_id in seen: + continue + seen.add(apda_id) + results.append({"id": apda_id, "name": name}) + return results + + +def build_school_choices(active_only=True): + """Build school choices for the dropdown.""" + return [(f"apda:{school['id']}", school["name"]) for school in fetch_remote_schools(active_only)] + + +def school_value(school): + if not school: + return "" + if school.apda_id not in (None, -1): + return f"apda:{school.apda_id}" + return NEW_CHOICE_VALUE + + +def registration_team_initial(reg_team): + team = reg_team.team + members = list(reg_team.members.select_related("debater", "school").order_by("position")) + defaults = [{"debater": None, "school": None}, {"debater": None, "school": None}] + for member in members: + if member.position in (0, 1): + defaults[member.position] = {"debater": member.debater, "school": member.school} + for index in range(2): + if defaults[index]["debater"] is None: + debater = team.debaters.all()[index] if team.debaters.count() > index else None + defaults[index]["debater"] = debater + defaults[index]["school"] = team.hybrid_school if index == 1 else team.school + first_school = defaults[0]["school"] + second_school = defaults[1]["school"] + return { + "registration_team_id": reg_team.pk, + "team_id": team.pk, + "name": team.name, + "is_free_seed": reg_team.is_free_seed, + "debater_one_id": defaults[0]["debater"].pk if defaults[0]["debater"] else None, + "debater_one_name": defaults[0]["debater"].name if defaults[0]["debater"] else "", + "debater_one_apda_id": defaults[0]["debater"].apda_id if defaults[0]["debater"] else None, + "debater_one_novice_status": defaults[0]["debater"].novice_status if defaults[0]["debater"] else 0, + "debater_one_qualified": defaults[0]["debater"].qualified if defaults[0]["debater"] else False, + "debater_one_school": school_value(first_school), + "debater_one_school_name": first_school.name if first_school else "", + "debater_two_id": defaults[1]["debater"].pk if defaults[1]["debater"] else None, + "debater_two_name": defaults[1]["debater"].name if defaults[1]["debater"] else "", + "debater_two_apda_id": defaults[1]["debater"].apda_id if defaults[1]["debater"] else None, + "debater_two_novice_status": defaults[1]["debater"].novice_status if defaults[1]["debater"] else 0, + "debater_two_qualified": defaults[1]["debater"].qualified if defaults[1]["debater"] else False, + "debater_two_school": school_value(second_school), + "debater_two_school_name": second_school.name if second_school else "", + } + + +def registration_judge_initial(reg_judge): + judge = reg_judge.judge + return { + "registration_judge_id": reg_judge.pk, + "judge_id": judge.pk, + "name": judge.name, + "experience": judge.rank, + } + + +def get_registration_forms(request, registration, school_choices): + if request.method == "POST": + reg_form = RegistrationForm(request.POST, school_choices=school_choices) + team_formset = TeamFormSet(request.POST, prefix="teams", school_choices=school_choices) + judge_formset = JudgeFormSet(request.POST, prefix="judges") + return reg_form, team_formset, judge_formset + if registration: + reg_form = RegistrationForm( + initial={ + "school": school_value(registration.school), + "school_name": registration.school.name, + "email": registration.email, + }, + school_choices=school_choices, + ) + teams_initial = [ + registration_team_initial(team) + for team in registration.teams.select_related("team").prefetch_related("team__debaters", "members__debater", "members__school") + ] + judges_initial = [ + registration_judge_initial(reg) + for reg in registration.judges.select_related("judge") + ] + else: + reg_form = RegistrationForm(school_choices=school_choices) + teams_initial = [] + judges_initial = [] + team_formset = TeamFormSet( + initial=teams_initial, + prefix="teams", + school_choices=school_choices, + ) + judge_formset = JudgeFormSet(initial=judges_initial, prefix="judges") + return reg_form, team_formset, judge_formset + + +def resolve_school(selection, cache): + key = tuple(sorted(selection.items())) + if key in cache: + return cache[key] + if "pk" in selection: + school = School.objects.select_for_update().filter(pk=selection["pk"]).first() + if not school: + raise forms.ValidationError("Unknown school") + cache[key] = school + return school + if "apda_id" in selection: + school = School.objects.select_for_update().filter(apda_id=selection["apda_id"]).first() + if school: + cache[key] = school + return school + school = School.objects.create(name=selection.get("name", ""), apda_id=selection["apda_id"]) + cache[key] = school + return school + name = selection["name"] + school = School.objects.select_for_update().filter(name__iexact=name).first() + if school: + if school.apda_id in (None, 0): + school.apda_id = -1 + school.save() + cache[key] = school + return school + school = School.objects.create(name=name, apda_id=-1) + cache[key] = school + return school + + +def get_or_create_debater(data, school, team): + debater_id = data.get("id") + apda_id = data.get("apda_id") + name = data["name"].strip() + if not name: + raise forms.ValidationError("Debater name required") + queryset = Debater.objects.select_for_update() + debater = None + if debater_id: + debater = queryset.filter(pk=debater_id).first() + if not debater: + raise forms.ValidationError("Unknown debater") + elif apda_id not in (None, ""): + debater = queryset.filter(apda_id=apda_id).first() + if not debater: + debater = queryset.filter(name__iexact=name).first() + if not debater: + debater = Debater(name=name, novice_status=data["novice_status"], apda_id=-1) + debater.name = name + debater.novice_status = data["novice_status"] + debater.qualified = data["qualified"] + debater.apda_id = apda_id if apda_id not in (None, "") else -1 + debater.save() + assignments = debater.team_set.exclude(pk=team.pk if team else None) + if assignments.exists(): + raise forms.ValidationError(f"Debater {debater.name} is already on another team") + return debater + + +def ensure_unique_team_name(team, name): + conflict = Team.objects.filter(name__iexact=name) + if team.pk: + conflict = conflict.exclude(pk=team.pk) + if conflict.exists(): + raise forms.ValidationError(f"Team name {name} already exists") + + +def summarise_registration(registration): + if not registration: + return None + registration = Registration.objects.select_related("school").prefetch_related( + Prefetch( + "teams", + queryset=RegistrationTeam.objects.select_related("team").prefetch_related( + "team__debaters", + "members__debater", + "members__school", + ), + ), + "judges__judge", + ).get(pk=registration.pk) + teams = [] + for reg_team in registration.teams.all(): + team = reg_team.team + debaters = [] + for member in reg_team.members.select_related("debater", "school").order_by("position"): + debaters.append(member.debater.name) + if not debaters: + debaters = [debater.name for debater in team.debaters.all()] + teams.append({"name": team.name, "is_free_seed": reg_team.is_free_seed, "debaters": debaters}) + judges = [ + {"name": reg.judge.name, "code": reg.judge.ballot_code} + for reg in registration.judges.select_related("judge") + ] + return { + "code": registration.herokunator_code, + "email": registration.email, + "school": registration.school.name, + "teams": teams, + "judges": judges, + } + + +def save_registration(reg_form, team_formset, judge_formset, registration): + school_cache = {} + main_school = resolve_school(reg_form.get_school(), school_cache) + registration = registration or Registration() + registration.school = main_school + registration.email = reg_form.cleaned_data["email"] + registration.save() + team_map = { + team.pk: team + for team in registration.teams.select_related("team").prefetch_related("team__debaters", "members__debater", "members__school") + } + saved_team_ids = [] + for form in team_formset: + if form.cleaned_data.get("DELETE"): + continue + payload = form.get_payload() + team_obj = Team.objects.select_for_update().filter(pk=payload["team_id"]).first() if payload.get("team_id") else Team() + ensure_unique_team_name(team_obj, payload["name"]) + members = payload["members"] + first_school = resolve_school(members[0]["school"], school_cache) + if first_school.pk != main_school.pk: + raise forms.ValidationError("The first debater must represent the registration school") + hybrid_school = None + member_instances = [] + for member_payload in members: + member_school = resolve_school(member_payload["school"], school_cache) + if member_school.pk != main_school.pk and not hybrid_school: + hybrid_school = member_school + debater = get_or_create_debater(member_payload, member_school, team_obj if team_obj.pk else None) + member_instances.append((debater, member_school)) + team_obj.school = main_school + team_obj.hybrid_school = hybrid_school + team_obj.name = payload["name"] + team_obj.seed = Team.FREE_SEED if payload["is_free_seed"] else Team.UNSEEDED + team_obj.break_preference = Team.VARSITY + team_obj.checked_in = False + team_obj.save() + team_obj.debaters.set([debater for debater, _ in member_instances]) + reg_team = RegistrationTeam.objects.select_for_update().filter(pk=payload.get("registration_team_id"), registration=registration).first() + if not reg_team: + reg_team = RegistrationTeam.objects.create(registration=registration, team=team_obj) + reg_team.is_free_seed = payload["is_free_seed"] + reg_team.team = team_obj + reg_team.save() + member_map = {member.position: member for member in reg_team.members.select_for_update()} + for index, (debater, school) in enumerate(member_instances): + member = member_map.pop(index, None) + if not member: + member = RegistrationTeamMember(registration_team=reg_team, position=index) + member.debater = debater + member.school = school + member.save() + for leftover in member_map.values(): + leftover.delete() + saved_team_ids.append(reg_team.pk) + for reg_team in list(registration.teams.all()): + if reg_team.pk not in saved_team_ids: + team = reg_team.team + reg_team.members.all().delete() + reg_team.delete() + team.debaters.clear() + try: + team.delete() + except Exception: + pass + judge_map = { + judge.pk: judge + for judge in registration.judges.select_related("judge") + } + saved_judge_ids = [] + for form in judge_formset: + if form.cleaned_data.get("DELETE"): + continue + payload = form.get_payload() + experience = payload["experience"] + if experience < 0 or experience > 10: + raise forms.ValidationError("Judge experience must be between 0 and 10") + judge = Judge.objects.select_for_update().filter(pk=payload.get("judge_id")).first() + if not judge: + judge = Judge(name=payload["name"], rank=experience) + judge.name = payload["name"] + judge.rank = experience + judge.save() + relation = registration.judges.select_for_update().filter(pk=payload.get("registration_judge_id"), registration=registration).first() + if not relation: + relation = RegistrationJudge.objects.create(registration=registration, judge=judge) + else: + relation.judge = judge + relation.save() + relation.judge.schools.set([registration.school]) + saved_judge_ids.append(relation.pk) + for reg_judge in list(registration.judges.all()): + if reg_judge.pk not in saved_judge_ids: + judge = reg_judge.judge + reg_judge.delete() + try: + judge.delete() + except Exception: + pass + return registration + + +@require_http_methods(["GET", "POST"]) +def registration_portal(request, code=None): + registration = None + if code: + registration = Registration.objects.select_related("school").filter(herokunator_code=code).first() + if not registration: + raise Http404() + school_choices = build_school_choices() + reg_form, team_formset, judge_formset = get_registration_forms(request, registration, school_choices) + config = RegistrationConfig.get_active() + if request.method == "POST": + if reg_form.is_valid() and team_formset.is_valid() and judge_formset.is_valid(): + if config and not config.is_open(): + reg_form.add_error(None, "Registration is currently closed") + else: + try: + with transaction.atomic(): + saved = save_registration(reg_form, team_formset, judge_formset, registration) + except forms.ValidationError as error: + team_formset._non_form_errors = team_formset.error_class(error.messages) + else: + return redirect(reverse("registration_portal_edit", args=[saved.herokunator_code])) + summary = summarise_registration(registration) if registration else None + context = { + "registration_form": reg_form, + "team_formset": team_formset, + "judge_formset": judge_formset, + "config": config, + "summary": summary, + "max_teams": MAX_TEAMS, + } + return render(request, "registration/portal.html", context) + + +@require_http_methods(["GET"]) +def proxy_schools_active(request): + """Proxy endpoint for active schools to avoid CORS issues.""" + try: + response = requests.get(SCHOOL_ACTIVE_URL, timeout=10) + if response.ok: + return JsonResponse(response.json(), safe=False) + return JsonResponse({"error": "Failed to fetch schools"}, status=response.status_code) + except RequestException as e: + return JsonResponse({"error": str(e)}, status=500) + + +@require_http_methods(["GET"]) +def proxy_schools_all(request): + """Proxy endpoint for all schools to avoid CORS issues.""" + try: + response = requests.get(SCHOOL_ALL_URL, timeout=10) + if response.ok: + return JsonResponse(response.json(), safe=False) + return JsonResponse({"error": "Failed to fetch schools"}, status=response.status_code) + except RequestException as e: + return JsonResponse({"error": str(e)}, status=500) + + +@require_http_methods(["GET"]) +def proxy_debaters(request, school_id): + """Proxy endpoint for school debaters to avoid CORS issues.""" + try: + url = f"https://results.apda.online/api/debaters/{school_id}/" + response = requests.get(url, timeout=10) + if response.ok: + return JsonResponse(response.json(), safe=False) + return JsonResponse({"error": "Failed to fetch debaters"}, status=response.status_code) + except RequestException as e: + return JsonResponse({"error": str(e)}, status=500) diff --git a/mittab/apps/tab/middleware.py b/mittab/apps/tab/middleware.py index e5bf64514..fd5f094aa 100644 --- a/mittab/apps/tab/middleware.py +++ b/mittab/apps/tab/middleware.py @@ -27,8 +27,11 @@ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): - whitelisted = (request.path in LOGIN_WHITELIST) or \ - EBALLOT_REGEX.match(request.path) + whitelisted = ( + request.path in LOGIN_WHITELIST or + EBALLOT_REGEX.match(request.path) or + request.path.startswith("/registration") + ) if not whitelisted and request.user.is_anonymous: if request.POST: diff --git a/mittab/apps/tab/migrations/0026_debater_qualified.py b/mittab/apps/tab/migrations/0026_debater_qualified.py new file mode 100644 index 000000000..595ea5be9 --- /dev/null +++ b/mittab/apps/tab/migrations/0026_debater_qualified.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tab", "0025_alter_tabsettings_key"), + ] + + operations = [ + migrations.AddField( + model_name="debater", + name="qualified", + field=models.BooleanField(default=False), + ), + ] diff --git a/mittab/apps/tab/models.py b/mittab/apps/tab/models.py index 61db42417..cbd10770d 100644 --- a/mittab/apps/tab/models.py +++ b/mittab/apps/tab/models.py @@ -97,6 +97,7 @@ class Debater(models.Model): (NOVICE, "Novice"), ) novice_status = models.IntegerField(choices=NOVICE_CHOICES) + qualified = models.BooleanField(default=False) tiebreaker = models.IntegerField(unique=True, null=True, blank=True) apda_id = models.IntegerField(blank=True, null=True, default=-1) diff --git a/mittab/settings.py b/mittab/settings.py index 086382cfa..843c6a558 100644 --- a/mittab/settings.py +++ b/mittab/settings.py @@ -20,7 +20,8 @@ INSTALLED_APPS = ("django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "mittab.apps.tab", "sentry_sdk.integrations.django", + "mittab.apps.tab", "mittab.apps.registration", + "sentry_sdk.integrations.django", "webpack_loader", "bootstrap4",) MIDDLEWARE = ( diff --git a/mittab/templates/registration/_judge_form.html b/mittab/templates/registration/_judge_form.html new file mode 100644 index 000000000..9b7cd8c9b --- /dev/null +++ b/mittab/templates/registration/_judge_form.html @@ -0,0 +1,28 @@ +
+
+
+ {% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %} +
+
+
+ Name +
+ {{ form.name }} +
+ {{ form.name.errors }} +
+
+
+
+ Experience +
+ {{ form.experience }} +
+ {{ form.experience.errors }} +
+
+ +
+
+
+
diff --git a/mittab/templates/registration/_school_selector.html b/mittab/templates/registration/_school_selector.html new file mode 100644 index 000000000..ed1c2dc7c --- /dev/null +++ b/mittab/templates/registration/_school_selector.html @@ -0,0 +1,35 @@ +{% if show_create_button %} +
+
+ {{ field.label_tag }} + {{ field }} + {{ field.errors }} + {% if show_see_more|default:True %} + Showing top 25 active schools. See more schools + {% endif %} +
+
+ + +
+
+ {{ name_field.label_tag }} + {{ name_field }} + {{ name_field.errors }} +
+
+{% else %} +
+ {{ field.label_tag }} + {{ field }} + {{ field.errors }} + {% if show_see_more|default:True %} + Showing top 25 active schools. See more schools + {% endif %} +
+ {{ name_field.label_tag }} + {{ name_field }} + {{ name_field.errors }} +
+
+{% endif %} diff --git a/mittab/templates/registration/_team_form.html b/mittab/templates/registration/_team_form.html new file mode 100644 index 000000000..a1b1383c4 --- /dev/null +++ b/mittab/templates/registration/_team_form.html @@ -0,0 +1,98 @@ +
+
+ {% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %} + {% for error in form.non_field_errors %} +
{{ error }}
+ {% endfor %} +
+
+
+
+
Team Info
+
+
+ Name +
+ {{ form.name }} +
+ {% if form.name.errors %} +
{{ form.name.errors }}
+ {% endif %} +
+ {{ form.is_free_seed }} + {{ form.is_free_seed.label_tag }} + {{ form.is_free_seed.errors }} +
+
+
+
+
+
+
+
First Debater
+ {{ form.debater_one_id }} +
+
+ Name +
+ {{ form.debater_one_name }} +
+ {% if form.debater_one_name.errors %} +
{{ form.debater_one_name.errors }}
+ {% endif %} +
+
+ School +
+ {{ form.debater_one_school }} +
+ Showing top 25 active schools. See more + {% if form.debater_one_school.errors %} +
{{ form.debater_one_school.errors }}
+ {% endif %} + + {{ form.debater_one_apda_id }} + {{ form.debater_one_novice_status }} + {{ form.debater_one_qualified }} + {{ form.debater_one_school_name }} +
+
+
+
+
+
+
Second Debater
+ {{ form.debater_two_id }} +
+
+ Name +
+ {{ form.debater_two_name }} +
+ {% if form.debater_two_name.errors %} +
{{ form.debater_two_name.errors }}
+ {% endif %} +
+
+ School +
+ {{ form.debater_two_school }} +
+ Showing top 25 active schools. See more + {% if form.debater_two_school.errors %} +
{{ form.debater_two_school.errors }}
+ {% endif %} + + {{ form.debater_two_apda_id }} + {{ form.debater_two_novice_status }} + {{ form.debater_two_qualified }} + {{ form.debater_two_school_name }} +
+
+
+
+ +
+
+
+
diff --git a/mittab/templates/registration/portal.html b/mittab/templates/registration/portal.html new file mode 100644 index 000000000..4ac0a191b --- /dev/null +++ b/mittab/templates/registration/portal.html @@ -0,0 +1,194 @@ +{% extends "base/__wide.html" %} +{% load render_bundle from webpack_loader %} + +{% block title %}Tournament Registration{% endblock %} +{% block banner %}Tournament Registration{% endblock %} + +{% block content %} +{% render_bundle 'registrationPortal' %} +
+ {% if config and not config.is_open %} +
Registration is currently closed.
+ {% endif %} + +
+ {% csrf_token %} +
+ +
+
+
+
School And Contact
+ {% for error in registration_form.non_field_errors %} +
{{ error }}
+ {% endfor %} +
+
+ School +
+ {{ registration_form.school }} +
+ Showing top 25 active schools. See more schools + {% if registration_form.school.errors %} +
{{ registration_form.school.errors }}
+ {% endif %} +
+
+ Email +
+ {{ registration_form.email }} +
+ {% if registration_form.email.errors %} +
{{ registration_form.email.errors }}
+ {% endif %} +
+
+
+ + +
+
+ +
+
+
Create New School
+
+
+ Name +
+ +
+ +
+
+ + +
+
+
Create New Debater
+
+
+ School +
+ +
+ Showing top 25 active schools. See more +
+
+ Name +
+ +
+
+
+ Status +
+ +
+ +
+
+
+
+
+
+
+
+
Teams
+ +
+ Select a school above before adding teams. Team schools will be automatically populated from your registration school. + {{ team_formset.management_form }} + {% if team_formset.non_form_errors %} +
+ {% for error in team_formset.non_form_errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} +
+ {% for form in team_formset.forms %} + {% include "registration/_team_form.html" with form=form %} + {% endfor %} +
+
+
+
+
+
+
Judges
+ +
+ {{ judge_formset.management_form }} + {% if judge_formset.non_form_errors %} +
+ {% for error in judge_formset.non_form_errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} +
+ {% for form in judge_formset.forms %} + {% include "registration/_judge_form.html" with form=form %} + {% endfor %} +
+
+
+
+ + {% if summary %} + Editing registration {{ summary.code }} + {% endif %} +
+
+ + + {% if summary %} +
+
+
+
Registration Saved
+

Herokunator Code: {{ summary.code }}

+

School: {{ summary.school }}

+

Email: {{ summary.email }}

+
Teams
+
+ {% for team in summary.teams %} +
+ {{ team.name }} + {% if team.is_free_seed %} + Free Seed + {% endif %} +
    + {% for debater in team.debaters %} +
  • {{ debater }}
  • + {% endfor %} +
+
+ {% endfor %} +
+
Judges
+
+ {% for judge in summary.judges %} +
+

{{ judge.name }}{% if judge.code %} (Code: {{ judge.code }}){% endif %}

+
+ {% endfor %} +
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/mittab/urls.py b/mittab/urls.py index c36576f5c..b4da53271 100644 --- a/mittab/urls.py +++ b/mittab/urls.py @@ -22,6 +22,7 @@ re_path(r"^admin/", admin.site.urls, name="admin"), path("dynamic-media/jsi18n/", i18n.JavaScriptCatalog.as_view(), name="js18"), path("", views.index, name="index"), + path("registration/", include("mittab.apps.registration.urls")), re_path(r"^403/", views.render_403, name="403"), re_path(r"^404/", views.render_404, name="404"), re_path(r"^500/", views.render_500, name="500"), diff --git a/webpack.config.js b/webpack.config.js index 133a35fa4..5e31597ae 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -11,7 +11,8 @@ module.exports = { main: "./assets/js/index", pairingDisplay: "./assets/js/pairingDisplay", publicDisplay: "./assets/js/publicDisplay", - bracket : "./assets/js/bracket", + bracket: "./assets/js/bracket", + registrationPortal: "./assets/js/registration/index", }, optimization: { minimizer: [new TerserPlugin(), new CssMinimizerPlugin()], From 1115e1410530c8b40cb1589039fdb88a9b156666 Mon Sep 17 00:00:00 2001 From: JoeyRubas Date: Mon, 10 Nov 2025 16:51:41 -0500 Subject: [PATCH 2/5] UI MVP --- .pylintrc | 4 +- Pipfile | 3 + assets/css/mobile.scss | 80 +- assets/css/public-home.scss | 122 +++- assets/css/registration.scss | 161 +++++ assets/js/registration/index.js | 1 + assets/js/registration/portal.js | 684 ++++++++++-------- mittab/apps/registration/admin.py | 8 +- mittab/apps/registration/forms.py | 217 ++++-- .../registration/migrations/0001_initial.py | 72 +- mittab/apps/registration/models.py | 44 +- .../tests/test_registration_flow.py | 7 +- mittab/apps/registration/urls.py | 1 + mittab/apps/registration/views.py | 315 ++++++-- .../tab/migrations/0026_debater_qualified.py | 15 - .../tab/migrations/0032_auto_20251110_2150.py | 23 + mittab/apps/tab/models.py | 1 + mittab/apps/tab/templatetags/tags.py | 12 +- mittab/apps/tab/views/public_views.py | 10 + mittab/templates/base/_navigation.html | 2 + mittab/templates/public/home.html | 54 +- .../templates/registration/_judge_form.html | 13 +- .../registration/_school_selector.html | 6 - mittab/templates/registration/_team_form.html | 13 +- mittab/templates/registration/portal.html | 104 ++- mittab/templates/registration/setup.html | 72 ++ 26 files changed, 1475 insertions(+), 569 deletions(-) create mode 100644 assets/css/registration.scss delete mode 100644 mittab/apps/tab/migrations/0026_debater_qualified.py create mode 100644 mittab/apps/tab/migrations/0032_auto_20251110_2150.py create mode 100644 mittab/templates/registration/setup.html diff --git a/.pylintrc b/.pylintrc index a21fd086a..58e8b6aee 100644 --- a/.pylintrc +++ b/.pylintrc @@ -15,7 +15,7 @@ ignore-patterns= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). -#init-hook= +init-hook=import sys; any(arg and not arg.startswith('-') for arg in sys.argv[1:]) or sys.argv.append("mittab") # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use. @@ -32,7 +32,7 @@ load-plugins=pylint_django django-settings-module=mittab.settings # Pickle collected data for later comparisons. -persistent=yes +persistent=no # Specify a configuration file. #rcfile= diff --git a/Pipfile b/Pipfile index fadafb11e..f778aea82 100644 --- a/Pipfile +++ b/Pipfile @@ -37,5 +37,8 @@ exceptiongroup = "==1.3.0" wrapt = "==1.17.3" pyyaml = "==5.3.1" +[scripts] +pylint = "pylint mittab" + [requires] python_version = "3.10.16" diff --git a/assets/css/mobile.scss b/assets/css/mobile.scss index e3a50d3f3..6b816ff04 100644 --- a/assets/css/mobile.scss +++ b/assets/css/mobile.scss @@ -211,23 +211,68 @@ ol { font-size: 0.8rem; column-count: 2; column-gap: 1rem; } } - .public-home { - padding: 1.4rem 0.9rem; + .registration-mobile-cta { + height: 10vw; + width: 75%; + font-size: 1.5rem !important; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; + } + + .registration-mobile-card { + padding: 1.1rem 1.15rem; + } - .public-home__grid { grid-template-columns: 1fr !important; gap: 1.25rem; } - .public-home__title { font-size: 1.6rem; } - .public-home__links { padding: 1.2rem; } + .registration-mobile-card__text { + font-size: .7rem; + line-height: 1.7; + a { + display: inline; + white-space: normal; + word-wrap: break-word; + overflow-wrap: anywhere; + word-break: break-word; + hyphens: auto; + max-width: 100%; + } } - .public-links__tile { - grid-template-columns: auto 1fr; - gap: 0.8rem; - padding: 1rem 1.1rem; + .public-home { + padding: 0; + + .grid { + grid-template-columns: 1fr !important; + gap: 1.25rem; + } + + .links { + padding: 1.15rem 1.2rem; + border-radius: 1rem; + box-shadow: 0 16px 45px -32px rgba(15, 24, 45, 0.55); + } + + .title { + font-size: 1.6rem; + } } - .public-links__chevron { display: none; } - .public-links__body { gap: 0.25rem; } - .public-links__icon { width: 2.3rem; height: 2.3rem; } + .public-links { + .tile { + grid-template-columns: auto 1fr; + gap: 0.8rem; + padding: 1rem 1.1rem; + } + + .chevron { + display: none; + } + + .body { + gap: 0.25rem; + } + } .footer small { font-size: 0.76rem; } } @@ -277,8 +322,15 @@ } } - .public-home { padding: 1.15rem 0.75rem; } - .public-links__tile { padding: 0.85rem 0.9rem; border-left-width: 3px; } + .public-home { + padding: 0; + + .links { + padding: 1rem 1.05rem; + } + } + + .public-links .tile { padding: 0.85rem 0.9rem; border-left-width: 3px; } .footer { padding: 0.95rem 0.35rem; } .footer small { font-size: 0.74rem; } diff --git a/assets/css/public-home.scss b/assets/css/public-home.scss index 2c41b22da..6c55f5d1e 100644 --- a/assets/css/public-home.scss +++ b/assets/css/public-home.scss @@ -153,6 +153,48 @@ body { } } +.register-cta { + display: flex; + width: 100%; + border: 1px solid rgba(28, 54, 94, 0.16); + border-radius: 0.85rem; + padding: 1.5rem 2rem; + background: linear-gradient(135deg, rgba(42, 102, 196, 0.09), rgba(25, 73, 154, 0.04)); + align-items: center; + gap: 1.25rem; + + &__text { + display: flex; + flex-direction: column; + gap: 0.35rem; + color: #16243a; + + .eyebrow { + font-size: 0.85rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(18, 42, 72, 0.7); + } + + .headline { + font-size: 1.25rem; + font-weight: 700; + } + + .body { + margin-bottom: 0; + font-size: 0.95rem; + color: rgba(18, 34, 58, 0.85); + } + } + + &__btn { + white-space: nowrap; + padding: 0.85rem 1.75rem; + font-size: 1rem; + } +} + .public-links { position: relative; @include flex-column(1rem); @@ -229,6 +271,43 @@ body { } } +.registration-info-card, +.registration-mobile-card { + border: 1px solid rgba(28, 54, 94, 0.15); + border-radius: 0.65rem; + padding: 1rem; + background: rgba(42, 102, 196, 0.08); + color: #1f2f4a; + width: 100%; + box-sizing: border-box; + + &__text { + font-size: 0.75rem; + line-height: 1.65; + + a { + color: #1d68d2; + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +} + +.registration-mobile-card { + @media (min-width: 992px) { + display: none; + } +} + +.registration-mobile-cta { + margin-top: 0.65rem; + margin-bottom: 0.65rem; +} + .public-sidebar-card { @include card-shell; padding: 2rem 2.5rem; @@ -297,6 +376,45 @@ body { border-top: 1px solid rgba(0, 44, 92, 0.08); } + .registration-panel { + border: 1px solid rgba(32, 60, 105, 0.18); + border-radius: 0.75rem; + padding: 1.25rem; + background: rgba(42, 102, 196, 0.08); + text-align: left; + + &__header { + margin-bottom: 0.9rem; + + h4 { + margin: 0 0 0.35rem 0; + font-size: 1.1rem; + font-weight: 600; + color: #1f2f4a; + } + } + + &__text { + font-size: 0.92rem; + line-height: 1.65; + color: rgba(28, 45, 70, 0.92); + + a { + color: #1d68d2; + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + .btn { + margin-top: 0.5rem; + } + } + &.status { .content { gap: 1.5rem; @@ -332,8 +450,8 @@ body { } .status-icon { - width: 3.25rem; - height: 3.25rem; + width: 2.75rem; + height: 2.75rem; } } diff --git a/assets/css/registration.scss b/assets/css/registration.scss new file mode 100644 index 000000000..7c7238565 --- /dev/null +++ b/assets/css/registration.scss @@ -0,0 +1,161 @@ +.btn { + height : auto; +} + +#registration-app { + .card { + box-shadow: 0 10px 30px -24px rgba(15, 33, 55, 0.45); + border: 1px solid rgba(12, 32, 54, 0.08); + } + + .input-group-text { + font-size: 0.85rem; + padding: 0.35rem 0.6rem; + } + + .seed-select select { + min-width: 120px; + font-size: 0.85rem; + } + + .registration-info-panel { + border: 1px solid rgba(16, 46, 97, 0.18); + border-radius: 0.85rem; + overflow: hidden; + background: #f7fbff; + + &__toggle { + width: 100%; + border: 0; + background: transparent; + display: flex; + align-items: center; + justify-content: space-between; + text-align: left; + padding: 1rem 1.25rem; + cursor: pointer; + color: #12213b; + font-weight: 600; + } + + &__text { + display: flex; + flex-direction: column; + gap: 0.35rem; + + .eyebrow { + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.75rem; + color: rgba(18, 33, 59, 0.65); + } + + .headline { + font-size: 1.05rem; + font-weight: 600; + } + } + + &__chevron { + color: rgba(18, 33, 59, 0.55); + transition: transform 0.2s ease; + } + + &__toggle.is-expanded &__chevron { + transform: rotate(180deg); + } + + &__body { + border-top: 1px solid rgba(16, 46, 97, 0.12); + background: #fff; + padding: 1rem 1.25rem; + font-size: 0.95rem; + line-height: 1.6; + color: #1c2e46; + } + } + + .registration-quick-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1rem; + + &__panel { + border: 1px solid rgba(16, 38, 70, 0.15); + border-radius: 0.75rem; + padding: 1rem; + background: #fff; + box-shadow: 0 14px 32px -28px rgba(10, 18, 32, 0.55); + } + + .btn-block { + margin-top: 0.5rem; + } + } +} + +@media (max-width: 767.98px) { + #registration-app { + .form-row { + display: flex; + flex-direction: column; + } + + [class*="col-md"] { + max-width: 100%; + } + + .card { + margin-bottom: 1rem; + } + + .input-group { + flex-direction: column; + + .input-group-prepend { + width: 100%; + + .input-group-text { + width: 100%; + justify-content: flex-start; + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + } + } + + .form-control { + width: 100%; + } + } + + .seed-select { + width: 100%; + + select { + width: 100%; + } + } + + .registration-summary .card { + margin-bottom: 0; + } + + .registration-info-panel__toggle { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .registration-quick-actions { + grid-template-columns: 1fr; + } + + .registration-quick-actions__panel { + height: auto; + } + + .h-100 { + height: auto !important; + } + } +} \ No newline at end of file diff --git a/assets/js/registration/index.js b/assets/js/registration/index.js index dac8907b7..93efda1b5 100644 --- a/assets/js/registration/index.js +++ b/assets/js/registration/index.js @@ -1,3 +1,4 @@ import initRegistrationPortal from "./portal"; +import "../../css/registration.scss"; document.addEventListener("DOMContentLoaded", initRegistrationPortal); diff --git a/assets/js/registration/portal.js b/assets/js/registration/portal.js index 40295777e..6ad20faa2 100644 --- a/assets/js/registration/portal.js +++ b/assets/js/registration/portal.js @@ -1,148 +1,170 @@ const NEW = "__new__"; // Use our proxy endpoints to avoid CORS issues -const DEB_URL = id => `/registration/api/debaters/${id}/`; -const SCHOOLS_ALL_URL = "/registration/api/schools/all/"; +const DEB_URL = (id) => `/registration/api/debaters/${id}/`; +const TEAM_SEEDS = { + UNSEEDED: "0", + HALF: "2", + FULL: "3", +}; let root; -let allSchoolsLoaded = false; const queryAll = (selector, scope = root) => Array.from(scope.querySelectorAll(selector)); -const byId = value => document.getElementById(value); -const debaterName = person => +const byId = (value) => document.getElementById(value); +const debaterName = (person) => person.name || person.full_name || `${person.first_name || ""} ${person.last_name || ""}`.trim(); -const toggleNew = select => { - queryAll(`[data-new-school-container="${select.id}"]`).forEach(container => { - const show = select.value === NEW; - container.classList.toggle("d-none", !show); - const inputs = queryAll("input", container); - for (let index = 0; index < inputs.length; index += 1) { - const field = inputs[index]; - if (show) { - field.classList.remove("d-none"); - } else { - field.classList.add("d-none"); - } - } - }); +const updateDebaterMetaFields = ( + input, + container, + prefix, + debaterData = null, +) => { + const apdaIdField = byId(input.dataset.apdaTarget); + const noviceField = container.querySelector( + `input[name$="${prefix}_novice_status"]`, + ); + const qualifiedField = container.querySelector( + `input[name$="${prefix}_qualified"]`, + ); + + if (apdaIdField) { + apdaIdField.value = + (debaterData && (debaterData.apda_id || debaterData.id)) || ""; + } + if (noviceField) { + const noviceStatus = debaterData?.status === "Novice" ? "1" : "0"; + noviceField.value = noviceStatus; + } + if (qualifiedField) { + qualifiedField.value = debaterData?.apda_id ? "on" : ""; + } }; -const loadAllSchools = () => { - if (allSchoolsLoaded) { - return Promise.resolve(); +const setCollapseState = (trigger, target, expanded) => { + if (!trigger || !target) { + return; + } + trigger.setAttribute("aria-expanded", expanded ? "true" : "false"); + trigger.classList.toggle("is-expanded", expanded); + if (expanded) { + target.removeAttribute("hidden"); + } else { + target.setAttribute("hidden", ""); } - - // Update the "See more" link to show loading state - queryAll('[data-load-all-schools]').forEach(link => { - link.textContent = 'Loading...'; +}; + +const initCollapsibles = () => { + queryAll("[data-collapse-toggle]").forEach((trigger) => { + const targetId = trigger.getAttribute("aria-controls"); + if (!targetId) { + return; + } + const target = document.getElementById(targetId); + if (!target) { + return; + } + const defaultExpanded = trigger.getAttribute("aria-expanded") === "true"; + setCollapseState(trigger, target, defaultExpanded); + trigger.addEventListener("click", (event) => { + event.preventDefault(); + const expanded = trigger.getAttribute("aria-expanded") === "true"; + setCollapseState(trigger, target, !expanded); + }); }); - - return fetch(SCHOOLS_ALL_URL) - .then(response => { - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); +}; + +const toggleNew = (select) => { + queryAll(`[data-new-school-container="${select.id}"]`).forEach( + (container) => { + const show = select.value === NEW; + container.classList.toggle("d-none", !show); + const inputs = queryAll("input", container); + for (let index = 0; index < inputs.length; index += 1) { + const field = inputs[index]; + if (show) { + field.classList.remove("d-none"); + } else { + field.classList.add("d-none"); + } } - return response.json(); - }) - .then(data => { - const schools = Array.isArray(data) ? data : data.schools || []; - const schoolSelects = queryAll('[data-school-select]'); - - schoolSelects.forEach(select => { - const currentValue = select.value; - const existingValues = new Set(); - - // Collect existing school IDs - queryAll('option', select).forEach(option => { - if (option.value && option.value.startsWith('apda:')) { - existingValues.add(option.value); - } - }); - - // Add new schools - const newSchoolOption = select.querySelector(`option[value="${NEW}"]`); - schools.forEach(school => { - const value = `apda:${school.id || school.apda_id}`; - if (!existingValues.has(value)) { - const option = document.createElement('option'); - option.value = value; - option.textContent = school.name; - if (newSchoolOption) { - select.insertBefore(option, newSchoolOption); - } else { - select.appendChild(option); - } - } - }); - - // Restore selected value - select.value = currentValue; - }); - - allSchoolsLoaded = true; - - // Update the "See more" link - queryAll('[data-load-all-schools]').forEach(link => { - link.textContent = 'All schools loaded'; - link.style.pointerEvents = 'none'; - link.style.color = '#6c757d'; - }); - }) - .catch(error => { - console.error('Failed to load all schools:', error); - // Update the "See more" link to show error and allow retry - queryAll('[data-load-all-schools]').forEach(link => { - link.textContent = 'Failed to load. Click to retry.'; - link.style.color = '#dc3545'; - }); - allSchoolsLoaded = false; // Allow retry - }); + }, + ); }; -const syncDebater = input => { - const container = input.closest('[data-debater]'); + +const normalizeQualifiedValue = (value) => { + if (!value) return false; + const normalized = value.toString().toLowerCase(); + return normalized === "on" || normalized === "true" || normalized === "1"; +}; + +const updateTeamSeed = (teamForm) => { + if (!teamForm) return; + const seedSelect = teamForm.querySelector("[data-team-seed]"); + if (!seedSelect || seedSelect.dataset.autoset === "false") { + return; + } + const firstQualified = teamForm.querySelector( + 'input[name$="debater_one_qualified"]', + ); + const secondQualified = teamForm.querySelector( + 'input[name$="debater_two_qualified"]', + ); + if (!firstQualified || !secondQualified) { + return; + } + const first = normalizeQualifiedValue(firstQualified.value); + const second = normalizeQualifiedValue(secondQualified.value); + if (first && second) { + seedSelect.value = TEAM_SEEDS.FULL; + } else if (first || second) { + seedSelect.value = TEAM_SEEDS.HALF; + } else { + seedSelect.value = TEAM_SEEDS.UNSEEDED; + } +}; + +const ensureSeedSelectState = (teamForm) => { + const seedSelect = teamForm.querySelector("[data-team-seed]"); + if (!seedSelect) { + return; + } + if (!seedSelect.dataset.autoset) { + const teamIdField = teamForm.querySelector('input[name$="team_id"]'); + const hasExistingTeam = teamIdField && teamIdField.value; + seedSelect.dataset.autoset = hasExistingTeam ? "false" : "true"; + } + if (seedSelect.dataset.autoset === "true") { + updateTeamSeed(teamForm); + } +}; + +const syncDebater = (input) => { + const container = input.closest("[data-debater]"); if (!container) return; - + const prefix = input.dataset.debaterInput; if (!prefix) return; - + // Find the matching option in the select itself const selectedOption = input.selectedOptions && input.selectedOptions[0]; - + if (selectedOption && selectedOption.dataset.debater) { // Parse the debater data stored in the option try { const debaterData = JSON.parse(selectedOption.dataset.debater); - - // Populate all the hidden fields - const apdaIdField = byId(input.dataset.apdaTarget); - const noviceField = container.querySelector(`input[name$="${prefix}_novice_status"]`); - const qualifiedField = container.querySelector(`input[name$="${prefix}_qualified"]`); - - if (apdaIdField) { - apdaIdField.value = debaterData.apda_id || debaterData.id || ''; - } - if (noviceField) { - // Convert status to novice value: "Novice" = 1, anything else = 0 - noviceField.value = debaterData.status === 'Novice' ? '1' : '0'; - } - if (qualifiedField) { - // Assume qualified if they have an APDA ID - qualifiedField.value = debaterData.apda_id ? 'on' : ''; - } - } catch (e) { - console.error('Error parsing debater data:', e); + updateDebaterMetaFields(input, container, prefix, debaterData); + } catch (_error) { + updateDebaterMetaFields(input, container, prefix); } } else { - // Clear the fields if no match - const apdaIdField = byId(input.dataset.apdaTarget); - const noviceField = container.querySelector(`input[name$="${prefix}_novice_status"]`); - const qualifiedField = container.querySelector(`input[name$="${prefix}_qualified"]`); - - if (apdaIdField) apdaIdField.value = ''; - if (noviceField) noviceField.value = '0'; - if (qualifiedField) qualifiedField.value = ''; + updateDebaterMetaFields(input, container, prefix); + } + const teamForm = input.closest('[data-form="team"]'); + if (teamForm) { + ensureSeedSelectState(teamForm); } }; const clearDebaterList = (list, selectElement) => { @@ -157,48 +179,44 @@ const clearDebaterList = (list, selectElement) => { } }; -const updateDebaters = select => { - const listId = select.dataset.listId; - const nameId = select.dataset.nameId; - - console.log('updateDebaters called', { listId, nameId, selectValue: select.value }); - - // Get or create the datalist element +const updateDebaters = (select) => { + const { listId, nameId } = select.dataset; + if (!listId || !nameId) { + return; + } + let list = byId(listId); if (!list) { - console.log('Creating datalist with id:', listId); - list = document.createElement('datalist'); + list = document.createElement("datalist"); list.id = listId; - list.style.display = 'none'; + list.style.display = "none"; document.body.appendChild(list); } - + const selectElement = byId(nameId); - - console.log('Found elements:', { list: !!list, selectElement: !!selectElement }); - if (!selectElement) { - console.log('Missing select element'); return; } - + // Handle custom schools - they only have manually created debaters - if (select.value && select.value.startsWith('custom:')) { + if (select.value && select.value.startsWith("custom:")) { const selectedOption = select.selectedOptions && select.selectedOptions[0]; if (selectedOption) { const schoolName = selectedOption.textContent; // Find the associated school_name hidden field - const schoolNameField = select.closest('[data-debater]')?.querySelector(`input[name$="_school_name"]`); + const schoolNameField = select + .closest("[data-debater]") + ?.querySelector(`input[name$="_school_name"]`); if (schoolNameField) { schoolNameField.value = schoolName; } } - + // Clear and add any custom debaters for this school selectElement.innerHTML = ''; const schoolValue = select.value; if (window.customDebaters && window.customDebaters[schoolValue]) { - window.customDebaters[schoolValue].forEach(debater => { + window.customDebaters[schoolValue].forEach((debater) => { const option = document.createElement("option"); option.value = `custom:${debater.id}`; option.textContent = debater.name; @@ -211,7 +229,7 @@ const updateDebaters = select => { } return; } - + if (!select.value.startsWith("apda:")) { clearDebaterList(list, selectElement); return; @@ -221,51 +239,47 @@ const updateDebaters = select => { clearDebaterList(list, selectElement); return; } - + // Disable select while loading selectElement.disabled = true; selectElement.innerHTML = ''; - + fetch(DEB_URL(apdaId)) - .then(response => (response.ok ? response.json() : { debaters: [] })) - .then(data => { + .then((response) => (response.ok ? response.json() : { debaters: [] })) + .then((data) => { const entries = Array.isArray(data) ? data : data.debaters || []; - - console.log('Loaded debaters:', entries.length); - - // Clear the select and add options selectElement.innerHTML = ""; - + // Add an empty option first const emptyOption = document.createElement("option"); emptyOption.value = ""; emptyOption.textContent = "Select a debater"; selectElement.appendChild(emptyOption); - + // Add debater options from API - entries.forEach(person => { + entries.forEach((person) => { const option = document.createElement("option"); const name = debaterName(person); option.value = name; option.textContent = name; - + // Store the full debater data in the option option.dataset.debater = JSON.stringify({ id: person.id || person.apda_id, apda_id: person.apda_id || person.id, - name: name, + name, first_name: person.first_name, last_name: person.last_name, - status: person.status + status: person.status, }); - + selectElement.appendChild(option); }); - + // Add custom debaters for this school if any exist const schoolValue = select.value; if (window.customDebaters && window.customDebaters[schoolValue]) { - window.customDebaters[schoolValue].forEach(debater => { + window.customDebaters[schoolValue].forEach((debater) => { const option = document.createElement("option"); option.value = `custom:${debater.id}`; option.textContent = debater.name; @@ -273,35 +287,34 @@ const updateDebaters = select => { selectElement.appendChild(option); }); } - - // Also store in the datalist for reference (though we don't use it visually) + + // Also store in the datalist for reference, even though it is hidden list.innerHTML = ""; - entries.forEach(person => { + entries.forEach((person) => { const option = document.createElement("option"); const name = debaterName(person); option.value = name; option.dataset.debater = JSON.stringify({ id: person.id || person.apda_id, apda_id: person.apda_id || person.id, - name: name, + name, first_name: person.first_name, last_name: person.last_name, - status: person.status + status: person.status, }); list.appendChild(option); }); - + // Enable the select selectElement.disabled = false; - - console.log('Debaters loaded successfully'); - // Trigger sync to populate hidden fields if there's a pre-selected value syncDebater(selectElement); }) - .catch((error) => { - console.error('Error loading debaters:', error); - clearDebaterList(list, selectElement); + .catch(() => { + list.innerHTML = ""; + selectElement.innerHTML = + ''; + selectElement.disabled = true; }); }; const addForm = (type, maxTeams) => { @@ -318,49 +331,60 @@ const addForm = (type, maxTeams) => { const wrapper = document.createElement("div"); wrapper.innerHTML = template.innerHTML.replace(/__prefix__/g, String(total)); const element = wrapper.firstElementChild; - + // Also replace __prefix__ in data attributes - queryAll('[data-name-id*="__prefix__"]', element).forEach(el => { - el.dataset.nameId = el.dataset.nameId.replace(/__prefix__/g, String(total)); + queryAll('[data-name-id*="__prefix__"]', element).forEach((el) => { + const elementWithNameId = el; + elementWithNameId.dataset.nameId = elementWithNameId.dataset.nameId.replace( + /__prefix__/g, + String(total), + ); }); - queryAll('[data-list-id*="__prefix__"]', element).forEach(el => { - el.dataset.listId = el.dataset.listId.replace(/__prefix__/g, String(total)); + queryAll('[data-list-id*="__prefix__"]', element).forEach((el) => { + const elementWithListId = el; + elementWithListId.dataset.listId = elementWithListId.dataset.listId.replace( + /__prefix__/g, + String(total), + ); }); - + root.querySelector(`[data-formset-container="${type}"]`).appendChild(element); totalInput.value = total + 1; - - // If it's a team form, populate ALL school selects (including debater schools) from main registration school + + // If it's a team form, populate all related school selects, including debater + // selects, from the main registration school choices. if (type === "team") { - const mainSchoolSelect = root.querySelector('[data-school-select="registration"]'); - - // First, copy all options from main school select to all new school selects in the team form - const allSchoolSelects = queryAll('[data-school-select]', element); - allSchoolSelects.forEach(select => { - // Copy options from main school select + const mainSchoolSelect = root.querySelector( + '[data-school-select="registration"]', + ); + + // First, copy all options from the main school select into the new team + // form instance. + const allSchoolSelects = queryAll("[data-school-select]", element); + allSchoolSelects.forEach((selectEl) => { + const schoolSelect = selectEl; if (mainSchoolSelect) { - // Clear existing options first - select.innerHTML = ''; - - // Clone all options from main school select - queryAll('option', mainSchoolSelect).forEach(option => { + schoolSelect.innerHTML = ""; + queryAll("option", mainSchoolSelect).forEach((option) => { const newOption = option.cloneNode(true); - select.appendChild(newOption); + schoolSelect.appendChild(newOption); }); } - - // Now set the value if main school is selected - if (mainSchoolSelect && mainSchoolSelect.value && mainSchoolSelect.value !== NEW && mainSchoolSelect.value !== "") { - select.value = mainSchoolSelect.value; - toggleNew(select); - // Only update debaters if it's a team school select (has listId) - if (select.dataset.listId) { - updateDebaters(select); + + if ( + mainSchoolSelect && + mainSchoolSelect.value && + mainSchoolSelect.value !== NEW && + mainSchoolSelect.value !== "" + ) { + schoolSelect.value = mainSchoolSelect.value; + toggleNew(schoolSelect); + if (schoolSelect.dataset.listId) { + updateDebaters(schoolSelect); } } else { - toggleNew(select); - const listId = select.dataset.listId; - const nameId = select.dataset.nameId; + toggleNew(schoolSelect); + const { listId, nameId } = schoolSelect.dataset; if (listId && nameId) { const input = byId(nameId); if (input) { @@ -371,13 +395,20 @@ const addForm = (type, maxTeams) => { } }); } - - queryAll("[data-debater-input]", element).forEach(field => { + + queryAll("[data-debater-input]", element).forEach((fieldEl) => { + const field = fieldEl; // Debater fields should now be select elements - const debaterContainer = field.closest('[data-debater]'); - const schoolSelect = debaterContainer ? debaterContainer.querySelector('[data-school-select]') : null; - - if (!schoolSelect || !schoolSelect.value || !schoolSelect.value.startsWith("apda:")) { + const debaterContainer = field.closest("[data-debater]"); + const schoolSelect = debaterContainer + ? debaterContainer.querySelector("[data-school-select]") + : null; + + if ( + !schoolSelect || + !schoolSelect.value || + !schoolSelect.value.startsWith("apda:") + ) { field.innerHTML = ''; field.disabled = true; } else { @@ -385,6 +416,9 @@ const addForm = (type, maxTeams) => { syncDebater(field); } }); + if (type === "team") { + ensureSeedSelectState(element); + } }; export default function initRegistrationPortal() { root = document.getElementById("registration-app"); @@ -392,99 +426,116 @@ export default function initRegistrationPortal() { return; } const maxTeams = parseInt(root.dataset.maxTeams || "200", 10); - + initCollapsibles(); + // Initialize existing school selects - queryAll("[data-school-select]").forEach(select => { + queryAll("[data-school-select]").forEach((selectEl) => { + const select = selectEl; toggleNew(select); updateDebaters(select); }); - + // Initialize existing debater selects - disable if no school selected - queryAll("[data-debater-input]").forEach(field => { - const schoolSelectId = field.closest('[data-debater]')?.querySelector('[data-school-select]')?.id; + queryAll("[data-debater-input]").forEach((fieldEl) => { + const field = fieldEl; + const schoolSelectId = field + .closest("[data-debater]") + ?.querySelector("[data-school-select]")?.id; if (schoolSelectId) { const schoolSelect = byId(schoolSelectId); - if (!schoolSelect || !schoolSelect.value || !schoolSelect.value.startsWith("apda:")) { + if ( + !schoolSelect || + !schoolSelect.value || + !schoolSelect.value.startsWith("apda:") + ) { field.innerHTML = ''; field.disabled = true; } } }); - + queryAll('[data-form="team"]').forEach((teamForm) => { + ensureSeedSelectState(teamForm); + }); + // Enable/disable Add Team button based on main school selection const updateAddTeamButton = () => { - const mainSchoolSelect = root.querySelector('[data-school-select="registration"]'); + const mainSchoolSelect = root.querySelector( + '[data-school-select="registration"]', + ); const addTeamButton = root.querySelector('[data-add-form="team"]'); if (addTeamButton) { - if (mainSchoolSelect && mainSchoolSelect.value && mainSchoolSelect.value !== "") { + if ( + mainSchoolSelect && + mainSchoolSelect.value && + mainSchoolSelect.value !== "" + ) { addTeamButton.disabled = false; } else { addTeamButton.disabled = true; } } }; - + updateAddTeamButton(); - - root.addEventListener("change", event => { + + root.addEventListener("change", (event) => { const { target } = event; - + // Handle debater select change if (target.matches("[data-debater-input]")) { syncDebater(target); return; } - + if (target.matches("[data-school-select]")) { toggleNew(target); updateDebaters(target); - - // Update Add Team button and propagate school to all teams when main school changes + + // Update the Add Team button and propagate the selection when the main + // school dropdown changes. if (target.matches('[data-school-select="registration"]')) { updateAddTeamButton(); - - // Propagate main school options and value to all team school selects - queryAll('[data-form="team"]').forEach(teamForm => { - queryAll('[data-school-select]', teamForm).forEach(schoolSelect => { - // First, sync the options from main school select - schoolSelect.innerHTML = ''; - queryAll('option', target).forEach(option => { + + // Propagate main school options and value to every team school select + queryAll('[data-form="team"]').forEach((teamForm) => { + queryAll("[data-school-select]", teamForm).forEach((selectEl) => { + const schoolSelect = selectEl; + schoolSelect.innerHTML = ""; + queryAll("option", target).forEach((option) => { const newOption = option.cloneNode(true); schoolSelect.appendChild(newOption); }); - - // Then update value if the school select doesn't already have a different value - if (!schoolSelect.value || schoolSelect.value === "") { - if (target.value && target.value !== NEW && target.value !== "") { - schoolSelect.value = target.value; - toggleNew(schoolSelect); - if (schoolSelect.dataset.listId) { - updateDebaters(schoolSelect); - } + + if ( + (!schoolSelect.value || schoolSelect.value === "") && + target.value && + target.value !== NEW && + target.value !== "" + ) { + schoolSelect.value = target.value; + toggleNew(schoolSelect); + if (schoolSelect.dataset.listId) { + updateDebaters(schoolSelect); } } }); }); } } + if (target.matches("[data-team-seed]")) { + target.dataset.autoset = "false"; + } }); root.addEventListener( "input", - event => - event.target.matches("[data-debater-input]") && syncDebater(event.target) + (event) => + event.target.matches("[data-debater-input]") && syncDebater(event.target), ); - root.addEventListener("click", event => { - // Handle "See more schools" link - if (event.target.matches('[data-load-all-schools]')) { - event.preventDefault(); - loadAllSchools(); - return; - } - + root.addEventListener("click", (event) => { // Handle "Create New School" button - if (event.target.matches('[data-trigger-new]')) { + if (event.target.matches("[data-trigger-new]")) { event.preventDefault(); - const selectId = event.target.getAttribute('data-trigger-new'); + const selectId = event.target.getAttribute("data-trigger-new"); const select = document.getElementById(selectId); if (select) { select.value = NEW; @@ -492,7 +543,7 @@ export default function initRegistrationPortal() { } return; } - + const addType = event.target.getAttribute("data-add-form"); if (addType) { event.preventDefault(); @@ -515,132 +566,136 @@ export default function initRegistrationPortal() { form.classList.add("d-none"); }); - // Store for custom schools and debaters (make global for access in updateDebaters) + // Store custom schools and debaters globally for reuse inside + // updateDebaters. window.customSchools = []; window.customDebaters = {}; // Create new school handler - const createSchoolBtn = byId('create-school-btn'); - const newSchoolNameInput = byId('new-school-name'); - + const createSchoolBtn = byId("create-school-btn"); + const newSchoolNameInput = byId("new-school-name"); + if (createSchoolBtn && newSchoolNameInput) { - createSchoolBtn.addEventListener('click', () => { + createSchoolBtn.addEventListener("click", () => { const schoolName = newSchoolNameInput.value.trim(); if (!schoolName) { - alert('Please enter a school name'); + alert("Please enter a school name"); return; } - + // Create a custom school with id = -1 (or negative incrementing IDs) const customId = -1 - window.customSchools.length; const schoolValue = `custom:${customId}`; const school = { id: customId, name: schoolName, - value: schoolValue + value: schoolValue, }; - + window.customSchools.push(school); - + // Add to all school dropdowns - queryAll('[data-school-select]').forEach(select => { - const option = document.createElement('option'); + queryAll("[data-school-select]").forEach((selectEl) => { + const select = selectEl; + const option = document.createElement("option"); option.value = schoolValue; option.textContent = schoolName; - option.dataset.schoolName = schoolName; // Store name for form submission + // Store name for form submission + option.dataset.schoolName = schoolName; select.appendChild(option); }); - + // Clear the input - newSchoolNameInput.value = ''; - + newSchoolNameInput.value = ""; + // Show success message alert(`School "${schoolName}" added successfully!`); }); } // Create new debater handler - const createDebaterBtn = byId('create-debater-btn'); - const newDebaterSchool = byId('new-debater-school'); - const newDebaterName = byId('new-debater-name'); - const newDebaterStatus = byId('new-debater-status'); - - if (createDebaterBtn && newDebaterSchool && newDebaterName && newDebaterStatus) { - createDebaterBtn.addEventListener('click', () => { + const createDebaterBtn = byId("create-debater-btn"); + const newDebaterSchool = byId("new-debater-school"); + const newDebaterName = byId("new-debater-name"); + const newDebaterStatus = byId("new-debater-status"); + + if ( + createDebaterBtn && + newDebaterSchool && + newDebaterName && + newDebaterStatus + ) { + createDebaterBtn.addEventListener("click", () => { const schoolValue = newDebaterSchool.value; - const debaterName = newDebaterName.value.trim(); + const customDebaterName = newDebaterName.value.trim(); const noviceStatus = newDebaterStatus.value; - + if (!schoolValue) { - alert('Please select a school'); + alert("Please select a school"); return; } - if (!debaterName) { - alert('Please enter a debater name'); + if (!customDebaterName) { + alert("Please enter a debater name"); return; } - - // Extract school ID - let schoolId; - if (schoolValue.startsWith('apda:')) { - schoolId = schoolValue.split(':')[1]; - } else if (schoolValue.startsWith('custom:')) { - schoolId = schoolValue.split(':')[1]; - } else if (schoolValue.startsWith('pk:')) { - schoolId = schoolValue.split(':')[1]; - } else { - alert('Invalid school selection'); + + // Ensure the school selection is valid before proceeding + const [schoolPrefix, schoolIdValue] = schoolValue.split(":"); + if (!schoolIdValue || !["apda", "custom", "pk"].includes(schoolPrefix)) { + alert("Invalid school selection"); return; } - + // Create custom debater with id = -1 const customId = -1; const debater = { id: customId, apda_id: customId, - name: debaterName, - full_name: debaterName, - status: noviceStatus === '1' ? 'Novice' : 'Varsity', - qualified: false + name: customDebaterName, + full_name: customDebaterName, + status: noviceStatus === "1" ? "Novice" : "Varsity", + qualified: false, }; - + // Store debater for this school if (!window.customDebaters[schoolValue]) { window.customDebaters[schoolValue] = []; } window.customDebaters[schoolValue].push(debater); - + // Add to any active debater dropdowns for this school - queryAll('[data-school-select]').forEach(schoolSelect => { + queryAll("[data-school-select]").forEach((selectEl) => { + const schoolSelect = selectEl; if (schoolSelect.value === schoolValue) { // Find associated debater select - const nameId = schoolSelect.dataset.nameId; + const { nameId } = schoolSelect.dataset; if (nameId) { const debaterSelect = byId(nameId); if (debaterSelect) { - const option = document.createElement('option'); + const option = document.createElement("option"); option.value = `custom:${customId}`; - option.textContent = debaterName; + option.textContent = customDebaterName; option.dataset.debater = JSON.stringify(debater); debaterSelect.appendChild(option); - + // Enable the select if it was disabled if (debaterSelect.disabled) { debaterSelect.disabled = false; - debaterSelect.innerHTML = ''; + debaterSelect.innerHTML = + ''; debaterSelect.appendChild(option); } } } } }); - + // Clear the inputs - newDebaterName.value = ''; - newDebaterStatus.value = '0'; - + newDebaterName.value = ""; + newDebaterStatus.value = "0"; + // Show success message - alert(`Debater "${debaterName}" added successfully!`); + alert(`Debater "${customDebaterName}" added successfully!`); }); } @@ -650,28 +705,27 @@ export default function initRegistrationPortal() { const syncNewDebaterSchools = () => { const currentValue = newDebaterSchool.value; newDebaterSchool.innerHTML = ''; - - queryAll('option', mainSchoolSelect).forEach(opt => { - if (opt.value && opt.value !== '') { - const option = document.createElement('option'); + + queryAll("option", mainSchoolSelect).forEach((opt) => { + if (opt.value && opt.value !== "") { + const option = document.createElement("option"); option.value = opt.value; option.textContent = opt.textContent; newDebaterSchool.appendChild(option); } }); - + // Restore value if still available if (currentValue) { newDebaterSchool.value = currentValue; } }; - + // Sync on load syncNewDebaterSchools(); - + // Re-sync when schools are added const observer = new MutationObserver(syncNewDebaterSchools); observer.observe(mainSchoolSelect, { childList: true }); } } - diff --git a/mittab/apps/registration/admin.py b/mittab/apps/registration/admin.py index 3e0c931b1..0e3c85320 100644 --- a/mittab/apps/registration/admin.py +++ b/mittab/apps/registration/admin.py @@ -4,6 +4,7 @@ RegistrationConfig, Registration, RegistrationJudge, + RegistrationContent, RegistrationTeam, RegistrationTeamMember, ) @@ -33,7 +34,12 @@ class RegistrationAdmin(admin.ModelAdmin): @admin.register(RegistrationConfig) class RegistrationConfigAdmin(admin.ModelAdmin): - list_display = ("registration_open", "registration_close", "tournament_start") + list_display = ("allow_new_registrations", "allow_registration_edits", "updated_at") + + +@admin.register(RegistrationContent) +class RegistrationContentAdmin(admin.ModelAdmin): + list_display = ("updated_at",) @admin.register(RegistrationTeam) diff --git a/mittab/apps/registration/forms.py b/mittab/apps/registration/forms.py index eebb73c04..46d051143 100644 --- a/mittab/apps/registration/forms.py +++ b/mittab/apps/registration/forms.py @@ -1,5 +1,8 @@ from django import forms +from mittab.apps.registration.models import RegistrationConfig, RegistrationContent +from mittab.apps.tab.models import Team + NEW_CHOICE_VALUE = "__new__" NOVICE_CHOICES = ((0, "Varsity"), (1, "Novice")) @@ -47,14 +50,18 @@ def __init__(self, *args, school_choices=None, **kwargs): final = [("", "Select school")] + choices self.fields["school"].widget = forms.Select(choices=final) self.choice_map = dict(choices) - self.fields["school"].widget.attrs.update({ - "class": "form-control", - "data-school-select": "registration", - }) - self.fields["school_name"].widget.attrs.update({ - "class": "form-control d-none", - "placeholder": "School name", - }) + self.fields["school"].widget.attrs.update( + { + "class": "form-control", + "data-school-select": "registration", + } + ) + self.fields["school_name"].widget.attrs.update( + { + "class": "form-control d-none", + "placeholder": "School name", + } + ) self.fields["school_name"].widget.attrs.setdefault( "data-related-select", self.fields["school"].widget.attrs.get("id") ) @@ -84,17 +91,30 @@ class TeamForm(forms.Form): team_id = forms.IntegerField(required=False, widget=forms.HiddenInput) name = forms.CharField(max_length=30) is_free_seed = forms.BooleanField(required=False) + seed_choice = forms.TypedChoiceField( + choices=[ + (Team.UNSEEDED, "Unseeded"), + (Team.HALF_SEED, "Half Seed"), + (Team.FULL_SEED, "Full Seed"), + ], + coerce=int, + initial=Team.UNSEEDED, + ) debater_one_id = forms.IntegerField(required=False, widget=forms.HiddenInput) debater_one_name = forms.CharField(max_length=30) debater_one_apda_id = forms.IntegerField(required=False, widget=forms.HiddenInput) - debater_one_novice_status = forms.IntegerField(required=False, widget=forms.HiddenInput) + debater_one_novice_status = forms.IntegerField( + required=False, widget=forms.HiddenInput + ) debater_one_qualified = forms.BooleanField(required=False, widget=forms.HiddenInput) debater_one_school = forms.CharField() debater_one_school_name = forms.CharField(required=False, max_length=50) debater_two_id = forms.IntegerField(required=False, widget=forms.HiddenInput) debater_two_name = forms.CharField(max_length=30) debater_two_apda_id = forms.IntegerField(required=False, widget=forms.HiddenInput) - debater_two_novice_status = forms.IntegerField(required=False, widget=forms.HiddenInput) + debater_two_novice_status = forms.IntegerField( + required=False, widget=forms.HiddenInput + ) debater_two_qualified = forms.BooleanField(required=False, widget=forms.HiddenInput) debater_two_school = forms.CharField() debater_two_school_name = forms.CharField(required=False, max_length=50) @@ -110,17 +130,31 @@ def __init__(self, *args, school_choices=None, **kwargs): ] for field in control_fields: self.fields[field].widget.attrs.setdefault("class", "form-control") - + self.fields["seed_choice"].widget.attrs.update( + { + "class": "form-control form-control-sm", + "data-team-seed": "true", + } + ) + # Configure debater name fields as Select widgets - self.fields["debater_one_name"].widget = forms.Select(choices=[("", "Select a school first")]) - self.fields["debater_one_name"].widget.attrs.update({ - "class": "form-control", - }) - self.fields["debater_two_name"].widget = forms.Select(choices=[("", "Select a school first")]) - self.fields["debater_two_name"].widget.attrs.update({ - "class": "form-control", - }) - + self.fields["debater_one_name"].widget = forms.Select( + choices=[("", "Select a school first")] + ) + self.fields["debater_one_name"].widget.attrs.update( + { + "class": "form-control", + } + ) + self.fields["debater_two_name"].widget = forms.Select( + choices=[("", "Select a school first")] + ) + self.fields["debater_two_name"].widget.attrs.update( + { + "class": "form-control", + } + ) + self.fields["is_free_seed"].widget.attrs.setdefault("class", "form-check-input") self.fields["debater_one_qualified"].widget.attrs.setdefault("value", "") self.fields["debater_two_qualified"].widget.attrs.setdefault("value", "") @@ -188,6 +222,7 @@ def get_payload(self): "team_id": self.cleaned_data.get("team_id"), "name": self.cleaned_data["name"], "is_free_seed": bool(self.cleaned_data.get("is_free_seed")), + "seed_choice": int(self.cleaned_data["seed_choice"]), "members": self.get_members(), } @@ -200,23 +235,33 @@ def _configure_school_field(self, field_name, current, base_choices, name_widget field = self.fields[field_name] choices_list = list(base_choices) if current and current not in [value for value, _ in choices_list]: - label = self._current_value(field_name.replace("_school", "_school_name")) or self.choice_map.get(current) or "Selected School" + label = ( + self._current_value(field_name.replace("_school", "_school_name")) + or self.choice_map.get(current) + or "Selected School" + ) choices_list.insert(1, (current, label)) # Don't add "Add New School" option - we have a dedicated form for that field.widget = forms.Select(choices=choices_list) - field.widget.attrs.update({ - "class": "form-control", - "data-school-select": "team", - }) + field.widget.attrs.update( + { + "class": "form-control", + "data-school-select": "team", + } + ) list_id = f"{self.prefix}-{field_name.replace('_school', '')}-options" field.widget.attrs["data-list-id"] = list_id name_id = self[field_name.replace("_school", "_name")].auto_id field.widget.attrs["data-name-id"] = name_id - name_widget.attrs.update({ - "class": "form-control mt-2 d-none", - "placeholder": "School name", - }) - name_widget.attrs.setdefault("data-related-select", field.widget.attrs.get("id")) + name_widget.attrs.update( + { + "class": "form-control mt-2 d-none", + "placeholder": "School name", + } + ) + name_widget.attrs.setdefault( + "data-related-select", field.widget.attrs.get("id") + ) if current == NEW_CHOICE_VALUE: self._reveal_input(name_widget) @@ -225,14 +270,16 @@ def _configure_debater_inputs(self, prefix): apda_field = f"{prefix}_apda_id" list_id = f"{self.prefix}-{prefix.replace('_', '-')}-options" name_widget = self.fields[name_field].widget - # For Select widget, we don't need autocomplete or list attributes for functionality, - # but we set them so the template can access the list ID - name_widget.attrs.update({ - "data-debater-input": prefix, - "data-apda-target": self[apda_field].auto_id, - "data-list": list_id, - "list": list_id, # Also set without data- prefix for template access - }) + # For select widgets we do not need autocomplete or list attributes for + # functionality, but we set them so the template can access the list ID. + name_widget.attrs.update( + { + "data-debater-input": prefix, + "data-apda-target": self[apda_field].auto_id, + "data-list": list_id, + "list": list_id, # Also set without data- prefix for template access + } + ) self.fields[apda_field].widget.attrs.setdefault("id", self[apda_field].auto_id) def _reveal_input(self, widget): @@ -246,25 +293,109 @@ class JudgeForm(forms.Form): registration_judge_id = forms.IntegerField(required=False, widget=forms.HiddenInput) judge_id = forms.IntegerField(required=False, widget=forms.HiddenInput) name = forms.CharField(max_length=30) + email = forms.EmailField(max_length=254) experience = forms.IntegerField( min_value=0, max_value=10, - widget=forms.NumberInput(attrs={'min': '0', 'max': '10', 'step': '1'}) + widget=forms.NumberInput(attrs={"min": "0", "max": "10", "step": "1"}), ) DELETE = forms.BooleanField(required=False, widget=forms.HiddenInput) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["name"].widget.attrs.setdefault("class", "form-control") - self.fields["experience"].widget.attrs.update({ - "class": "form-control", - "placeholder": "0-10" - }) + self.fields["email"].widget.attrs.update( + { + "class": "form-control", + "placeholder": "Email", + } + ) + self.fields["experience"].widget.attrs.update( + {"class": "form-control", "placeholder": "0-10"} + ) def get_payload(self): return { "registration_judge_id": self.cleaned_data.get("registration_judge_id"), "judge_id": self.cleaned_data.get("judge_id"), "name": self.cleaned_data["name"], + "email": self.cleaned_data["email"], "experience": self.cleaned_data["experience"], } + + +class RegistrationSettingsForm(forms.Form): + allow_new_registrations = forms.BooleanField( + label="Allow New Registrations", + required=False, + help_text="Toggle whether schools can start a brand new registration.", + widget=forms.CheckboxInput(attrs={"class": "custom-control-input"}), + ) + allow_registration_edits = forms.BooleanField( + label="Allow Registration Updates", + required=False, + help_text="Controls whether existing registration links can modify their data.", + widget=forms.CheckboxInput(attrs={"class": "custom-control-input"}), + ) + registration_description = forms.CharField( + label="Homepage Registration Description", + required=False, + widget=forms.Textarea( + attrs={ + "class": "form-control", + "rows": 4, + "placeholder": ( + "Describe how teams should register " + "(links supported)." + ), + } + ), + help_text=( + "Shown on the public homepage when new registrations are enabled." + ), + ) + registration_completion_message = forms.CharField( + label="Post-Registration Instructions", + required=False, + widget=forms.Textarea( + attrs={ + "class": "form-control", + "rows": 4, + "placeholder": ( + "Provide follow-up instructions after submission " + "(links supported)." + ), + } + ), + help_text=( + "Displayed inside the registration portal once a school submits " + "their entry." + ), + ) + + def __init__(self, *args, config=None, content=None, **kwargs): + self.config = config or RegistrationConfig.get_or_create_active() + self.content = content or RegistrationContent.get_solo() + initial = { + "allow_new_registrations": self.config.allow_new_registrations, + "allow_registration_edits": self.config.allow_registration_edits, + "registration_description": self.content.description, + "registration_completion_message": self.content.completion_message, + } + kwargs.setdefault("initial", initial) + super().__init__(*args, **kwargs) + + def save(self): + self.config.allow_new_registrations = self.cleaned_data[ + "allow_new_registrations" + ] + self.config.allow_registration_edits = self.cleaned_data[ + "allow_registration_edits" + ] + self.config.save() + self.content.description = self.cleaned_data["registration_description"] + self.content.completion_message = self.cleaned_data[ + "registration_completion_message" + ] + self.content.save() + return self.config, self.content diff --git a/mittab/apps/registration/migrations/0001_initial.py b/mittab/apps/registration/migrations/0001_initial.py index 8b2db3e2e..4c8d5ee59 100644 --- a/mittab/apps/registration/migrations/0001_initial.py +++ b/mittab/apps/registration/migrations/0001_initial.py @@ -1,61 +1,73 @@ +# Generated by Django 3.2.25 on 2025-11-10 21:50 + from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): + initial = True dependencies = [ - ("tab", "0025_alter_tabsettings_key"), + ('tab', '0032_auto_20251110_2150'), ] operations = [ migrations.CreateModel( - name="RegistrationConfig", + name='Registration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('herokunator_code', models.CharField(blank=True, max_length=255, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('school', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tab.school')), + ], + ), + migrations.CreateModel( + name='RegistrationConfig', fields=[ - ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("registration_open", models.DateField(blank=True, null=True)), - ("registration_close", models.DateField(blank=True, null=True)), - ("tournament_start", models.DateField(blank=True, null=True)), - ("extra_information", models.TextField(blank=True)), - ("updated_at", models.DateTimeField(auto_now=True)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('allow_new_registrations', models.BooleanField(default=True)), + ('allow_registration_edits', models.BooleanField(default=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ], ), migrations.CreateModel( - name="Registration", + name='RegistrationContent', fields=[ - ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("email", models.EmailField(max_length=254)), - ("herokunator_code", models.CharField(blank=True, max_length=255, unique=True)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("school", models.ForeignKey(on_delete=models.deletion.CASCADE, to="tab.school")), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField(blank=True)), + ('completion_message', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ], ), migrations.CreateModel( - name="RegistrationTeam", + name='RegistrationTeam', fields=[ - ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("is_free_seed", models.BooleanField(default=False)), - ("registration", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="teams", to="registration.registration")), - ("team", models.ForeignKey(on_delete=models.deletion.CASCADE, to="tab.team")), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_free_seed', models.BooleanField(default=False)), + ('registration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='registration.registration')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tab.team')), ], ), migrations.CreateModel( - name="RegistrationTeamMember", + name='RegistrationTeamMember', fields=[ - ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("position", models.IntegerField(default=0)), - ("debater", models.ForeignKey(on_delete=models.deletion.CASCADE, to="tab.debater")), - ("registration_team", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="members", to="registration.registrationteam")), - ("school", models.ForeignKey(on_delete=models.deletion.CASCADE, to="tab.school")), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('position', models.IntegerField(default=0)), + ('debater', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tab.debater')), + ('registration_team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='registration.registrationteam')), + ('school', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tab.school')), ], ), migrations.CreateModel( - name="RegistrationJudge", + name='RegistrationJudge', fields=[ - ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("judge", models.ForeignKey(on_delete=models.deletion.CASCADE, to="tab.judge")), - ("registration", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="judges", to="registration.registration")), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tab.judge')), + ('registration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='judges', to='registration.registration')), ], ), ] diff --git a/mittab/apps/registration/models.py b/mittab/apps/registration/models.py index be0361f4b..01477978f 100644 --- a/mittab/apps/registration/models.py +++ b/mittab/apps/registration/models.py @@ -1,28 +1,46 @@ from haikunator import Haikunator from django.db import models -from django.utils import timezone - from mittab.apps.tab.models import School, Team, Judge, Debater class RegistrationConfig(models.Model): - registration_open = models.DateField(null=True, blank=True) - registration_close = models.DateField(null=True, blank=True) - tournament_start = models.DateField(null=True, blank=True) - extra_information = models.TextField(blank=True) + allow_new_registrations = models.BooleanField(default=True) + allow_registration_edits = models.BooleanField(default=True) updated_at = models.DateTimeField(auto_now=True) @classmethod def get_active(cls): return cls.objects.first() - def is_open(self): - today = timezone.now().date() - if self.registration_open and today < self.registration_open: - return False - if self.registration_close and today > self.registration_close: - return False - return True + @classmethod + def get_or_create_active(cls): + config = cls.get_active() + if config: + return config + return cls.objects.create() + + def can_create(self): + return self.allow_new_registrations + + def can_modify(self): + return self.allow_registration_edits + + +class RegistrationContent(models.Model): + description = models.TextField(blank=True) + completion_message = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + @classmethod + def get_solo(cls): + instance = cls.objects.first() + if instance: + return instance + return cls.objects.create() + + def __str__(self): + return "Registration Content" class Registration(models.Model): diff --git a/mittab/apps/registration/tests/test_registration_flow.py b/mittab/apps/registration/tests/test_registration_flow.py index 4877debe8..43fb4daa2 100644 --- a/mittab/apps/registration/tests/test_registration_flow.py +++ b/mittab/apps/registration/tests/test_registration_flow.py @@ -32,6 +32,7 @@ def test_registration_flow_creates_objects(client): "teams-0-team_id": "", "teams-0-name": "Registration U A", "teams-0-is_free_seed": "on", + "teams-0-seed_choice": str(Team.FULL_SEED), "teams-0-debater_one_id": "", "teams-0-debater_one_name": "Registration U Speaker 1", "teams-0-debater_one_apda_id": "1000", @@ -48,6 +49,7 @@ def test_registration_flow_creates_objects(client): "judges-0-registration_judge_id": "", "judges-0-judge_id": "", "judges-0-name": "Reg Judge", + "judges-0-email": "judge@example.com", "judges-0-experience": "7", "judges-0-DELETE": "", } @@ -71,6 +73,7 @@ def test_registration_flow_creates_objects(client): judge_relation = registration.judges.select_related("judge").first() assert judge_relation.judge.name == "Reg Judge" assert judge_relation.judge.rank == Decimal("7") + assert judge_relation.judge.email == "judge@example.com" @pytest.mark.django_db @@ -87,6 +90,7 @@ def test_registration_requires_single_free_seed(client): "teams-0-registration_team_id": "", "teams-0-team_id": "", "teams-0-name": "Team One", + "teams-0-seed_choice": str(Team.UNSEEDED), "teams-0-debater_one_id": "", "teams-0-debater_one_name": "Speaker 1", "teams-0-debater_one_apda_id": "", @@ -103,6 +107,7 @@ def test_registration_requires_single_free_seed(client): "teams-1-registration_team_id": "", "teams-1-team_id": "", "teams-1-name": "Team Two", + "teams-1-seed_choice": str(Team.UNSEEDED), "teams-1-debater_one_id": "", "teams-1-debater_one_name": "Speaker 3", "teams-1-debater_one_apda_id": "", @@ -120,4 +125,4 @@ def test_registration_requires_single_free_seed(client): ) response = client.post("/registration/", data=data) assert response.status_code == 200 - assert b"Select exactly one free seed" in response.content + assert b"Select at most one free seed" in response.content diff --git a/mittab/apps/registration/urls.py b/mittab/apps/registration/urls.py index 7b919db69..c2c857c11 100644 --- a/mittab/apps/registration/urls.py +++ b/mittab/apps/registration/urls.py @@ -4,6 +4,7 @@ urlpatterns = [ path("", views.registration_portal, name="registration_portal"), + path("setup/", views.registration_setup, name="registration_setup"), path("/", views.registration_portal, name="registration_portal_edit"), # API proxy endpoints to avoid CORS issues path("api/schools/", views.proxy_schools_active, name="api_schools_active"), diff --git a/mittab/apps/registration/views.py b/mittab/apps/registration/views.py index 1bb4e7e53..d9b6a2c3f 100644 --- a/mittab/apps/registration/views.py +++ b/mittab/apps/registration/views.py @@ -1,4 +1,7 @@ +from typing import Type, cast + from django import forms +from django.contrib.auth.decorators import permission_required from django.db import transaction from django.db.models import Prefetch from django.forms import BaseFormSet, formset_factory @@ -13,13 +16,16 @@ JudgeForm, NEW_CHOICE_VALUE, RegistrationForm, + RegistrationSettingsForm, TeamForm, - parse_school, ) from mittab.apps.tab.models import Debater, Judge, School, Team +from mittab.apps.tab.helpers import redirect_and_flash_success +from mittab.libs.cacheing.public_cache import invalidate_all_public_caches from .models import ( Registration, RegistrationConfig, + RegistrationContent, RegistrationJudge, RegistrationTeam, RegistrationTeamMember, @@ -43,24 +49,31 @@ def clean(self): super().clean() if any(self.errors): return - active = [form for form in self.forms if form.cleaned_data and not form.cleaned_data.get("DELETE")] + active = [ + form + for form in self.forms + if form.cleaned_data and not form.cleaned_data.get("DELETE") + ] if not active: raise forms.ValidationError("Add at least one team") free = sum(1 for form in active if form.cleaned_data.get("is_free_seed")) - if free != 1: - raise forms.ValidationError("Select exactly one free seed") + if free > 1: + raise forms.ValidationError("Select at most one free seed") class RegistrationJudgeFormSet(BaseFormSet): pass -TeamFormSet = formset_factory( - TeamForm, - formset=RegistrationTeamFormSet, - extra=0, - can_delete=True, - max_num=MAX_TEAMS, +TeamFormSet = cast( + Type[RegistrationTeamFormSet], + formset_factory( + TeamForm, + formset=RegistrationTeamFormSet, + extra=0, + can_delete=True, + max_num=MAX_TEAMS, + ), ) JudgeFormSet = formset_factory( JudgeForm, @@ -98,9 +111,12 @@ def fetch_remote_schools(active_only=True): return results -def build_school_choices(active_only=True): +def build_school_choices(active_only=False): """Build school choices for the dropdown.""" - return [(f"apda:{school['id']}", school["name"]) for school in fetch_remote_schools(active_only)] + return [ + (f"apda:{school['id']}", school["name"]) + for school in fetch_remote_schools(active_only) + ] def school_value(school): @@ -113,16 +129,25 @@ def school_value(school): def registration_team_initial(reg_team): team = reg_team.team - members = list(reg_team.members.select_related("debater", "school").order_by("position")) + members = list( + reg_team.members.select_related("debater", "school").order_by("position") + ) defaults = [{"debater": None, "school": None}, {"debater": None, "school": None}] for member in members: if member.position in (0, 1): - defaults[member.position] = {"debater": member.debater, "school": member.school} + defaults[member.position] = { + "debater": member.debater, + "school": member.school, + } for index in range(2): if defaults[index]["debater"] is None: - debater = team.debaters.all()[index] if team.debaters.count() > index else None + debater = ( + team.debaters.all()[index] if team.debaters.count() > index else None + ) defaults[index]["debater"] = debater - defaults[index]["school"] = team.hybrid_school if index == 1 else team.school + defaults[index]["school"] = ( + team.hybrid_school if index == 1 else team.school + ) first_school = defaults[0]["school"] second_school = defaults[1]["school"] return { @@ -130,18 +155,39 @@ def registration_team_initial(reg_team): "team_id": team.pk, "name": team.name, "is_free_seed": reg_team.is_free_seed, + "seed_choice": ( + team.seed + if team.seed in (Team.UNSEEDED, Team.HALF_SEED, Team.FULL_SEED) + else Team.UNSEEDED + ), "debater_one_id": defaults[0]["debater"].pk if defaults[0]["debater"] else None, - "debater_one_name": defaults[0]["debater"].name if defaults[0]["debater"] else "", - "debater_one_apda_id": defaults[0]["debater"].apda_id if defaults[0]["debater"] else None, - "debater_one_novice_status": defaults[0]["debater"].novice_status if defaults[0]["debater"] else 0, - "debater_one_qualified": defaults[0]["debater"].qualified if defaults[0]["debater"] else False, + "debater_one_name": ( + defaults[0]["debater"].name if defaults[0]["debater"] else "" + ), + "debater_one_apda_id": ( + defaults[0]["debater"].apda_id if defaults[0]["debater"] else None + ), + "debater_one_novice_status": ( + defaults[0]["debater"].novice_status if defaults[0]["debater"] else 0 + ), + "debater_one_qualified": ( + defaults[0]["debater"].qualified if defaults[0]["debater"] else False + ), "debater_one_school": school_value(first_school), "debater_one_school_name": first_school.name if first_school else "", "debater_two_id": defaults[1]["debater"].pk if defaults[1]["debater"] else None, - "debater_two_name": defaults[1]["debater"].name if defaults[1]["debater"] else "", - "debater_two_apda_id": defaults[1]["debater"].apda_id if defaults[1]["debater"] else None, - "debater_two_novice_status": defaults[1]["debater"].novice_status if defaults[1]["debater"] else 0, - "debater_two_qualified": defaults[1]["debater"].qualified if defaults[1]["debater"] else False, + "debater_two_name": ( + defaults[1]["debater"].name if defaults[1]["debater"] else "" + ), + "debater_two_apda_id": ( + defaults[1]["debater"].apda_id if defaults[1]["debater"] else None + ), + "debater_two_novice_status": ( + defaults[1]["debater"].novice_status if defaults[1]["debater"] else 0 + ), + "debater_two_qualified": ( + defaults[1]["debater"].qualified if defaults[1]["debater"] else False + ), "debater_two_school": school_value(second_school), "debater_two_school_name": second_school.name if second_school else "", } @@ -153,6 +199,7 @@ def registration_judge_initial(reg_judge): "registration_judge_id": reg_judge.pk, "judge_id": judge.pk, "name": judge.name, + "email": judge.email, "experience": judge.rank, } @@ -160,7 +207,11 @@ def registration_judge_initial(reg_judge): def get_registration_forms(request, registration, school_choices): if request.method == "POST": reg_form = RegistrationForm(request.POST, school_choices=school_choices) - team_formset = TeamFormSet(request.POST, prefix="teams", school_choices=school_choices) + team_formset = TeamFormSet( # pylint: disable=unexpected-keyword-arg + request.POST, + prefix="teams", + school_choices=school_choices, + ) judge_formset = JudgeFormSet(request.POST, prefix="judges") return reg_form, team_formset, judge_formset if registration: @@ -174,7 +225,9 @@ def get_registration_forms(request, registration, school_choices): ) teams_initial = [ registration_team_initial(team) - for team in registration.teams.select_related("team").prefetch_related("team__debaters", "members__debater", "members__school") + for team in registration.teams.select_related("team").prefetch_related( + "team__debaters", "members__debater", "members__school" + ) ] judges_initial = [ registration_judge_initial(reg) @@ -184,7 +237,7 @@ def get_registration_forms(request, registration, school_choices): reg_form = RegistrationForm(school_choices=school_choices) teams_initial = [] judges_initial = [] - team_formset = TeamFormSet( + team_formset = TeamFormSet( # pylint: disable=unexpected-keyword-arg initial=teams_initial, prefix="teams", school_choices=school_choices, @@ -204,11 +257,17 @@ def resolve_school(selection, cache): cache[key] = school return school if "apda_id" in selection: - school = School.objects.select_for_update().filter(apda_id=selection["apda_id"]).first() + school = ( + School.objects.select_for_update() + .filter(apda_id=selection["apda_id"]) + .first() + ) if school: cache[key] = school return school - school = School.objects.create(name=selection.get("name", ""), apda_id=selection["apda_id"]) + school = School.objects.create( + name=selection.get("name", ""), apda_id=selection["apda_id"] + ) cache[key] = school return school name = selection["name"] @@ -224,7 +283,7 @@ def resolve_school(selection, cache): return school -def get_or_create_debater(data, school, team): +def get_or_create_debater(data, team): debater_id = data.get("id") apda_id = data.get("apda_id") name = data["name"].strip() @@ -249,7 +308,9 @@ def get_or_create_debater(data, school, team): debater.save() assignments = debater.team_set.exclude(pk=team.pk if team else None) if assignments.exists(): - raise forms.ValidationError(f"Debater {debater.name} is already on another team") + raise forms.ValidationError( + f"Debater {debater.name} is already on another team" + ) return debater @@ -264,28 +325,46 @@ def ensure_unique_team_name(team, name): def summarise_registration(registration): if not registration: return None - registration = Registration.objects.select_related("school").prefetch_related( - Prefetch( - "teams", - queryset=RegistrationTeam.objects.select_related("team").prefetch_related( - "team__debaters", - "members__debater", - "members__school", + registration = ( + Registration.objects.select_related("school") + .prefetch_related( + Prefetch( + "teams", + queryset=RegistrationTeam.objects.select_related( + "team" + ).prefetch_related( + "team__debaters", + "members__debater", + "members__school", + ), ), - ), - "judges__judge", - ).get(pk=registration.pk) + "judges__judge", + ) + .get(pk=registration.pk) + ) teams = [] for reg_team in registration.teams.all(): team = reg_team.team debaters = [] - for member in reg_team.members.select_related("debater", "school").order_by("position"): + for member in reg_team.members.select_related("debater", "school").order_by( + "position" + ): debaters.append(member.debater.name) if not debaters: debaters = [debater.name for debater in team.debaters.all()] - teams.append({"name": team.name, "is_free_seed": reg_team.is_free_seed, "debaters": debaters}) + teams.append( + { + "name": team.name, + "is_free_seed": reg_team.is_free_seed, + "debaters": debaters, + } + ) judges = [ - {"name": reg.judge.name, "code": reg.judge.ballot_code} + { + "name": reg.judge.name, + "code": reg.judge.ballot_code, + "email": reg.judge.email, + } for reg in registration.judges.select_related("judge") ] return { @@ -304,48 +383,64 @@ def save_registration(reg_form, team_formset, judge_formset, registration): registration.school = main_school registration.email = reg_form.cleaned_data["email"] registration.save() - team_map = { - team.pk: team - for team in registration.teams.select_related("team").prefetch_related("team__debaters", "members__debater", "members__school") - } saved_team_ids = [] for form in team_formset: if form.cleaned_data.get("DELETE"): continue payload = form.get_payload() - team_obj = Team.objects.select_for_update().filter(pk=payload["team_id"]).first() if payload.get("team_id") else Team() + team_obj = ( + Team.objects.select_for_update().filter(pk=payload["team_id"]).first() + if payload.get("team_id") + else Team() + ) ensure_unique_team_name(team_obj, payload["name"]) members = payload["members"] first_school = resolve_school(members[0]["school"], school_cache) if first_school.pk != main_school.pk: - raise forms.ValidationError("The first debater must represent the registration school") + raise forms.ValidationError( + "The first debater must represent the registration school" + ) hybrid_school = None member_instances = [] for member_payload in members: member_school = resolve_school(member_payload["school"], school_cache) if member_school.pk != main_school.pk and not hybrid_school: hybrid_school = member_school - debater = get_or_create_debater(member_payload, member_school, team_obj if team_obj.pk else None) + debater = get_or_create_debater( + member_payload, team_obj if team_obj.pk else None + ) member_instances.append((debater, member_school)) team_obj.school = main_school team_obj.hybrid_school = hybrid_school team_obj.name = payload["name"] - team_obj.seed = Team.FREE_SEED if payload["is_free_seed"] else Team.UNSEEDED + team_obj.seed = ( + Team.FREE_SEED if payload["is_free_seed"] else payload["seed_choice"] + ) team_obj.break_preference = Team.VARSITY team_obj.checked_in = False team_obj.save() team_obj.debaters.set([debater for debater, _ in member_instances]) - reg_team = RegistrationTeam.objects.select_for_update().filter(pk=payload.get("registration_team_id"), registration=registration).first() + reg_team = ( + RegistrationTeam.objects.select_for_update() + .filter(pk=payload.get("registration_team_id"), registration=registration) + .first() + ) if not reg_team: - reg_team = RegistrationTeam.objects.create(registration=registration, team=team_obj) + reg_team = RegistrationTeam.objects.create( + registration=registration, team=team_obj + ) reg_team.is_free_seed = payload["is_free_seed"] reg_team.team = team_obj reg_team.save() - member_map = {member.position: member for member in reg_team.members.select_for_update()} + member_map = { + member.position: member for member in reg_team.members.select_for_update() + } for index, (debater, school) in enumerate(member_instances): member = member_map.pop(index, None) if not member: - member = RegistrationTeamMember(registration_team=reg_team, position=index) + member = RegistrationTeamMember( + registration_team=reg_team, position=index + ) member.debater = debater member.school = school member.save() @@ -362,10 +457,6 @@ def save_registration(reg_form, team_formset, judge_formset, registration): team.delete() except Exception: pass - judge_map = { - judge.pk: judge - for judge in registration.judges.select_related("judge") - } saved_judge_ids = [] for form in judge_formset: if form.cleaned_data.get("DELETE"): @@ -374,15 +465,24 @@ def save_registration(reg_form, team_formset, judge_formset, registration): experience = payload["experience"] if experience < 0 or experience > 10: raise forms.ValidationError("Judge experience must be between 0 and 10") - judge = Judge.objects.select_for_update().filter(pk=payload.get("judge_id")).first() + judge = ( + Judge.objects.select_for_update().filter(pk=payload.get("judge_id")).first() + ) if not judge: judge = Judge(name=payload["name"], rank=experience) judge.name = payload["name"] judge.rank = experience + judge.email = payload["email"] judge.save() - relation = registration.judges.select_for_update().filter(pk=payload.get("registration_judge_id"), registration=registration).first() + relation = ( + registration.judges.select_for_update() + .filter(pk=payload.get("registration_judge_id"), registration=registration) + .first() + ) if not relation: - relation = RegistrationJudge.objects.create(registration=registration, judge=judge) + relation = RegistrationJudge.objects.create( + registration=registration, judge=judge + ) else: relation.judge = judge relation.save() @@ -403,36 +503,97 @@ def save_registration(reg_form, team_formset, judge_formset, registration): def registration_portal(request, code=None): registration = None if code: - registration = Registration.objects.select_related("school").filter(herokunator_code=code).first() + registration = ( + Registration.objects.select_related("school") + .filter(herokunator_code=code) + .first() + ) if not registration: raise Http404() school_choices = build_school_choices() - reg_form, team_formset, judge_formset = get_registration_forms(request, registration, school_choices) - config = RegistrationConfig.get_active() + reg_form, team_formset, judge_formset = get_registration_forms( + request, registration, school_choices + ) + config = RegistrationConfig.get_or_create_active() + content = RegistrationContent.get_solo() + is_edit_mode = registration is not None + can_create = config.can_create() + can_modify = config.can_modify() + can_submit = can_modify if is_edit_mode else can_create + lock_message = None + if not can_submit: + lock_message = ( + "Registration updates are currently disabled." + if is_edit_mode + else "New registrations are currently closed." + ) if request.method == "POST": if reg_form.is_valid() and team_formset.is_valid() and judge_formset.is_valid(): - if config and not config.is_open(): - reg_form.add_error(None, "Registration is currently closed") + if not can_submit: + reg_form.add_error( + None, lock_message or "Registration is currently unavailable" + ) else: try: with transaction.atomic(): - saved = save_registration(reg_form, team_formset, judge_formset, registration) + saved = save_registration( + reg_form, team_formset, judge_formset, registration + ) except forms.ValidationError as error: - team_formset._non_form_errors = team_formset.error_class(error.messages) + team_formset._non_form_errors = ( # pylint: disable=protected-access + team_formset.error_class(error.messages) + ) else: - return redirect(reverse("registration_portal_edit", args=[saved.herokunator_code])) + return redirect( + reverse( + "registration_portal_edit", args=[saved.herokunator_code] + ) + ) summary = summarise_registration(registration) if registration else None context = { "registration_form": reg_form, "team_formset": team_formset, "judge_formset": judge_formset, "config": config, + "content": content, "summary": summary, "max_teams": MAX_TEAMS, + "is_edit_mode": is_edit_mode, + "can_create_registration": can_create, + "can_modify_registration": can_modify, + "can_submit_registration": can_submit, + "registration_lock_message": lock_message, } return render(request, "registration/portal.html", context) +@permission_required("tab.tab_settings.can_change", login_url="/403/") +def registration_setup(request): + config = RegistrationConfig.get_or_create_active() + content = RegistrationContent.get_solo() + if request.method == "POST": + form = RegistrationSettingsForm(request.POST, config=config, content=content) + if form.is_valid(): + form.save() + invalidate_all_public_caches() + return redirect_and_flash_success( + request, + "Registration settings updated!", + path=reverse("registration_setup"), + ) + else: + form = RegistrationSettingsForm(config=config, content=content) + return render( + request, + "registration/setup.html", + { + "form": form, + "config": config, + "content": content, + }, + ) + + @require_http_methods(["GET"]) def proxy_schools_active(request): """Proxy endpoint for active schools to avoid CORS issues.""" @@ -440,7 +601,9 @@ def proxy_schools_active(request): response = requests.get(SCHOOL_ACTIVE_URL, timeout=10) if response.ok: return JsonResponse(response.json(), safe=False) - return JsonResponse({"error": "Failed to fetch schools"}, status=response.status_code) + return JsonResponse( + {"error": "Failed to fetch schools"}, status=response.status_code + ) except RequestException as e: return JsonResponse({"error": str(e)}, status=500) @@ -452,7 +615,9 @@ def proxy_schools_all(request): response = requests.get(SCHOOL_ALL_URL, timeout=10) if response.ok: return JsonResponse(response.json(), safe=False) - return JsonResponse({"error": "Failed to fetch schools"}, status=response.status_code) + return JsonResponse( + {"error": "Failed to fetch schools"}, status=response.status_code + ) except RequestException as e: return JsonResponse({"error": str(e)}, status=500) @@ -465,6 +630,8 @@ def proxy_debaters(request, school_id): response = requests.get(url, timeout=10) if response.ok: return JsonResponse(response.json(), safe=False) - return JsonResponse({"error": "Failed to fetch debaters"}, status=response.status_code) + return JsonResponse( + {"error": "Failed to fetch debaters"}, status=response.status_code + ) except RequestException as e: return JsonResponse({"error": str(e)}, status=500) diff --git a/mittab/apps/tab/migrations/0026_debater_qualified.py b/mittab/apps/tab/migrations/0026_debater_qualified.py deleted file mode 100644 index 595ea5be9..000000000 --- a/mittab/apps/tab/migrations/0026_debater_qualified.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("tab", "0025_alter_tabsettings_key"), - ] - - operations = [ - migrations.AddField( - model_name="debater", - name="qualified", - field=models.BooleanField(default=False), - ), - ] diff --git a/mittab/apps/tab/migrations/0032_auto_20251110_2150.py b/mittab/apps/tab/migrations/0032_auto_20251110_2150.py new file mode 100644 index 000000000..4fba194f8 --- /dev/null +++ b/mittab/apps/tab/migrations/0032_auto_20251110_2150.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2025-11-10 21:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tab', '0031_remove_noshow_lenient_late'), + ] + + operations = [ + migrations.AddField( + model_name='debater', + name='qualified', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='judge', + name='email', + field=models.EmailField(blank=True, max_length=254), + ), + ] diff --git a/mittab/apps/tab/models.py b/mittab/apps/tab/models.py index 06ae2e17e..8e8531358 100644 --- a/mittab/apps/tab/models.py +++ b/mittab/apps/tab/models.py @@ -329,6 +329,7 @@ class BreakingTeam(models.Model): class Judge(models.Model): name = models.CharField(max_length=30, unique=True) rank = models.DecimalField(max_digits=4, decimal_places=2) + email = models.EmailField(blank=True) schools = models.ManyToManyField(School) ballot_code = models.CharField(max_length=255, blank=True, diff --git a/mittab/apps/tab/templatetags/tags.py b/mittab/apps/tab/templatetags/tags.py index 511647f33..978f37327 100644 --- a/mittab/apps/tab/templatetags/tags.py +++ b/mittab/apps/tab/templatetags/tags.py @@ -1,5 +1,8 @@ from django import template from django.forms.fields import FileField +from django.utils.html import urlize +from django.utils.safestring import mark_safe + from mittab.apps.tab.models import TabSettings register = template.Library() @@ -49,4 +52,11 @@ def judge_team_count(context, judge, pairing): @register.simple_tag def tournament_name(): return TabSettings.get("tournament_name", "New Tournament") - \ No newline at end of file + + +@register.filter(name="registration_text", needs_autoescape=True) +def registration_text(value, autoescape=True): + if not value: + return "" + linked = urlize(value, nofollow=True, autoescape=autoescape) + return mark_safe(linked.replace("\n", "
")) diff --git a/mittab/apps/tab/views/public_views.py b/mittab/apps/tab/views/public_views.py index d95a4887e..e50cf6d3e 100644 --- a/mittab/apps/tab/views/public_views.py +++ b/mittab/apps/tab/views/public_views.py @@ -7,6 +7,7 @@ from mittab.apps.tab.helpers import redirect_and_flash_error from mittab.apps.tab.models import (BreakingTeam, Bye, Outround, TabSettings, Judge, Team, Round) +from mittab.apps.registration.models import RegistrationConfig, RegistrationContent from mittab.apps.tab.views.pairing_views import enter_result from mittab.libs.cacheing import cache_logic from mittab.libs.bracket_display_logic import get_bracket_data_json @@ -21,6 +22,9 @@ def public_access_error(request): @cache_public_view(timeout=60) def public_home(request): + registration_config = RegistrationConfig.get_or_create_active() + registration_content = RegistrationContent.get_solo() + registration_open = bool(registration_config and registration_config.can_create()) cur_round_setting = TabSettings.get("cur_round", 1) - 1 tot_rounds = TabSettings.get("tot_rounds", 5) pairing_released_inround = TabSettings.get("pairing_released", 0) == 1 @@ -38,6 +42,9 @@ def public_home(request): { "status_primary": status_primary, "status_secondary": status_secondary, + "registration_open": registration_open, + "registration_description": registration_content.description if registration_content else "", + "registration_url": reverse("registration_portal"), }, ) @@ -94,6 +101,9 @@ def public_home(request): { "status_primary": status_primary, "status_secondary": status_secondary, + "registration_open": registration_open, + "registration_description": registration_content.description if registration_content else "", + "registration_url": reverse("registration_portal"), }, ) diff --git a/mittab/templates/base/_navigation.html b/mittab/templates/base/_navigation.html index 5521e5d5a..14c09cceb 100644 --- a/mittab/templates/base/_navigation.html +++ b/mittab/templates/base/_navigation.html @@ -35,6 +35,7 @@ {% url "view_scratches" as view_scratches %} {% url "add_scratch" as add_scratch %} {% url "settings_form" as settings_form %} +{% url "registration_setup" as registration_setup %} {% url "outround_pairing_view_default" as outround_pairings %} {% url "pretty_pair" as public_pairings %} {% url "missing_ballots" as public_missing_ballots %} @@ -110,6 +111,7 @@ Batch Checkin Bulk Data Upload Settings Form + Registration Setup New Tournament Admin Interface diff --git a/mittab/templates/public/home.html b/mittab/templates/public/home.html index a4a1c7334..fd99dc698 100644 --- a/mittab/templates/public/home.html +++ b/mittab/templates/public/home.html @@ -6,7 +6,7 @@ {% block extra_head %} {% render_bundle 'publicHome' %} - + {% render_bundle 'publicDisplay' %} {% endblock %} {% block banner %}{% endblock %} @@ -25,8 +25,8 @@

Welcome to {% tournament_name %}!

+ {% if not registration_open %}
- @@ -35,36 +35,46 @@

Welcome to {% tournament_name %}!

{{ status_secondary }}
-
-
Tournament Updates

Check back regularly for updated pairings, results, and standings throughout the tournament.

-
For Judges

Use your ballot code to submit results electronically via the E-Ballot link.

-
For Teams

View your matchups in Released Pairings and track your progress in Team Rankings.

-
Staff Login

Login here if you're tournament staff to access admin features.

+ {% else %} +
+
+

Registration Open

+
+
+ {% if registration_description %} + {{ registration_description|registration_text }} + {% else %} + Registration is live! Please review the information below and complete the form to reserve your school’s slots. + {% endif %} +
+ Register Now +
+ {% endif %}
@@ -72,6 +82,18 @@

Welcome to {% tournament_name %}!

diff --git a/mittab/templates/registration/_judge_form.html b/mittab/templates/registration/_judge_form.html index 9b7cd8c9b..4c81a6f58 100644 --- a/mittab/templates/registration/_judge_form.html +++ b/mittab/templates/registration/_judge_form.html @@ -2,7 +2,7 @@
{% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %} -
+
Name @@ -11,7 +11,16 @@
{{ form.name.errors }}
-
+
+
+
+ Email +
+ {{ form.email }} +
+ {{ form.email.errors }} +
+
Experience diff --git a/mittab/templates/registration/_school_selector.html b/mittab/templates/registration/_school_selector.html index ed1c2dc7c..13ab4cb23 100644 --- a/mittab/templates/registration/_school_selector.html +++ b/mittab/templates/registration/_school_selector.html @@ -4,9 +4,6 @@ {{ field.label_tag }} {{ field }} {{ field.errors }} - {% if show_see_more|default:True %} - Showing top 25 active schools. See more schools - {% endif %}
@@ -23,9 +20,6 @@ {{ field.label_tag }} {{ field }} {{ field.errors }} - {% if show_see_more|default:True %} - Showing top 25 active schools. See more schools - {% endif %}
{{ name_field.label_tag }} {{ name_field }} diff --git a/mittab/templates/registration/_team_form.html b/mittab/templates/registration/_team_form.html index a1b1383c4..f6f1b19bc 100644 --- a/mittab/templates/registration/_team_form.html +++ b/mittab/templates/registration/_team_form.html @@ -46,7 +46,6 @@
First Debater
{{ form.debater_one_school }}
- Showing top 25 active schools. See more {% if form.debater_one_school.errors %}
{{ form.debater_one_school.errors }}
{% endif %} @@ -61,7 +60,16 @@
First Debater
-
Second Debater
+
+
Second Debater
+
+ + {{ form.seed_choice }} + {% if form.seed_choice.errors %} +
{{ form.seed_choice.errors }}
+ {% endif %} +
+
{{ form.debater_two_id }}
@@ -78,7 +86,6 @@
Second Debater
{{ form.debater_two_school }}
- Showing top 25 active schools. See more {% if form.debater_two_school.errors %}
{{ form.debater_two_school.errors }}
{% endif %} diff --git a/mittab/templates/registration/portal.html b/mittab/templates/registration/portal.html index 4ac0a191b..3368c52cc 100644 --- a/mittab/templates/registration/portal.html +++ b/mittab/templates/registration/portal.html @@ -1,5 +1,6 @@ {% extends "base/__wide.html" %} {% load render_bundle from webpack_loader %} +{% load tags %} {% block title %}Tournament Registration{% endblock %} {% block banner %}Tournament Registration{% endblock %} @@ -7,8 +8,31 @@ {% block content %} {% render_bundle 'registrationPortal' %}
- {% if config and not config.is_open %} -
Registration is currently closed.
+ {% if content.description %} +
+ + +
+ {% endif %} + {% if registration_lock_message %} +
{{ registration_lock_message }}
{% endif %}
-
+
-
-
-
Create New School
-
-
- Name -
- +
+
Create New School
+
+
+ Name
- +
+
-
-
-
Create New Debater
-
-
- School -
- +
+
Create New Debater
+
+
+ School
- Showing top 25 active schools. See more -
-
- Name -
- + +
+
+
+ Name
-
-
- Status -
- + +
+
+
+ Status
- +
+
@@ -142,7 +160,7 @@
Judges
- + {% if summary %} Editing registration {{ summary.code }} {% endif %} @@ -183,12 +201,20 @@
Judges
{% for judge in summary.judges %}

{{ judge.name }}{% if judge.code %} (Code: {{ judge.code }}){% endif %}

+ {% if judge.email %} +

Email: {{ judge.email }}

+ {% endif %}
{% endfor %}
+ {% if content.completion_message %} +
+ {{ content.completion_message|registration_text }} +
+ {% endif %} {% endif %}
{% endblock %} diff --git a/mittab/templates/registration/setup.html b/mittab/templates/registration/setup.html new file mode 100644 index 000000000..6b262b678 --- /dev/null +++ b/mittab/templates/registration/setup.html @@ -0,0 +1,72 @@ +{% extends "base/__normal.html" %} +{% load bootstrap4 %} + +{% block title %}Registration Setup{% endblock %} +{% block banner %}Registration Setup{% endblock %} + +{% block content %} +
+
+
+

Portal Controls

+

+ Toggle who can access the public registration portal and update the helper text that appears on the homepage and confirmation screen. +

+
+ {% csrf_token %} +
+
+ {{ form.allow_new_registrations }} + +
+ {% if form.allow_new_registrations.help_text %} + {{ form.allow_new_registrations.help_text }} + {% endif %} + {{ form.allow_new_registrations.errors }} +
+
+
+ {{ form.allow_registration_edits }} + +
+ {% if form.allow_registration_edits.help_text %} + {{ form.allow_registration_edits.help_text }} + {% endif %} + {{ form.allow_registration_edits.errors }} +
+
+
+ + {{ form.registration_description }} + {% if form.registration_description.help_text %} + {{ form.registration_description.help_text }} + {% endif %} + {{ form.registration_description.errors }} +
+
+ + {{ form.registration_completion_message }} + {% if form.registration_completion_message.help_text %} + {{ form.registration_completion_message.help_text }} + {% endif %} + {{ form.registration_completion_message.errors }} +
+
+ +
+
+
+
+
+ The homepage banner and registration portal copy support hyperlinks automatically. Use the toggles above to immediately open or close registrations without relying on calendar dates. +
+
+{% endblock %} From aa87a41b9d674ee52e8756db7400cfa83e9f0984 Mon Sep 17 00:00:00 2001 From: JoeyRubas Date: Wed, 12 Nov 2025 21:02:46 -0500 Subject: [PATCH 3/5] mvp short --- assets/js/registration/portal.js | 902 +++++------------- mittab/apps/registration/admin.py | 30 +- mittab/apps/registration/forms.py | 221 ++--- .../registration/migrations/0001_initial.py | 41 +- mittab/apps/registration/models.py | 26 +- .../tests/test_registration_flow.py | 193 ++-- mittab/apps/registration/views.py | 412 +++----- .../tab/migrations/0032_auto_20251110_2150.py | 23 - .../tab/migrations/0032_auto_20251111_2132.py | 40 + mittab/apps/tab/models.py | 18 + mittab/apps/tab/templatetags/tags.py | 9 + .../templates/registration/_debater_card.html | 27 + .../templates/registration/_input_group.html | 9 + .../templates/registration/_judge_form.html | 24 +- mittab/templates/registration/_team_form.html | 94 +- mittab/templates/registration/portal.html | 57 +- mittab/templates/registration/setup.html | 46 +- 17 files changed, 717 insertions(+), 1455 deletions(-) delete mode 100644 mittab/apps/tab/migrations/0032_auto_20251110_2150.py create mode 100644 mittab/apps/tab/migrations/0032_auto_20251111_2132.py create mode 100644 mittab/templates/registration/_debater_card.html create mode 100644 mittab/templates/registration/_input_group.html diff --git a/assets/js/registration/portal.js b/assets/js/registration/portal.js index 6ad20faa2..68532423a 100644 --- a/assets/js/registration/portal.js +++ b/assets/js/registration/portal.js @@ -1,731 +1,297 @@ -const NEW = "__new__"; -// Use our proxy endpoints to avoid CORS issues +import $ from "jquery"; + +const NEW_VALUE = "__new__"; const DEB_URL = (id) => `/registration/api/debaters/${id}/`; -const TEAM_SEEDS = { - UNSEEDED: "0", - HALF: "2", - FULL: "3", -}; -let root; -const queryAll = (selector, scope = root) => - Array.from(scope.querySelectorAll(selector)); -const byId = (value) => document.getElementById(value); -const debaterName = (person) => - person.name || - person.full_name || - `${person.first_name || ""} ${person.last_name || ""}`.trim(); +const customSchools = []; +const customDebaters = {}; -const updateDebaterMetaFields = ( - input, - container, - prefix, - debaterData = null, -) => { - const apdaIdField = byId(input.dataset.apdaTarget); - const noviceField = container.querySelector( - `input[name$="${prefix}_novice_status"]`, - ); - const qualifiedField = container.querySelector( - `input[name$="${prefix}_qualified"]`, - ); +const setPlaceholder = ($select, message, disabled = true) => { + $select.empty().append(``); + $select.prop("disabled", disabled); +}; - if (apdaIdField) { - apdaIdField.value = +const toggleNewSchoolInput = ($select) => { + const targetId = $select.attr("id"); + $(`[data-new-school-container="${targetId}"]`).each((_, el) => { + const $container = $(el); + const show = $select.val() === NEW_VALUE; + $container.toggleClass("d-none", !show); + $container.find("input").toggleClass("d-none", !show); + }); +}; + +const fillDebaterData = (selectEl, debaterData = null) => { + const { apdaTarget } = selectEl.dataset; + const noviceField = selectEl + .closest("[data-debater]") + ?.querySelector( + `input[name$="${selectEl.dataset.debaterInput}_novice_status"]`, + ); + const qualifiedField = selectEl + .closest("[data-debater]") + ?.querySelector( + `input[name$="${selectEl.dataset.debaterInput}_qualified"]`, + ); + const apdaField = apdaTarget ? document.getElementById(apdaTarget) : null; + if (apdaField) { + apdaField.value = (debaterData && (debaterData.apda_id || debaterData.id)) || ""; } if (noviceField) { - const noviceStatus = debaterData?.status === "Novice" ? "1" : "0"; - noviceField.value = noviceStatus; + noviceField.value = debaterData?.status === "Novice" ? "1" : "0"; } if (qualifiedField) { qualifiedField.value = debaterData?.apda_id ? "on" : ""; } }; -const setCollapseState = (trigger, target, expanded) => { - if (!trigger || !target) { - return; - } - trigger.setAttribute("aria-expanded", expanded ? "true" : "false"); - trigger.classList.toggle("is-expanded", expanded); - if (expanded) { - target.removeAttribute("hidden"); - } else { - target.setAttribute("hidden", ""); - } -}; - -const initCollapsibles = () => { - queryAll("[data-collapse-toggle]").forEach((trigger) => { - const targetId = trigger.getAttribute("aria-controls"); - if (!targetId) { - return; - } - const target = document.getElementById(targetId); - if (!target) { +const syncDebater = (selectEl) => { + const option = selectEl.selectedOptions && selectEl.selectedOptions[0]; + if (option && option.dataset.debater) { + try { + fillDebaterData(selectEl, JSON.parse(option.dataset.debater)); return; + } catch (error) { + // ignore JSON parse errors } - const defaultExpanded = trigger.getAttribute("aria-expanded") === "true"; - setCollapseState(trigger, target, defaultExpanded); - trigger.addEventListener("click", (event) => { - event.preventDefault(); - const expanded = trigger.getAttribute("aria-expanded") === "true"; - setCollapseState(trigger, target, !expanded); - }); - }); -}; - -const toggleNew = (select) => { - queryAll(`[data-new-school-container="${select.id}"]`).forEach( - (container) => { - const show = select.value === NEW; - container.classList.toggle("d-none", !show); - const inputs = queryAll("input", container); - for (let index = 0; index < inputs.length; index += 1) { - const field = inputs[index]; - if (show) { - field.classList.remove("d-none"); - } else { - field.classList.add("d-none"); - } - } - }, - ); -}; - -const normalizeQualifiedValue = (value) => { - if (!value) return false; - const normalized = value.toString().toLowerCase(); - return normalized === "on" || normalized === "true" || normalized === "1"; -}; - -const updateTeamSeed = (teamForm) => { - if (!teamForm) return; - const seedSelect = teamForm.querySelector("[data-team-seed]"); - if (!seedSelect || seedSelect.dataset.autoset === "false") { - return; - } - const firstQualified = teamForm.querySelector( - 'input[name$="debater_one_qualified"]', - ); - const secondQualified = teamForm.querySelector( - 'input[name$="debater_two_qualified"]', - ); - if (!firstQualified || !secondQualified) { - return; - } - const first = normalizeQualifiedValue(firstQualified.value); - const second = normalizeQualifiedValue(secondQualified.value); - if (first && second) { - seedSelect.value = TEAM_SEEDS.FULL; - } else if (first || second) { - seedSelect.value = TEAM_SEEDS.HALF; - } else { - seedSelect.value = TEAM_SEEDS.UNSEEDED; } + fillDebaterData(selectEl); }; -const ensureSeedSelectState = (teamForm) => { - const seedSelect = teamForm.querySelector("[data-team-seed]"); - if (!seedSelect) { - return; - } - if (!seedSelect.dataset.autoset) { - const teamIdField = teamForm.querySelector('input[name$="team_id"]'); - const hasExistingTeam = teamIdField && teamIdField.value; - seedSelect.dataset.autoset = hasExistingTeam ? "false" : "true"; - } - if (seedSelect.dataset.autoset === "true") { - updateTeamSeed(teamForm); +const renderDebaterList = ($select, entries = [], schoolValue = "") => { + $select.empty().append(''); + entries.forEach((entry) => { + const name = entry.name || entry.full_name || ""; + const option = document.createElement("option"); + option.value = name.startsWith("custom:") ? name : name; + option.textContent = name; + option.dataset.debater = JSON.stringify({ + id: entry.id || entry.apda_id, + apda_id: entry.apda_id || entry.id, + name, + status: entry.status, + }); + $select.append(option); + }); + if (customDebaters[schoolValue]) { + customDebaters[schoolValue].forEach((debater) => { + const option = document.createElement("option"); + option.value = `custom:${debater.id}`; + option.textContent = debater.name; + option.dataset.debater = JSON.stringify(debater); + $select.append(option); + }); } + $select.prop("disabled", false); }; -const syncDebater = (input) => { - const container = input.closest("[data-debater]"); - if (!container) return; - - const prefix = input.dataset.debaterInput; - if (!prefix) return; - - // Find the matching option in the select itself - const selectedOption = input.selectedOptions && input.selectedOptions[0]; - - if (selectedOption && selectedOption.dataset.debater) { - // Parse the debater data stored in the option - try { - const debaterData = JSON.parse(selectedOption.dataset.debater); - updateDebaterMetaFields(input, container, prefix, debaterData); - } catch (_error) { - updateDebaterMetaFields(input, container, prefix); +const broadcastCustomDebater = (schoolValue, debater) => { + $("[data-school-select]").each((_, el) => { + if (el.value !== schoolValue) return; + const listId = el.dataset.nameId; + if (!listId) return; + const $debaterSelect = $(`#${listId}`); + if (!$debaterSelect.length) return; + if ($debaterSelect.prop("disabled")) { + setPlaceholder($debaterSelect, "Select a debater", false); } - } else { - updateDebaterMetaFields(input, container, prefix); - } - const teamForm = input.closest('[data-form="team"]'); - if (teamForm) { - ensureSeedSelectState(teamForm); - } -}; -const clearDebaterList = (list, selectElement) => { - const targetList = list; - const target = selectElement; - if (targetList) { - targetList.innerHTML = ""; - } - if (target) { - target.innerHTML = ''; - target.disabled = true; - } + const option = document.createElement("option"); + option.value = `custom:${debater.id}`; + option.textContent = debater.name; + option.dataset.debater = JSON.stringify(debater); + $debaterSelect.append(option); + }); }; -const updateDebaters = (select) => { - const { listId, nameId } = select.dataset; - if (!listId || !nameId) { - return; - } - - let list = byId(listId); - if (!list) { - list = document.createElement("datalist"); - list.id = listId; - list.style.display = "none"; - document.body.appendChild(list); - } - - const selectElement = byId(nameId); - if (!selectElement) { +const loadDebaters = ($schoolSelect) => { + const listId = $schoolSelect.data("nameId"); + if (!listId) return; + const $debaterSelect = $(`#${listId}`); + if (!$debaterSelect.length) return; + const value = $schoolSelect.val(); + if (!value) { + setPlaceholder($debaterSelect, "Select a school first"); return; } - - // Handle custom schools - they only have manually created debaters - if (select.value && select.value.startsWith("custom:")) { - const selectedOption = select.selectedOptions && select.selectedOptions[0]; - if (selectedOption) { - const schoolName = selectedOption.textContent; - // Find the associated school_name hidden field - const schoolNameField = select - .closest("[data-debater]") - ?.querySelector(`input[name$="_school_name"]`); - if (schoolNameField) { - schoolNameField.value = schoolName; - } - } - - // Clear and add any custom debaters for this school - selectElement.innerHTML = ''; - const schoolValue = select.value; - if (window.customDebaters && window.customDebaters[schoolValue]) { - window.customDebaters[schoolValue].forEach((debater) => { - const option = document.createElement("option"); - option.value = `custom:${debater.id}`; - option.textContent = debater.name; - option.dataset.debater = JSON.stringify(debater); - selectElement.appendChild(option); - }); - selectElement.disabled = false; - } else { - selectElement.disabled = true; - } - return; - } - - if (!select.value.startsWith("apda:")) { - clearDebaterList(list, selectElement); + if (value.startsWith("custom:")) { + renderDebaterList($debaterSelect, [], value); return; } - const apdaId = select.value.split(":")[1]; - if (!apdaId) { - clearDebaterList(list, selectElement); + if (!value.startsWith("apda:")) { + setPlaceholder($debaterSelect, "Select a school first"); return; } - - // Disable select while loading - selectElement.disabled = true; - selectElement.innerHTML = ''; - - fetch(DEB_URL(apdaId)) - .then((response) => (response.ok ? response.json() : { debaters: [] })) - .then((data) => { + setPlaceholder($debaterSelect, "Loading debaters..."); + $.getJSON(DEB_URL(value.split(":")[1])) + .done((data) => { const entries = Array.isArray(data) ? data : data.debaters || []; - selectElement.innerHTML = ""; - - // Add an empty option first - const emptyOption = document.createElement("option"); - emptyOption.value = ""; - emptyOption.textContent = "Select a debater"; - selectElement.appendChild(emptyOption); - - // Add debater options from API - entries.forEach((person) => { - const option = document.createElement("option"); - const name = debaterName(person); - option.value = name; - option.textContent = name; - - // Store the full debater data in the option - option.dataset.debater = JSON.stringify({ - id: person.id || person.apda_id, - apda_id: person.apda_id || person.id, - name, - first_name: person.first_name, - last_name: person.last_name, - status: person.status, - }); - - selectElement.appendChild(option); - }); - - // Add custom debaters for this school if any exist - const schoolValue = select.value; - if (window.customDebaters && window.customDebaters[schoolValue]) { - window.customDebaters[schoolValue].forEach((debater) => { - const option = document.createElement("option"); - option.value = `custom:${debater.id}`; - option.textContent = debater.name; - option.dataset.debater = JSON.stringify(debater); - selectElement.appendChild(option); - }); - } - - // Also store in the datalist for reference, even though it is hidden - list.innerHTML = ""; - entries.forEach((person) => { - const option = document.createElement("option"); - const name = debaterName(person); - option.value = name; - option.dataset.debater = JSON.stringify({ - id: person.id || person.apda_id, - apda_id: person.apda_id || person.id, - name, - first_name: person.first_name, - last_name: person.last_name, - status: person.status, - }); - list.appendChild(option); - }); - - // Enable the select - selectElement.disabled = false; - // Trigger sync to populate hidden fields if there's a pre-selected value - syncDebater(selectElement); + renderDebaterList($debaterSelect, entries, value); + syncDebater($debaterSelect[0]); }) - .catch(() => { - list.innerHTML = ""; - selectElement.innerHTML = - ''; - selectElement.disabled = true; + .fail(() => { + setPlaceholder($debaterSelect, "Unable to load debaters"); }); }; -const addForm = (type, maxTeams) => { - const prefix = type === "team" ? "teams" : "judges"; - const totalInput = root.querySelector(`input[name="${prefix}-TOTAL_FORMS"]`); - const total = parseInt(totalInput.value, 10) || 0; - if (type === "team" && total >= maxTeams) { - return; - } - const template = byId(`${type}-empty-form`); - if (!template) { - return; - } - const wrapper = document.createElement("div"); - wrapper.innerHTML = template.innerHTML.replace(/__prefix__/g, String(total)); - const element = wrapper.firstElementChild; - - // Also replace __prefix__ in data attributes - queryAll('[data-name-id*="__prefix__"]', element).forEach((el) => { - const elementWithNameId = el; - elementWithNameId.dataset.nameId = elementWithNameId.dataset.nameId.replace( - /__prefix__/g, - String(total), - ); - }); - queryAll('[data-list-id*="__prefix__"]', element).forEach((el) => { - const elementWithListId = el; - elementWithListId.dataset.listId = elementWithListId.dataset.listId.replace( - /__prefix__/g, - String(total), - ); - }); - - root.querySelector(`[data-formset-container="${type}"]`).appendChild(element); - totalInput.value = total + 1; - // If it's a team form, populate all related school selects, including debater - // selects, from the main registration school choices. - if (type === "team") { - const mainSchoolSelect = root.querySelector( - '[data-school-select="registration"]', - ); - - // First, copy all options from the main school select into the new team - // form instance. - const allSchoolSelects = queryAll("[data-school-select]", element); - allSchoolSelects.forEach((selectEl) => { - const schoolSelect = selectEl; - if (mainSchoolSelect) { - schoolSelect.innerHTML = ""; - queryAll("option", mainSchoolSelect).forEach((option) => { - const newOption = option.cloneNode(true); - schoolSelect.appendChild(newOption); - }); - } - - if ( - mainSchoolSelect && - mainSchoolSelect.value && - mainSchoolSelect.value !== NEW && - mainSchoolSelect.value !== "" - ) { - schoolSelect.value = mainSchoolSelect.value; - toggleNew(schoolSelect); - if (schoolSelect.dataset.listId) { - updateDebaters(schoolSelect); - } - } else { - toggleNew(schoolSelect); - const { listId, nameId } = schoolSelect.dataset; - if (listId && nameId) { - const input = byId(nameId); - if (input) { - input.disabled = true; - input.placeholder = "Select a school first"; - } - } - } - }); - } - - queryAll("[data-debater-input]", element).forEach((fieldEl) => { - const field = fieldEl; - // Debater fields should now be select elements - const debaterContainer = field.closest("[data-debater]"); - const schoolSelect = debaterContainer - ? debaterContainer.querySelector("[data-school-select]") - : null; +const configureSchoolSelect = ($select) => { + toggleNewSchoolInput($select); + loadDebaters($select); +}; - if ( - !schoolSelect || - !schoolSelect.value || - !schoolSelect.value.startsWith("apda:") - ) { - field.innerHTML = ''; - field.disabled = true; - } else { - // If school is already selected, sync the debater data - syncDebater(field); - } - }); - if (type === "team") { - ensureSeedSelectState(element); - } +const toggleAddTeamButton = () => { + const $addButton = $("[data-add-form='team']"); + const hasSchool = Boolean( + $("[data-school-select='registration']").first().val(), + ); + $addButton.prop("disabled", !hasSchool); }; -export default function initRegistrationPortal() { - root = document.getElementById("registration-app"); - if (!root) { - return; - } - const maxTeams = parseInt(root.dataset.maxTeams || "200", 10); - initCollapsibles(); - // Initialize existing school selects - queryAll("[data-school-select]").forEach((selectEl) => { - const select = selectEl; - toggleNew(select); - updateDebaters(select); - }); +const updateManagementForm = (prefix, delta) => { + const $total = $(`[name="${prefix}-TOTAL_FORMS"]`); + $total.val(parseInt($total.val(), 10) + delta); +}; - // Initialize existing debater selects - disable if no school selected - queryAll("[data-debater-input]").forEach((fieldEl) => { - const field = fieldEl; - const schoolSelectId = field - .closest("[data-debater]") - ?.querySelector("[data-school-select]")?.id; - if (schoolSelectId) { - const schoolSelect = byId(schoolSelectId); - if ( - !schoolSelect || - !schoolSelect.value || - !schoolSelect.value.startsWith("apda:") - ) { - field.innerHTML = ''; - field.disabled = true; - } - } - }); - queryAll('[data-form="team"]').forEach((teamForm) => { - ensureSeedSelectState(teamForm); +const addForm = (type, maxTeams) => { + const prefix = type === "team" ? "teams" : "judges"; + const $template = $(`#${type}-empty-form`); + if (!$template.length) return; + const total = parseInt($(`[name="${prefix}-TOTAL_FORMS"]`).val(), 10); + if (type === "team" && total >= maxTeams) return; + const html = $template.html().replace(/__prefix__/g, total); + const $element = $(html); + $(`[data-formset-container="${type}"]`).append($element); + updateManagementForm(prefix, 1); + $element.find("[data-school-select]").each((_, el) => { + configureSchoolSelect($(el)); }); +}; - // Enable/disable Add Team button based on main school selection - const updateAddTeamButton = () => { - const mainSchoolSelect = root.querySelector( - '[data-school-select="registration"]', - ); - const addTeamButton = root.querySelector('[data-add-form="team"]'); - if (addTeamButton) { - if ( - mainSchoolSelect && - mainSchoolSelect.value && - mainSchoolSelect.value !== "" - ) { - addTeamButton.disabled = false; - } else { - addTeamButton.disabled = true; - } - } - }; - - updateAddTeamButton(); - - root.addEventListener("change", (event) => { - const { target } = event; +const removeForm = (button) => { + const $form = $(button).closest("[data-form]"); + const $deleteField = $form.find('input[name$="-DELETE"]'); + if ($deleteField.length) { + $deleteField.val("on"); + $form.addClass("d-none"); + } else { + $form.remove(); + } +}; - // Handle debater select change - if (target.matches("[data-debater-input]")) { - syncDebater(target); +const registerQuickActions = () => { + const $newSchoolInput = $("#new-school-name"); + $("#create-school-btn").on("click", () => { + const name = ($newSchoolInput.val() || "").trim(); + if (!name) { + alert("Please enter a school name"); return; } - - if (target.matches("[data-school-select]")) { - toggleNew(target); - updateDebaters(target); - - // Update the Add Team button and propagate the selection when the main - // school dropdown changes. - if (target.matches('[data-school-select="registration"]')) { - updateAddTeamButton(); - - // Propagate main school options and value to every team school select - queryAll('[data-form="team"]').forEach((teamForm) => { - queryAll("[data-school-select]", teamForm).forEach((selectEl) => { - const schoolSelect = selectEl; - schoolSelect.innerHTML = ""; - queryAll("option", target).forEach((option) => { - const newOption = option.cloneNode(true); - schoolSelect.appendChild(newOption); - }); - - if ( - (!schoolSelect.value || schoolSelect.value === "") && - target.value && - target.value !== NEW && - target.value !== "" - ) { - schoolSelect.value = target.value; - toggleNew(schoolSelect); - if (schoolSelect.dataset.listId) { - updateDebaters(schoolSelect); - } - } - }); - }); - } - } - if (target.matches("[data-team-seed]")) { - target.dataset.autoset = "false"; - } + customSchools.push(name); + const value = `custom:-${customSchools.length}`; + $("[data-school-select]").each((_, el) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = name; + el.append(option); + }); + $newSchoolInput.val(""); + alert(`School "${name}" added.`); }); - root.addEventListener( - "input", - (event) => - event.target.matches("[data-debater-input]") && syncDebater(event.target), - ); - root.addEventListener("click", (event) => { - // Handle "Create New School" button - if (event.target.matches("[data-trigger-new]")) { - event.preventDefault(); - const selectId = event.target.getAttribute("data-trigger-new"); - const select = document.getElementById(selectId); - if (select) { - select.value = NEW; - toggleNew(select); - } - return; - } - const addType = event.target.getAttribute("data-add-form"); - if (addType) { - event.preventDefault(); - addForm(addType, maxTeams); + $("#create-debater-btn").on("click", () => { + const $school = $("#new-debater-school"); + const schoolValue = $school.val(); + const name = ($("#new-debater-name").val() || "").trim(); + const status = $("#new-debater-status").val() || "0"; + if (!schoolValue) { + alert("Please select a school"); return; } - const removeType = event.target.getAttribute("data-remove-form"); - if (!removeType) { + if (!name) { + alert("Please enter a debater name"); return; } - event.preventDefault(); - const form = event.target.closest(`[data-form="${removeType}"]`); - if (!form) { - return; - } - const deleteField = form.querySelector('input[name$="-DELETE"]'); - if (deleteField) { - deleteField.value = "on"; + const debater = { + id: -Date.now(), + apda_id: -Date.now(), + name, + full_name: name, + status: status === "1" ? "Novice" : "Varsity", + qualified: false, + }; + if (!customDebaters[schoolValue]) { + customDebaters[schoolValue] = []; } - form.classList.add("d-none"); + customDebaters[schoolValue].push(debater); + broadcastCustomDebater(schoolValue, debater); + $("#new-debater-name").val(""); + $("#new-debater-status").val("0"); + alert(`Debater "${name}" added.`); }); +}; - // Store custom schools and debaters globally for reuse inside - // updateDebaters. - window.customSchools = []; - window.customDebaters = {}; - - // Create new school handler - const createSchoolBtn = byId("create-school-btn"); - const newSchoolNameInput = byId("new-school-name"); - - if (createSchoolBtn && newSchoolNameInput) { - createSchoolBtn.addEventListener("click", () => { - const schoolName = newSchoolNameInput.value.trim(); - if (!schoolName) { - alert("Please enter a school name"); - return; +const initNewDebaterSelect = () => { + const $mainSchool = $("[data-school-select='registration']").first(); + const $target = $("#new-debater-school"); + if (!$mainSchool.length || !$target.length) return; + const sync = () => { + const current = $target.val(); + $target.empty().append(''); + $mainSchool.find("option").each((_, opt) => { + if (opt.value) { + $target.append($("'; - debaterSelect.appendChild(option); - } - } - } - } - }); + $root.find("[data-school-select]").each((_, el) => { + configureSchoolSelect($(el)); + }); - // Clear the inputs - newDebaterName.value = ""; - newDebaterStatus.value = "0"; + toggleAddTeamButton(); + registerQuickActions(); + initNewDebaterSelect(); - // Show success message - alert(`Debater "${customDebaterName}" added successfully!`); - }); - } - - // Populate new-debater-school with schools from main dropdown on load - const mainSchoolSelect = queryAll('[data-school-select="registration"]')[0]; - if (mainSchoolSelect && newDebaterSchool) { - const syncNewDebaterSchools = () => { - const currentValue = newDebaterSchool.value; - newDebaterSchool.innerHTML = ''; + $root.on("change", "[data-school-select]", function onSchoolChange() { + const $select = $(this); + configureSchoolSelect($select); + if ($select.is('[data-school-select="registration"]')) { + toggleAddTeamButton(); + } + }); - queryAll("option", mainSchoolSelect).forEach((opt) => { - if (opt.value && opt.value !== "") { - const option = document.createElement("option"); - option.value = opt.value; - option.textContent = opt.textContent; - newDebaterSchool.appendChild(option); - } - }); + $root.on("change", "[data-debater-input]", function onDebChange() { + syncDebater(this); + }); - // Restore value if still available - if (currentValue) { - newDebaterSchool.value = currentValue; - } - }; + $root.on("click", "[data-add-form]", function onAddClick(event) { + event.preventDefault(); + addForm($(this).data("addForm"), maxTeams); + }); - // Sync on load - syncNewDebaterSchools(); + $root.on("click", "[data-remove-form]", function onRemoveClick(event) { + event.preventDefault(); + removeForm(this); + }); - // Re-sync when schools are added - const observer = new MutationObserver(syncNewDebaterSchools); - observer.observe(mainSchoolSelect, { childList: true }); - } -} + $root.on("click", "[data-trigger-new]", function triggerNew(event) { + event.preventDefault(); + const target = $(this).data("triggerNew"); + const $select = $(`#${target}`); + if ($select.length) { + $select.val(NEW_VALUE).trigger("change"); + } + }); +}); diff --git a/mittab/apps/registration/admin.py b/mittab/apps/registration/admin.py index 0e3c85320..e054e7de6 100644 --- a/mittab/apps/registration/admin.py +++ b/mittab/apps/registration/admin.py @@ -1,28 +1,22 @@ from django.contrib import admin -from .models import ( - RegistrationConfig, - Registration, - RegistrationJudge, - RegistrationContent, - RegistrationTeam, - RegistrationTeamMember, -) +from mittab.apps.tab.models import Judge, Team + +from .models import Registration, RegistrationConfig, RegistrationContent class RegistrationTeamInline(admin.TabularInline): - model = RegistrationTeam + model = Team + fk_name = "registration" extra = 0 + fields = ("name", "seed", "school", "hybrid_school") class RegistrationJudgeInline(admin.TabularInline): - model = RegistrationJudge - extra = 0 - - -class RegistrationTeamMemberInline(admin.TabularInline): - model = RegistrationTeamMember + model = Judge + fk_name = "registration" extra = 0 + fields = ("name", "rank", "email") @admin.register(Registration) @@ -40,9 +34,3 @@ class RegistrationConfigAdmin(admin.ModelAdmin): @admin.register(RegistrationContent) class RegistrationContentAdmin(admin.ModelAdmin): list_display = ("updated_at",) - - -@admin.register(RegistrationTeam) -class RegistrationTeamAdmin(admin.ModelAdmin): - list_display = ("registration", "team", "is_free_seed") - inlines = (RegistrationTeamMemberInline,) diff --git a/mittab/apps/registration/forms.py b/mittab/apps/registration/forms.py index 46d051143..eea2bee32 100644 --- a/mittab/apps/registration/forms.py +++ b/mittab/apps/registration/forms.py @@ -4,7 +4,44 @@ from mittab.apps.tab.models import Team NEW_CHOICE_VALUE = "__new__" -NOVICE_CHOICES = ((0, "Varsity"), (1, "Novice")) +DEBATER_PREFIXES = ("debater_one", "debater_two") + + +def _build_debater_fields(): + fields = {} + for prefix in DEBATER_PREFIXES: + fields[f"{prefix}_id"] = forms.IntegerField( + required=False, widget=forms.HiddenInput + ) + fields[f"{prefix}_name"] = forms.CharField(max_length=30) + fields[f"{prefix}_apda_id"] = forms.IntegerField( + required=False, widget=forms.HiddenInput + ) + fields[f"{prefix}_novice_status"] = forms.IntegerField( + required=False, widget=forms.HiddenInput + ) + fields[f"{prefix}_qualified"] = forms.BooleanField( + required=False, widget=forms.HiddenInput + ) + fields[f"{prefix}_school"] = forms.CharField() + fields[f"{prefix}_school_name"] = forms.CharField( + required=False, max_length=50 + ) + return fields + + +class ValueMixin: + def _current_value(self, field_name): + if self.is_bound: + return self.data.get(self.add_prefix(field_name), "") + return self.initial.get(field_name, "") + + @staticmethod + def _reveal_input(widget): + classes = widget.attrs.get("class", "") + widget.attrs["class"] = " ".join( + part for part in classes.split() if part != "d-none" + ).strip() def parse_school(value, name): @@ -34,7 +71,7 @@ def parse_school(value, name): raise forms.ValidationError("Invalid school choice") -class RegistrationForm(forms.Form): +class RegistrationForm(ValueMixin, forms.Form): school = forms.CharField() school_name = forms.CharField(required=False, max_length=50) email = forms.EmailField(max_length=254) @@ -69,116 +106,74 @@ def __init__(self, *args, school_choices=None, **kwargs): self._reveal_input(self.fields["school_name"].widget) self.fields["email"].widget.attrs.update({"class": "form-control"}) - def _current_value(self, field_name): - if self.is_bound: - return self.data.get(self.add_prefix(field_name), "") - return self.initial.get(field_name, "") - - def _reveal_input(self, widget): - classes = widget.attrs.get("class", "") - widget.attrs["class"] = " ".join( - part for part in classes.split() if part != "d-none" - ).strip() - def get_school(self): value = self.cleaned_data["school"] label = self.cleaned_data.get("school_name") or self.choice_map.get(value) return parse_school(value, label) -class TeamForm(forms.Form): - registration_team_id = forms.IntegerField(required=False, widget=forms.HiddenInput) +class TeamForm(ValueMixin, forms.Form): team_id = forms.IntegerField(required=False, widget=forms.HiddenInput) name = forms.CharField(max_length=30) - is_free_seed = forms.BooleanField(required=False) seed_choice = forms.TypedChoiceField( choices=[ (Team.UNSEEDED, "Unseeded"), + (Team.FREE_SEED, "Free Seed"), (Team.HALF_SEED, "Half Seed"), (Team.FULL_SEED, "Full Seed"), ], coerce=int, initial=Team.UNSEEDED, ) - debater_one_id = forms.IntegerField(required=False, widget=forms.HiddenInput) - debater_one_name = forms.CharField(max_length=30) - debater_one_apda_id = forms.IntegerField(required=False, widget=forms.HiddenInput) - debater_one_novice_status = forms.IntegerField( - required=False, widget=forms.HiddenInput + team_school_source = forms.ChoiceField( + choices=[("debater_one", "Debater One"), ("debater_two", "Debater Two")], + initial="debater_one", ) - debater_one_qualified = forms.BooleanField(required=False, widget=forms.HiddenInput) - debater_one_school = forms.CharField() - debater_one_school_name = forms.CharField(required=False, max_length=50) - debater_two_id = forms.IntegerField(required=False, widget=forms.HiddenInput) - debater_two_name = forms.CharField(max_length=30) - debater_two_apda_id = forms.IntegerField(required=False, widget=forms.HiddenInput) - debater_two_novice_status = forms.IntegerField( - required=False, widget=forms.HiddenInput + hybrid_school_source = forms.ChoiceField( + choices=[ + ("none", "No hybrid school"), + ("debater_one", "Debater One"), + ("debater_two", "Debater Two"), + ], + initial="none", ) - debater_two_qualified = forms.BooleanField(required=False, widget=forms.HiddenInput) - debater_two_school = forms.CharField() - debater_two_school_name = forms.CharField(required=False, max_length=50) DELETE = forms.BooleanField(required=False, widget=forms.HiddenInput) + locals().update(_build_debater_fields()) def __init__(self, *args, school_choices=None, **kwargs): super().__init__(*args, **kwargs) school_choices = school_choices or [] self.choice_map = dict(school_choices) base_choices = [("", "Select school")] + school_choices - control_fields = [ - "name", - ] - for field in control_fields: - self.fields[field].widget.attrs.setdefault("class", "form-control") + self.fields["name"].widget.attrs.setdefault("class", "form-control") self.fields["seed_choice"].widget.attrs.update( { "class": "form-control form-control-sm", "data-team-seed": "true", } ) - - # Configure debater name fields as Select widgets - self.fields["debater_one_name"].widget = forms.Select( - choices=[("", "Select a school first")] + self.fields["team_school_source"].widget.attrs.update( + {"class": "form-control form-control-sm"} ) - self.fields["debater_one_name"].widget.attrs.update( - { - "class": "form-control", - } - ) - self.fields["debater_two_name"].widget = forms.Select( - choices=[("", "Select a school first")] + self.fields["hybrid_school_source"].widget.attrs.update( + {"class": "form-control form-control-sm"} ) - self.fields["debater_two_name"].widget.attrs.update( - { - "class": "form-control", - } - ) - - self.fields["is_free_seed"].widget.attrs.setdefault("class", "form-check-input") - self.fields["debater_one_qualified"].widget.attrs.setdefault("value", "") - self.fields["debater_two_qualified"].widget.attrs.setdefault("value", "") - self.fields["debater_one_novice_status"].initial = 0 - self.fields["debater_two_novice_status"].initial = 0 - - first_value = self._current_value("debater_one_school") - second_value = self._current_value("debater_two_school") - self._configure_school_field( - "debater_one_school", - first_value, - base_choices, - self.fields["debater_one_school_name"].widget, - ) - self._configure_school_field( - "debater_two_school", - second_value, - base_choices, - self.fields["debater_two_school_name"].widget, - ) - - self._configure_debater_inputs("debater_one") - self._configure_debater_inputs("debater_two") + for prefix in DEBATER_PREFIXES: + name_field = f"{prefix}_name" + self.fields[name_field].widget = forms.Select( + choices=[("", "Select a school first")] + ) + self.fields[name_field].widget.attrs["class"] = "form-control" + self.fields[f"{prefix}_qualified"].widget.attrs.setdefault("value", "") + self.fields[f"{prefix}_novice_status"].initial = 0 + self._configure_school_field( + f"{prefix}_school", + self._current_value(f"{prefix}_school"), + base_choices, + self.fields[f"{prefix}_school_name"].widget, + ) + self._configure_debater_inputs(prefix) def clean(self): data = super().clean() @@ -186,51 +181,44 @@ def clean(self): return data if not data.get("debater_one_name") or not data.get("debater_two_name"): raise forms.ValidationError("Each team needs two debaters") + primary = data.get("team_school_source") or "debater_one" + if not data.get(f"{primary}_school"): + raise forms.ValidationError("Select a school for the primary debater") + hybrid = data.get("hybrid_school_source") or "none" + if hybrid != "none" and not data.get(f"{hybrid}_school"): + raise forms.ValidationError("Select a school for the hybrid designation") + data["team_school_source"] = primary + data["hybrid_school_source"] = hybrid return data + def _member_payload(self, prefix): + school_value = self.cleaned_data[f"{prefix}_school"] + school_label = ( + self.cleaned_data.get(f"{prefix}_school_name") + or self.choice_map.get(school_value) + ) + return { + "id": self.cleaned_data.get(f"{prefix}_id"), + "name": self.cleaned_data[f"{prefix}_name"], + "apda_id": self.cleaned_data.get(f"{prefix}_apda_id"), + "novice_status": int(self.cleaned_data[f"{prefix}_novice_status"]), + "qualified": bool(self.cleaned_data.get(f"{prefix}_qualified")), + "school": parse_school(school_value, school_label), + } + def get_members(self): - return [ - { - "id": self.cleaned_data.get("debater_one_id"), - "name": self.cleaned_data["debater_one_name"], - "apda_id": self.cleaned_data.get("debater_one_apda_id"), - "novice_status": int(self.cleaned_data["debater_one_novice_status"]), - "qualified": bool(self.cleaned_data.get("debater_one_qualified")), - "school": parse_school( - self.cleaned_data["debater_one_school"], - self.cleaned_data.get("debater_one_school_name") - or self.choice_map.get(self.cleaned_data["debater_one_school"]), - ), - }, - { - "id": self.cleaned_data.get("debater_two_id"), - "name": self.cleaned_data["debater_two_name"], - "apda_id": self.cleaned_data.get("debater_two_apda_id"), - "novice_status": int(self.cleaned_data["debater_two_novice_status"]), - "qualified": bool(self.cleaned_data.get("debater_two_qualified")), - "school": parse_school( - self.cleaned_data["debater_two_school"], - self.cleaned_data.get("debater_two_school_name") - or self.choice_map.get(self.cleaned_data["debater_two_school"]), - ), - }, - ] + return [self._member_payload(prefix) for prefix in DEBATER_PREFIXES] def get_payload(self): return { - "registration_team_id": self.cleaned_data.get("registration_team_id"), "team_id": self.cleaned_data.get("team_id"), "name": self.cleaned_data["name"], - "is_free_seed": bool(self.cleaned_data.get("is_free_seed")), "seed_choice": int(self.cleaned_data["seed_choice"]), "members": self.get_members(), + "team_school_source": self.cleaned_data["team_school_source"], + "hybrid_school_source": self.cleaned_data["hybrid_school_source"], } - def _current_value(self, field_name): - if self.is_bound: - return self.data.get(self.add_prefix(field_name), "") - return self.initial.get(field_name, "") - def _configure_school_field(self, field_name, current, base_choices, name_widget): field = self.fields[field_name] choices_list = list(base_choices) @@ -249,8 +237,6 @@ def _configure_school_field(self, field_name, current, base_choices, name_widget "data-school-select": "team", } ) - list_id = f"{self.prefix}-{field_name.replace('_school', '')}-options" - field.widget.attrs["data-list-id"] = list_id name_id = self[field_name.replace("_school", "_name")].auto_id field.widget.attrs["data-name-id"] = name_id name_widget.attrs.update( @@ -268,26 +254,15 @@ def _configure_school_field(self, field_name, current, base_choices, name_widget def _configure_debater_inputs(self, prefix): name_field = f"{prefix}_name" apda_field = f"{prefix}_apda_id" - list_id = f"{self.prefix}-{prefix.replace('_', '-')}-options" name_widget = self.fields[name_field].widget - # For select widgets we do not need autocomplete or list attributes for - # functionality, but we set them so the template can access the list ID. name_widget.attrs.update( { "data-debater-input": prefix, "data-apda-target": self[apda_field].auto_id, - "data-list": list_id, - "list": list_id, # Also set without data- prefix for template access } ) self.fields[apda_field].widget.attrs.setdefault("id", self[apda_field].auto_id) - def _reveal_input(self, widget): - classes = widget.attrs.get("class", "") - widget.attrs["class"] = " ".join( - part for part in classes.split() if part != "d-none" - ).strip() - class JudgeForm(forms.Form): registration_judge_id = forms.IntegerField(required=False, widget=forms.HiddenInput) diff --git a/mittab/apps/registration/migrations/0001_initial.py b/mittab/apps/registration/migrations/0001_initial.py index 4c8d5ee59..ce3bc55ec 100644 --- a/mittab/apps/registration/migrations/0001_initial.py +++ b/mittab/apps/registration/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.25 on 2025-11-10 21:50 +# Generated by Django 3.2.25 on 2025-11-11 21:32 from django.db import migrations, models import django.db.models.deletion @@ -9,21 +9,10 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('tab', '0032_auto_20251110_2150'), + ('tab', '0031_remove_noshow_lenient_late'), ] operations = [ - migrations.CreateModel( - name='Registration', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('email', models.EmailField(max_length=254)), - ('herokunator_code', models.CharField(blank=True, max_length=255, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('school', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tab.school')), - ], - ), migrations.CreateModel( name='RegistrationConfig', fields=[ @@ -44,30 +33,14 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='RegistrationTeam', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_free_seed', models.BooleanField(default=False)), - ('registration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='registration.registration')), - ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tab.team')), - ], - ), - migrations.CreateModel( - name='RegistrationTeamMember', + name='Registration', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('position', models.IntegerField(default=0)), - ('debater', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tab.debater')), - ('registration_team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='registration.registrationteam')), + ('email', models.EmailField(max_length=254)), + ('herokunator_code', models.CharField(blank=True, max_length=255, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ('school', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tab.school')), ], ), - migrations.CreateModel( - name='RegistrationJudge', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tab.judge')), - ('registration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='judges', to='registration.registration')), - ], - ), ] diff --git a/mittab/apps/registration/models.py b/mittab/apps/registration/models.py index 01477978f..2e9517795 100644 --- a/mittab/apps/registration/models.py +++ b/mittab/apps/registration/models.py @@ -1,6 +1,6 @@ from haikunator import Haikunator from django.db import models -from mittab.apps.tab.models import School, Team, Judge, Debater +from mittab.apps.tab.models import School class RegistrationConfig(models.Model): @@ -58,27 +58,3 @@ def save(self, *args, **kwargs): code = haikunator.haikunate(token_length=0) self.herokunator_code = code super().save(*args, **kwargs) - - -class RegistrationTeam(models.Model): - registration = models.ForeignKey(Registration, - related_name="teams", - on_delete=models.CASCADE) - team = models.ForeignKey(Team, on_delete=models.CASCADE) - is_free_seed = models.BooleanField(default=False) - - -class RegistrationJudge(models.Model): - registration = models.ForeignKey(Registration, - related_name="judges", - on_delete=models.CASCADE) - judge = models.ForeignKey(Judge, on_delete=models.CASCADE) - - -class RegistrationTeamMember(models.Model): - registration_team = models.ForeignKey(RegistrationTeam, - related_name="members", - on_delete=models.CASCADE) - debater = models.ForeignKey(Debater, on_delete=models.CASCADE) - school = models.ForeignKey(School, on_delete=models.CASCADE) - position = models.IntegerField(default=0) diff --git a/mittab/apps/registration/tests/test_registration_flow.py b/mittab/apps/registration/tests/test_registration_flow.py index 43fb4daa2..c1c9de1fa 100644 --- a/mittab/apps/registration/tests/test_registration_flow.py +++ b/mittab/apps/registration/tests/test_registration_flow.py @@ -2,7 +2,8 @@ import pytest -from mittab.apps.registration.models import Registration, RegistrationTeamMember +from mittab.apps.registration.forms import DEBATER_PREFIXES +from mittab.apps.registration.models import Registration from mittab.apps.registration.views import MAX_TEAMS from mittab.apps.tab.models import Team @@ -17,112 +18,120 @@ def base_management(prefix, total, initial=0): } -@pytest.mark.django_db -def test_registration_flow_creates_objects(client): - data = { - "school": "apda:123", - "school_name": "Registration U", - "email": "contact@example.com", +def speaker(name, school, school_name, apda_id=""): + return { + "id": "", + "name": name, + "apda_id": apda_id, + "novice_status": "0", + "school": school, + "school_name": school_name, + } + + +def team_entry(index, name, speakers, seed_choice=Team.UNSEEDED, + team_school_source="debater_one", hybrid_school_source="none"): + base = { + f"teams-{index}-team_id": "", + f"teams-{index}-name": name, + f"teams-{index}-seed_choice": str(seed_choice), + f"teams-{index}-DELETE": "", + f"teams-{index}-team_school_source": team_school_source, + f"teams-{index}-hybrid_school_source": hybrid_school_source, } - data.update(base_management("teams", 1)) - data.update(base_management("judges", 1)) - data.update( + base.update( { - "teams-0-registration_team_id": "", - "teams-0-team_id": "", - "teams-0-name": "Registration U A", - "teams-0-is_free_seed": "on", - "teams-0-seed_choice": str(Team.FULL_SEED), - "teams-0-debater_one_id": "", - "teams-0-debater_one_name": "Registration U Speaker 1", - "teams-0-debater_one_apda_id": "1000", - "teams-0-debater_one_novice_status": "0", - "teams-0-debater_one_school": "apda:123", - "teams-0-debater_one_school_name": "Registration U", - "teams-0-debater_two_id": "", - "teams-0-debater_two_name": "Registration U Speaker 2", - "teams-0-debater_two_apda_id": "", - "teams-0-debater_two_novice_status": "0", - "teams-0-debater_two_school": "__new__", - "teams-0-debater_two_school_name": "Hybrid School", - "teams-0-DELETE": "", - "judges-0-registration_judge_id": "", - "judges-0-judge_id": "", - "judges-0-name": "Reg Judge", - "judges-0-email": "judge@example.com", - "judges-0-experience": "7", - "judges-0-DELETE": "", + f"teams-{index}-{prefix}_{field}": value + for prefix, details in zip(DEBATER_PREFIXES, speakers) + for field, value in details.items() } ) + return base + + +def judge_entry(index, name, email, experience): + return { + f"judges-{index}-registration_judge_id": "", + f"judges-{index}-judge_id": "", + f"judges-{index}-name": name, + f"judges-{index}-email": email, + f"judges-{index}-experience": str(experience), + f"judges-{index}-DELETE": "", + } + + +def registration_payload(school, school_name, email, teams, judges): + data = {"school": school, "school_name": school_name, "email": email} + data.update(base_management("teams", len(teams))) + data.update(base_management("judges", len(judges))) + for entry in teams + judges: + data.update(entry) + return data + + +@pytest.mark.django_db +def test_registration_flow_creates_objects(client): + teams = [ + team_entry( + 0, + "Registration U A", + [ + speaker( + "Registration U Speaker 1", + "apda:123", + "Registration U", + apda_id="1000", + ), + speaker("Registration U Speaker 2", "__new__", "Hybrid School"), + ], + seed_choice=Team.FREE_SEED, + hybrid_school_source="debater_two", + ) + ] + judges = [judge_entry(0, "Reg Judge", "judge@example.com", 7)] + data = registration_payload( + "apda:123", "Registration U", "contact@example.com", teams, judges + ) response = client.post("/registration/", data=data, follow=True) assert response.status_code == 200 registration = Registration.objects.first() assert registration.herokunator_code in response.request["PATH_INFO"] assert registration.email == "contact@example.com" - reg_team = registration.teams.select_related("team").first() - team = reg_team.team + team = ( + registration.teams.select_related("school", "hybrid_school") + .prefetch_related("debaters") + .first() + ) assert team.name == "Registration U A" assert team.seed == Team.FREE_SEED - members = list( - RegistrationTeamMember.objects.filter(registration_team=reg_team) - .select_related("school") - .order_by("position") - ) - assert members[0].school == registration.school - assert members[1].school.name == "Hybrid School" - judge_relation = registration.judges.select_related("judge").first() - assert judge_relation.judge.name == "Reg Judge" - assert judge_relation.judge.rank == Decimal("7") - assert judge_relation.judge.email == "judge@example.com" + assert team.school == registration.school + assert team.hybrid_school.name == "Hybrid School" + debater_schools = { + d.name: (d.school.name if d.school else None) for d in team.debaters.all() + } + assert debater_schools["Registration U Speaker 1"] == "Registration U" + assert debater_schools["Registration U Speaker 2"] == "Hybrid School" + judge = registration.judges.first() + assert judge.name == "Reg Judge" + assert judge.rank == Decimal("7") + assert judge.email == "judge@example.com" @pytest.mark.django_db def test_registration_requires_single_free_seed(client): - data = { - "school": "apda:50", - "school_name": "Test School", - "email": "team@example.com", - } - data.update(base_management("teams", 2)) - data.update(base_management("judges", 0)) - data.update( - { - "teams-0-registration_team_id": "", - "teams-0-team_id": "", - "teams-0-name": "Team One", - "teams-0-seed_choice": str(Team.UNSEEDED), - "teams-0-debater_one_id": "", - "teams-0-debater_one_name": "Speaker 1", - "teams-0-debater_one_apda_id": "", - "teams-0-debater_one_novice_status": "0", - "teams-0-debater_one_school": "apda:50", - "teams-0-debater_one_school_name": "Test School", - "teams-0-debater_two_id": "", - "teams-0-debater_two_name": "Speaker 2", - "teams-0-debater_two_apda_id": "", - "teams-0-debater_two_novice_status": "0", - "teams-0-debater_two_school": "apda:50", - "teams-0-debater_two_school_name": "Test School", - "teams-0-DELETE": "", - "teams-1-registration_team_id": "", - "teams-1-team_id": "", - "teams-1-name": "Team Two", - "teams-1-seed_choice": str(Team.UNSEEDED), - "teams-1-debater_one_id": "", - "teams-1-debater_one_name": "Speaker 3", - "teams-1-debater_one_apda_id": "", - "teams-1-debater_one_novice_status": "0", - "teams-1-debater_one_school": "apda:50", - "teams-1-debater_one_school_name": "Test School", - "teams-1-debater_two_id": "", - "teams-1-debater_two_name": "Speaker 4", - "teams-1-debater_two_apda_id": "", - "teams-1-debater_two_novice_status": "0", - "teams-1-debater_two_school": "apda:50", - "teams-1-debater_two_school_name": "Test School", - "teams-1-DELETE": "", - } - ) + speakers = [ + speaker("Speaker 1", "apda:50", "Test School"), + speaker("Speaker 2", "apda:50", "Test School"), + ] + more_speakers = [ + speaker("Speaker 3", "apda:50", "Test School"), + speaker("Speaker 4", "apda:50", "Test School"), + ] + teams = [ + team_entry(0, "Team One", speakers), + team_entry(1, "Team Two", more_speakers), + ] + data = registration_payload("apda:50", "Test School", "team@example.com", teams, []) response = client.post("/registration/", data=data) assert response.status_code == 200 assert b"Select at most one free seed" in response.content diff --git a/mittab/apps/registration/views.py b/mittab/apps/registration/views.py index d9b6a2c3f..0ce12ee2c 100644 --- a/mittab/apps/registration/views.py +++ b/mittab/apps/registration/views.py @@ -22,17 +22,10 @@ from mittab.apps.tab.models import Debater, Judge, School, Team from mittab.apps.tab.helpers import redirect_and_flash_success from mittab.libs.cacheing.public_cache import invalidate_all_public_caches -from .models import ( - Registration, - RegistrationConfig, - RegistrationContent, - RegistrationJudge, - RegistrationTeam, - RegistrationTeamMember, -) +from .models import Registration, RegistrationConfig, RegistrationContent -SCHOOL_ACTIVE_URL = "https://results.apda.online/api/schools/" -SCHOOL_ALL_URL = "https://results.apda.online/api/schools/all/" +SCHOOL_ACTIVE_URL = "https://results.apda.online/api/schools/all/" +SCHOOL_ALL_URL = SCHOOL_ACTIVE_URL MAX_TEAMS = 200 @@ -56,13 +49,6 @@ def clean(self): ] if not active: raise forms.ValidationError("Add at least one team") - free = sum(1 for form in active if form.cleaned_data.get("is_free_seed")) - if free > 1: - raise forms.ValidationError("Select at most one free seed") - - -class RegistrationJudgeFormSet(BaseFormSet): - pass TeamFormSet = cast( @@ -75,47 +61,33 @@ class RegistrationJudgeFormSet(BaseFormSet): max_num=MAX_TEAMS, ), ) -JudgeFormSet = formset_factory( - JudgeForm, - formset=RegistrationJudgeFormSet, - extra=0, - can_delete=True, -) +JudgeFormSet = formset_factory(JudgeForm, extra=0, can_delete=True) -def fetch_remote_schools(active_only=True): - """ - Fetch schools from the API. - If active_only is True, only fetches from the active schools endpoint. - If False, fetches from both active and all schools endpoints. - """ +def fetch_remote_schools(): + """Fetch schools from the API.""" results = [] seen = set() - urls = (SCHOOL_ACTIVE_URL,) if active_only else (SCHOOL_ACTIVE_URL, SCHOOL_ALL_URL) - for url in urls: - try: - response = requests.get(url, timeout=5) - if not response.ok: - continue - data = response.json() - except (ValueError, RequestException): + try: + response = requests.get(SCHOOL_ACTIVE_URL, timeout=5) + response.raise_for_status() + items = response.json() + except (ValueError, RequestException): + items = [] + for item in items if isinstance(items, list) else items.get("schools", []): + name = item.get("name") + apda_id = item.get("id") if "id" in item else item.get("apda_id") + if not name or apda_id is None or apda_id in seen: continue - items = data if isinstance(data, list) else data.get("schools", []) - for item in items: - name = item.get("name") - apda_id = item.get("id") if "id" in item else item.get("apda_id") - if not name or apda_id is None or apda_id in seen: - continue - seen.add(apda_id) - results.append({"id": apda_id, "name": name}) + seen.add(apda_id) + results.append({"id": apda_id, "name": name}) return results -def build_school_choices(active_only=False): - """Build school choices for the dropdown.""" +def build_school_choices(): return [ (f"apda:{school['id']}", school["name"]) - for school in fetch_remote_schools(active_only) + for school in fetch_remote_schools() ] @@ -127,39 +99,34 @@ def school_value(school): return NEW_CHOICE_VALUE -def registration_team_initial(reg_team): - team = reg_team.team - members = list( - reg_team.members.select_related("debater", "school").order_by("position") - ) - defaults = [{"debater": None, "school": None}, {"debater": None, "school": None}] - for member in members: - if member.position in (0, 1): - defaults[member.position] = { - "debater": member.debater, - "school": member.school, - } +def registration_team_initial(team): + members = list(team.debaters.all()) + defaults = [{"debater": None, "school": None} for _ in range(2)] for index in range(2): - if defaults[index]["debater"] is None: - debater = ( - team.debaters.all()[index] if team.debaters.count() > index else None - ) - defaults[index]["debater"] = debater - defaults[index]["school"] = ( - team.hybrid_school if index == 1 else team.school - ) + debater = members[index] if len(members) > index else None + defaults[index] = { + "debater": debater, + "school": getattr(debater, "school", None), + } first_school = defaults[0]["school"] second_school = defaults[1]["school"] + primary_source = "debater_one" + if team.school and second_school and team.school.pk == second_school.pk: + primary_source = "debater_two" + elif team.school and first_school and team.school.pk == first_school.pk: + primary_source = "debater_one" + hybrid_source = "none" + if team.hybrid_school: + if first_school and team.hybrid_school.pk == first_school.pk: + hybrid_source = "debater_one" + elif second_school and team.hybrid_school.pk == second_school.pk: + hybrid_source = "debater_two" return { - "registration_team_id": reg_team.pk, "team_id": team.pk, "name": team.name, - "is_free_seed": reg_team.is_free_seed, - "seed_choice": ( - team.seed - if team.seed in (Team.UNSEEDED, Team.HALF_SEED, Team.FULL_SEED) - else Team.UNSEEDED - ), + "seed_choice": team.seed, + "team_school_source": primary_source, + "hybrid_school_source": hybrid_source, "debater_one_id": defaults[0]["debater"].pk if defaults[0]["debater"] else None, "debater_one_name": ( defaults[0]["debater"].name if defaults[0]["debater"] else "" @@ -193,10 +160,9 @@ def registration_team_initial(reg_team): } -def registration_judge_initial(reg_judge): - judge = reg_judge.judge +def registration_judge_initial(judge): return { - "registration_judge_id": reg_judge.pk, + "registration_judge_id": judge.pk, "judge_id": judge.pk, "name": judge.name, "email": judge.email, @@ -225,13 +191,13 @@ def get_registration_forms(request, registration, school_choices): ) teams_initial = [ registration_team_initial(team) - for team in registration.teams.select_related("team").prefetch_related( - "team__debaters", "members__debater", "members__school" - ) + for team in registration.teams.select_related( + "school", "hybrid_school" + ).prefetch_related("debaters") ] judges_initial = [ - registration_judge_initial(reg) - for reg in registration.judges.select_related("judge") + registration_judge_initial(judge) + for judge in registration.judges.all() ] else: reg_form = RegistrationForm(school_choices=school_choices) @@ -246,71 +212,52 @@ def get_registration_forms(request, registration, school_choices): return reg_form, team_formset, judge_formset -def resolve_school(selection, cache): +def resolve_school(selection, cache=None): + if not selection: + raise forms.ValidationError("Select a school") + cache = cache or {} key = tuple(sorted(selection.items())) if key in cache: return cache[key] if "pk" in selection: - school = School.objects.select_for_update().filter(pk=selection["pk"]).first() - if not school: - raise forms.ValidationError("Unknown school") - cache[key] = school - return school - if "apda_id" in selection: - school = ( - School.objects.select_for_update() - .filter(apda_id=selection["apda_id"]) - .first() - ) + school = School.objects.filter(pk=selection["pk"]).first() if school: cache[key] = school return school - school = School.objects.create( - name=selection.get("name", ""), apda_id=selection["apda_id"] + raise forms.ValidationError("Unknown school") + if "apda_id" in selection: + school, _ = School.objects.get_or_create( + apda_id=selection["apda_id"], + defaults={"name": selection.get("name", "")}, ) cache[key] = school return school - name = selection["name"] - school = School.objects.select_for_update().filter(name__iexact=name).first() - if school: - if school.apda_id in (None, 0): - school.apda_id = -1 - school.save() - cache[key] = school - return school - school = School.objects.create(name=name, apda_id=-1) + name = selection.get("name", "").strip() + if not name: + raise forms.ValidationError("Enter a school name") + school, _ = School.objects.get_or_create(name__iexact=name, defaults={"name": name}) cache[key] = school return school -def get_or_create_debater(data, team): - debater_id = data.get("id") - apda_id = data.get("apda_id") - name = data["name"].strip() +def get_or_create_debater(data, school): + name = (data["name"] or "").strip() if not name: raise forms.ValidationError("Debater name required") - queryset = Debater.objects.select_for_update() - debater = None - if debater_id: - debater = queryset.filter(pk=debater_id).first() - if not debater: - raise forms.ValidationError("Unknown debater") - elif apda_id not in (None, ""): - debater = queryset.filter(apda_id=apda_id).first() - if not debater: - debater = queryset.filter(name__iexact=name).first() - if not debater: - debater = Debater(name=name, novice_status=data["novice_status"], apda_id=-1) + debater, _ = Debater.objects.get_or_create( + name__iexact=name, + defaults={ + "name": name, + "novice_status": data["novice_status"], + "apda_id": data.get("apda_id") or -1, + }, + ) debater.name = name debater.novice_status = data["novice_status"] debater.qualified = data["qualified"] - debater.apda_id = apda_id if apda_id not in (None, "") else -1 + debater.apda_id = data.get("apda_id") or -1 + debater.school = school debater.save() - assignments = debater.team_set.exclude(pk=team.pk if team else None) - if assignments.exists(): - raise forms.ValidationError( - f"Debater {debater.name} is already on another team" - ) return debater @@ -322,180 +269,71 @@ def ensure_unique_team_name(team, name): raise forms.ValidationError(f"Team name {name} already exists") -def summarise_registration(registration): - if not registration: - return None - registration = ( - Registration.objects.select_related("school") - .prefetch_related( - Prefetch( - "teams", - queryset=RegistrationTeam.objects.select_related( - "team" - ).prefetch_related( - "team__debaters", - "members__debater", - "members__school", - ), - ), - "judges__judge", - ) - .get(pk=registration.pk) - ) - teams = [] - for reg_team in registration.teams.all(): - team = reg_team.team - debaters = [] - for member in reg_team.members.select_related("debater", "school").order_by( - "position" - ): - debaters.append(member.debater.name) - if not debaters: - debaters = [debater.name for debater in team.debaters.all()] - teams.append( - { - "name": team.name, - "is_free_seed": reg_team.is_free_seed, - "debaters": debaters, - } - ) - judges = [ - { - "name": reg.judge.name, - "code": reg.judge.ballot_code, - "email": reg.judge.email, - } - for reg in registration.judges.select_related("judge") - ] - return { - "code": registration.herokunator_code, - "email": registration.email, - "school": registration.school.name, - "teams": teams, - "judges": judges, - } - - def save_registration(reg_form, team_formset, judge_formset, registration): - school_cache = {} - main_school = resolve_school(reg_form.get_school(), school_cache) + school = resolve_school(reg_form.get_school()) registration = registration or Registration() - registration.school = main_school + registration.school = school registration.email = reg_form.cleaned_data["email"] registration.save() + saved_team_ids = [] for form in team_formset: if form.cleaned_data.get("DELETE"): continue payload = form.get_payload() - team_obj = ( - Team.objects.select_for_update().filter(pk=payload["team_id"]).first() - if payload.get("team_id") - else Team() - ) - ensure_unique_team_name(team_obj, payload["name"]) members = payload["members"] - first_school = resolve_school(members[0]["school"], school_cache) - if first_school.pk != main_school.pk: + member_schools = [resolve_school(member["school"]) for member in members] + primary_index = 0 if payload["team_school_source"] == "debater_one" else 1 + primary_school = member_schools[primary_index] + if primary_school != school: raise forms.ValidationError( - "The first debater must represent the registration school" + "The primary debater must represent the registration school" ) hybrid_school = None - member_instances = [] - for member_payload in members: - member_school = resolve_school(member_payload["school"], school_cache) - if member_school.pk != main_school.pk and not hybrid_school: - hybrid_school = member_school - debater = get_or_create_debater( - member_payload, team_obj if team_obj.pk else None + if payload["hybrid_school_source"] != "none": + hybrid_index = ( + 0 if payload["hybrid_school_source"] == "debater_one" else 1 ) - member_instances.append((debater, member_school)) - team_obj.school = main_school - team_obj.hybrid_school = hybrid_school - team_obj.name = payload["name"] - team_obj.seed = ( - Team.FREE_SEED if payload["is_free_seed"] else payload["seed_choice"] + hybrid_school = member_schools[hybrid_index] + team = ( + Team.objects.filter( + pk=payload.get("team_id"), registration=registration + ).first() + or Team(registration=registration) ) - team_obj.break_preference = Team.VARSITY - team_obj.checked_in = False - team_obj.save() - team_obj.debaters.set([debater for debater, _ in member_instances]) - reg_team = ( - RegistrationTeam.objects.select_for_update() - .filter(pk=payload.get("registration_team_id"), registration=registration) - .first() - ) - if not reg_team: - reg_team = RegistrationTeam.objects.create( - registration=registration, team=team_obj - ) - reg_team.is_free_seed = payload["is_free_seed"] - reg_team.team = team_obj - reg_team.save() - member_map = { - member.position: member for member in reg_team.members.select_for_update() - } - for index, (debater, school) in enumerate(member_instances): - member = member_map.pop(index, None) - if not member: - member = RegistrationTeamMember( - registration_team=reg_team, position=index - ) - member.debater = debater - member.school = school - member.save() - for leftover in member_map.values(): - leftover.delete() - saved_team_ids.append(reg_team.pk) - for reg_team in list(registration.teams.all()): - if reg_team.pk not in saved_team_ids: - team = reg_team.team - reg_team.members.all().delete() - reg_team.delete() - team.debaters.clear() - try: - team.delete() - except Exception: - pass + team.name = payload["name"] + team.seed = payload["seed_choice"] + team.school = primary_school + team.hybrid_school = hybrid_school + team.break_preference = Team.VARSITY + team.checked_in = False + team.save() + debaters = [ + get_or_create_debater(member, member_schools[index]) + for index, member in enumerate(members) + ] + team.debaters.set(debaters) + saved_team_ids.append(team.pk) + for team in registration.teams.exclude(pk__in=saved_team_ids): + team.debaters.clear() + team.delete() + saved_judge_ids = [] for form in judge_formset: if form.cleaned_data.get("DELETE"): continue payload = form.get_payload() - experience = payload["experience"] - if experience < 0 or experience > 10: - raise forms.ValidationError("Judge experience must be between 0 and 10") - judge = ( - Judge.objects.select_for_update().filter(pk=payload.get("judge_id")).first() - ) - if not judge: - judge = Judge(name=payload["name"], rank=experience) + judge = Judge.objects.filter( + pk=payload.get("judge_id"), registration=registration + ).first() or Judge(registration=registration) judge.name = payload["name"] - judge.rank = experience + judge.rank = payload["experience"] judge.email = payload["email"] judge.save() - relation = ( - registration.judges.select_for_update() - .filter(pk=payload.get("registration_judge_id"), registration=registration) - .first() - ) - if not relation: - relation = RegistrationJudge.objects.create( - registration=registration, judge=judge - ) - else: - relation.judge = judge - relation.save() - relation.judge.schools.set([registration.school]) - saved_judge_ids.append(relation.pk) - for reg_judge in list(registration.judges.all()): - if reg_judge.pk not in saved_judge_ids: - judge = reg_judge.judge - reg_judge.delete() - try: - judge.delete() - except Exception: - pass + judge.schools.set([registration.school]) + saved_judge_ids.append(judge.pk) + for judge in registration.judges.exclude(pk__in=saved_judge_ids): + judge.delete() return registration @@ -549,7 +387,21 @@ def registration_portal(request, code=None): "registration_portal_edit", args=[saved.herokunator_code] ) ) - summary = summarise_registration(registration) if registration else None + summary = None + if registration: + summary = ( + Registration.objects.select_related("school") + .prefetch_related( + Prefetch( + "teams", + queryset=Team.objects.select_related( + "school", "hybrid_school" + ).prefetch_related("debaters"), + ), + "judges", + ) + .get(pk=registration.pk) + ) context = { "registration_form": reg_form, "team_formset": team_formset, diff --git a/mittab/apps/tab/migrations/0032_auto_20251110_2150.py b/mittab/apps/tab/migrations/0032_auto_20251110_2150.py deleted file mode 100644 index 4fba194f8..000000000 --- a/mittab/apps/tab/migrations/0032_auto_20251110_2150.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.25 on 2025-11-10 21:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tab', '0031_remove_noshow_lenient_late'), - ] - - operations = [ - migrations.AddField( - model_name='debater', - name='qualified', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='judge', - name='email', - field=models.EmailField(blank=True, max_length=254), - ), - ] diff --git a/mittab/apps/tab/migrations/0032_auto_20251111_2132.py b/mittab/apps/tab/migrations/0032_auto_20251111_2132.py new file mode 100644 index 000000000..2381e51ec --- /dev/null +++ b/mittab/apps/tab/migrations/0032_auto_20251111_2132.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.25 on 2025-11-11 21:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('registration', '0001_initial'), + ('tab', '0031_remove_noshow_lenient_late'), + ] + + operations = [ + migrations.AddField( + model_name='debater', + name='qualified', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='debater', + name='school', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tab.school'), + ), + migrations.AddField( + model_name='judge', + name='email', + field=models.EmailField(blank=True, max_length=254), + ), + migrations.AddField( + model_name='judge', + name='registration', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='judges', to='registration.registration'), + ), + migrations.AddField( + model_name='team', + name='registration', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='registration.registration'), + ), + ] diff --git a/mittab/apps/tab/models.py b/mittab/apps/tab/models.py index 8e8531358..d4957d9f7 100644 --- a/mittab/apps/tab/models.py +++ b/mittab/apps/tab/models.py @@ -115,6 +115,10 @@ class Debater(models.Model): qualified = models.BooleanField(default=False) tiebreaker = models.IntegerField(unique=True, null=True, blank=True) apda_id = models.IntegerField(blank=True, null=True, default=-1) + school = models.ForeignKey("School", + null=True, + blank=True, + on_delete=models.SET_NULL) def save(self, force_insert=False, @@ -159,6 +163,13 @@ class Team(models.Model): on_delete=models.SET_NULL, related_name="hybrid_school") debaters = models.ManyToManyField(Debater) + registration = models.ForeignKey( + "registration.Registration", + related_name="teams", + null=True, + blank=True, + on_delete=models.CASCADE, + ) UNSEEDED = 0 FREE_SEED = 1 HALF_SEED = 2 @@ -331,6 +342,13 @@ class Judge(models.Model): rank = models.DecimalField(max_digits=4, decimal_places=2) email = models.EmailField(blank=True) schools = models.ManyToManyField(School) + registration = models.ForeignKey( + "registration.Registration", + related_name="judges", + null=True, + blank=True, + on_delete=models.CASCADE, + ) ballot_code = models.CharField(max_length=255, blank=True, null=True, diff --git a/mittab/apps/tab/templatetags/tags.py b/mittab/apps/tab/templatetags/tags.py index 978f37327..fcf65a99d 100644 --- a/mittab/apps/tab/templatetags/tags.py +++ b/mittab/apps/tab/templatetags/tags.py @@ -60,3 +60,12 @@ def registration_text(value, autoescape=True): return "" linked = urlize(value, nofollow=True, autoescape=autoescape) return mark_safe(linked.replace("\n", "
")) + + +@register.filter(name="get_field") +def get_field(form, field_name): + """Get a form field by name dynamically.""" + try: + return form[field_name] + except (KeyError, TypeError): + return None diff --git a/mittab/templates/registration/_debater_card.html b/mittab/templates/registration/_debater_card.html new file mode 100644 index 000000000..66781621f --- /dev/null +++ b/mittab/templates/registration/_debater_card.html @@ -0,0 +1,27 @@ +{% load tags %} +{% with name_key=prefix|add:"_name" school_key=prefix|add:"_school" %} +{% with name_field=form|get_field:name_key school_field=form|get_field:school_key %} +
+
+
+ {% if show_seed %} +
+
{{ title }}
+
+ + {{ form.seed_choice }} + {% if form.seed_choice.errors %} +
{{ form.seed_choice.errors }}
+ {% endif %} +
+
+ {% else %} +
{{ title }}
+ {% endif %} + {% include "registration/_input_group.html" with field=name_field label="Name" errors=name_field.errors label_style="font-size: 0.75rem;" margin="mb-1" %} + {% include "registration/_input_group.html" with field=school_field label="School" errors=school_field.errors label_style="font-size: 0.75rem;" margin="mb-1" %} +
+
+
+{% endwith %} +{% endwith %} diff --git a/mittab/templates/registration/_input_group.html b/mittab/templates/registration/_input_group.html new file mode 100644 index 000000000..f8a2d3ebf --- /dev/null +++ b/mittab/templates/registration/_input_group.html @@ -0,0 +1,9 @@ +
+
+ {{ label }} +
+ {{ field }} +
+{% if errors %} +
{{ errors }}
+{% endif %} diff --git a/mittab/templates/registration/_judge_form.html b/mittab/templates/registration/_judge_form.html index 4c81a6f58..4d4f5ac6b 100644 --- a/mittab/templates/registration/_judge_form.html +++ b/mittab/templates/registration/_judge_form.html @@ -3,31 +3,13 @@
{% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
-
-
- Name -
- {{ form.name }} -
- {{ form.name.errors }} + {% include "registration/_input_group.html" with field=form.name label="Name" errors=form.name.errors margin="" %}
-
-
- Email -
- {{ form.email }} -
- {{ form.email.errors }} + {% include "registration/_input_group.html" with field=form.email label="Email" errors=form.email.errors margin="" %}
-
-
- Experience -
- {{ form.experience }} -
- {{ form.experience.errors }} + {% include "registration/_input_group.html" with field=form.experience label="Experience" errors=form.experience.errors margin="" %}
diff --git a/mittab/templates/registration/_team_form.html b/mittab/templates/registration/_team_form.html index f6f1b19bc..092d82a18 100644 --- a/mittab/templates/registration/_team_form.html +++ b/mittab/templates/registration/_team_form.html @@ -9,94 +9,22 @@
Team Info
-
-
- Name -
- {{ form.name }} + {% include "registration/_input_group.html" with field=form.name label="Name" errors=form.name.errors %} +
+ + {{ form.team_school_source }} + {{ form.team_school_source.errors }}
- {% if form.name.errors %} -
{{ form.name.errors }}
- {% endif %} -
- {{ form.is_free_seed }} - {{ form.is_free_seed.label_tag }} - {{ form.is_free_seed.errors }} +
+ + {{ form.hybrid_school_source }} + {{ form.hybrid_school_source.errors }}
-
-
-
-
First Debater
- {{ form.debater_one_id }} -
-
- Name -
- {{ form.debater_one_name }} -
- {% if form.debater_one_name.errors %} -
{{ form.debater_one_name.errors }}
- {% endif %} -
-
- School -
- {{ form.debater_one_school }} -
- {% if form.debater_one_school.errors %} -
{{ form.debater_one_school.errors }}
- {% endif %} - - {{ form.debater_one_apda_id }} - {{ form.debater_one_novice_status }} - {{ form.debater_one_qualified }} - {{ form.debater_one_school_name }} -
-
-
-
-
-
-
-
Second Debater
-
- - {{ form.seed_choice }} - {% if form.seed_choice.errors %} -
{{ form.seed_choice.errors }}
- {% endif %} -
-
- {{ form.debater_two_id }} -
-
- Name -
- {{ form.debater_two_name }} -
- {% if form.debater_two_name.errors %} -
{{ form.debater_two_name.errors }}
- {% endif %} -
-
- School -
- {{ form.debater_two_school }} -
- {% if form.debater_two_school.errors %} -
{{ form.debater_two_school.errors }}
- {% endif %} - - {{ form.debater_two_apda_id }} - {{ form.debater_two_novice_status }} - {{ form.debater_two_qualified }} - {{ form.debater_two_school_name }} -
-
-
+ {% include "registration/_debater_card.html" with slot="first" title="First Debater" prefix="debater_one" show_seed=False %} + {% include "registration/_debater_card.html" with slot="second" title="Second Debater" prefix="debater_two" show_seed=True %}
diff --git a/mittab/templates/registration/portal.html b/mittab/templates/registration/portal.html index 3368c52cc..169e0fbde 100644 --- a/mittab/templates/registration/portal.html +++ b/mittab/templates/registration/portal.html @@ -9,27 +9,20 @@ {% render_bundle 'registrationPortal' %}
{% if content.description %} -
- -
+ {% endif %} {% if registration_lock_message %}
{{ registration_lock_message }}
@@ -48,24 +41,8 @@
School And Contact
{% for error in registration_form.non_field_errors %}
{{ error }}
{% endfor %} -
-
- School -
- {{ registration_form.school }} -
- {% if registration_form.school.errors %} -
{{ registration_form.school.errors }}
- {% endif %} -
-
- Email -
- {{ registration_form.email }} -
- {% if registration_form.email.errors %} -
{{ registration_form.email.errors }}
- {% endif %} + {% include "registration/_input_group.html" with field=registration_form.school label="School" errors=registration_form.school.errors margin="mb-2" size="" %} + {% include "registration/_input_group.html" with field=registration_form.email label="Email" errors=registration_form.email.errors margin="mb-2" size="" %}
@@ -162,7 +139,7 @@
Judges
{% if summary %} - Editing registration {{ summary.code }} + Editing registration {{ summary.herokunator_code }} {% endif %}
@@ -177,20 +154,20 @@
Judges
Registration Saved
-

Herokunator Code: {{ summary.code }}

-

School: {{ summary.school }}

+

Herokunator Code: {{ summary.herokunator_code }}

+

School: {{ summary.school.name }}

Email: {{ summary.email }}

Teams
- {% for team in summary.teams %} + {% for team in summary.teams.all %}
{{ team.name }} - {% if team.is_free_seed %} + {% if team.seed == team.FREE_SEED %} Free Seed {% endif %}
    - {% for debater in team.debaters %} -
  • {{ debater }}
  • + {% for debater in team.debaters.all %} +
  • {{ debater.name }}
  • {% endfor %}
@@ -198,9 +175,9 @@
Teams
Judges
- {% for judge in summary.judges %} + {% for judge in summary.judges.all %}
-

{{ judge.name }}{% if judge.code %} (Code: {{ judge.code }}){% endif %}

+

{{ judge.name }}{% if judge.ballot_code %} (Code: {{ judge.ballot_code }}){% endif %}

{% if judge.email %}

Email: {{ judge.email }}

{% endif %} diff --git a/mittab/templates/registration/setup.html b/mittab/templates/registration/setup.html index 6b262b678..cc4d0e739 100644 --- a/mittab/templates/registration/setup.html +++ b/mittab/templates/registration/setup.html @@ -14,51 +14,7 @@

Portal Controls

{% csrf_token %} -
-
- {{ form.allow_new_registrations }} - -
- {% if form.allow_new_registrations.help_text %} - {{ form.allow_new_registrations.help_text }} - {% endif %} - {{ form.allow_new_registrations.errors }} -
-
-
- {{ form.allow_registration_edits }} - -
- {% if form.allow_registration_edits.help_text %} - {{ form.allow_registration_edits.help_text }} - {% endif %} - {{ form.allow_registration_edits.errors }} -
-
-
- - {{ form.registration_description }} - {% if form.registration_description.help_text %} - {{ form.registration_description.help_text }} - {% endif %} - {{ form.registration_description.errors }} -
-
- - {{ form.registration_completion_message }} - {% if form.registration_completion_message.help_text %} - {{ form.registration_completion_message.help_text }} - {% endif %} - {{ form.registration_completion_message.errors }} -
+ {% bootstrap_form form layout='vertical' %}
From bbd8c7085cc7d2fc80cfaf33ca2d0e1937095c10 Mon Sep 17 00:00:00 2001 From: Joey Rubas Date: Sat, 20 Dec 2025 14:58:04 -0500 Subject: [PATCH 4/5] UI fixes --- assets/css/registration.scss | 302 +++++++++++++++--- assets/js/registration/portal.js | 185 ++++++++++- mittab/apps/registration/forms.py | 98 ++++-- .../tests/test_registration_flow.py | 39 ++- mittab/apps/registration/views.py | 161 ++++++++-- .../templates/registration/_debater_card.html | 21 +- .../templates/registration/_input_group.html | 2 +- .../templates/registration/_judge_form.html | 34 +- mittab/templates/registration/_team_form.html | 34 +- mittab/templates/registration/portal.html | 58 ++-- 10 files changed, 754 insertions(+), 180 deletions(-) diff --git a/assets/css/registration.scss b/assets/css/registration.scss index 7c7238565..7a8800ab7 100644 --- a/assets/css/registration.scss +++ b/assets/css/registration.scss @@ -3,14 +3,174 @@ } #registration-app { + --rg-space-1: 0.2rem; + --rg-space-2: 0.3rem; + --rg-space-3: 0.4rem; + --rg-card-radius: 0.5rem; + --rg-card-border: 1px solid rgba(12, 32, 54, 0.08); + --rg-card-shadow: 0 10px 30px -24px rgba(15, 33, 55, 0.45); + --rg-label-width: 72px; + --rg-label-font: 0.76rem; + --rg-control-height: 2.25rem; + --rg-control-height-sm: 2.05rem; + + .registration-layout { + display: grid; + gap: var(--rg-space-3); + } + + .registration-split { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: var(--rg-space-2); + align-items: stretch; + } + + .registration-card { + border-radius: var(--rg-card-radius); + } + .card { - box-shadow: 0 10px 30px -24px rgba(15, 33, 55, 0.45); - border: 1px solid rgba(12, 32, 54, 0.08); + box-shadow: var(--rg-card-shadow); + border: var(--rg-card-border); + border-radius: var(--rg-card-radius); + } + + .registration-card__body { + padding: 0.52rem 0.55rem; + display: grid; + gap: var(--rg-space-2); + } + + .registration-card__body--dense { + padding: 0.4rem 0.44rem; + gap: var(--rg-space-1); + } + + .registration-card__title { + margin-bottom: 0; + } + + .registration-team-grid { + display: grid; + grid-template-columns: repeat(3, minmax(260px, 1fr)) auto; + gap: var(--rg-space-2); + align-items: stretch; + } + + .registration-team-grid > * { + min-width: 0; + } + + @media (max-width: 1199.98px) { + .registration-team-grid { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + } + } + + .registration-team-actions { + align-self: start; + } + + .registration-judge-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--rg-space-2); + align-items: start; + grid-auto-rows: minmax(var(--rg-control-height), auto); + } + + .registration-judge-grid > * { + min-width: 0; + } + + .registration-judge-grid__item--wide { + grid-column: 1 / -1; + } + + .registration-field { + display: grid; + grid-template-columns: var(--rg-label-width) minmax(0, 1fr); + column-gap: var(--rg-space-1); + align-items: stretch; + width: 100%; + } + + .registration-field .input-group-prepend { + margin-right: 0; + grid-column: 1; + height: 100%; + } + + .registration-field .input-group-text { + width: 100%; + justify-content: flex-start; + font-size: var(--rg-label-font); + border-right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + height: 100%; + min-height: var(--rg-control-height); + line-height: 1.15; + display: flex; + align-items: center; + padding: 0.18rem 0.35rem; + } + + .registration-field .form-control { + grid-column: 2; + width: 100%; + min-height: var(--rg-control-height); + height: var(--rg-control-height); + padding: 0.28rem 0.48rem; + font-size: 0.87rem; + line-height: 1.3; + } + + .registration-field.input-group-sm .input-group-text { + font-size: 0.75rem; + padding: 0.14rem 0.28rem; + min-height: var(--rg-control-height-sm); + } + + .registration-field.input-group-sm .form-control { + padding-top: 0.2rem; + padding-bottom: 0.2rem; + min-height: var(--rg-control-height-sm); + height: var(--rg-control-height-sm); + } + + .registration-field + .text-danger { + margin-left: calc(var(--rg-label-width) + var(--rg-space-1)); } .input-group-text { - font-size: 0.85rem; - padding: 0.35rem 0.6rem; + font-size: 0.74rem; + padding: 0.18rem 0.34rem; + } + + .input-group { + align-items: stretch; + + .input-group-prepend { + margin-right: 0; + height: calc(var(--rg-control-height) - 3px); + + .input-group-text { + border-right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + height: 100%; + display: flex; + align-items: center; + } + } + + .form-control { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 0; + } } .seed-select select { @@ -18,6 +178,52 @@ font-size: 0.85rem; } + .judge-availability { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + + .custom-control { + padding-left: 1.5rem; + } + + .custom-control-label { + font-size: 0.85rem; + } + } + + .judge-schools-select { + min-height: 0; + width: 100%; + } + + .registration-judge-grid .select2-container--bootstrap4 { + width: 100% !important; + } + + .registration-judge-grid .select2-selection--multiple { + min-height: var(--rg-control-height); + padding: 0.18rem 0.4rem; + border-radius: 0.25rem; + } + + .registration-judge-grid + .select2-container--bootstrap4 + .select2-selection--multiple + .select2-selection__rendered { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + padding: 0; + } + + .registration-judge-grid + .select2-container--bootstrap4 + .select2-selection--multiple + .select2-selection__choice { + margin-top: 0.12rem; + } + .registration-info-panel { border: 1px solid rgba(16, 46, 97, 0.18); border-radius: 0.85rem; @@ -78,78 +284,86 @@ .registration-quick-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 1rem; + gap: var(--rg-space-2); &__panel { - border: 1px solid rgba(16, 38, 70, 0.15); - border-radius: 0.75rem; - padding: 1rem; + border: var(--rg-card-border); + border-radius: var(--rg-card-radius); + padding: 0.48rem 0.52rem; background: #fff; - box-shadow: 0 14px 32px -28px rgba(10, 18, 32, 0.55); + box-shadow: var(--rg-card-shadow); + display: grid; + gap: var(--rg-space-2); } .btn-block { - margin-top: 0.5rem; + margin-top: 0; + padding: 0.28rem 0.48rem; + font-size: 0.85rem; + height: var(--rg-control-height); } } } @media (max-width: 767.98px) { #registration-app { - .form-row { - display: flex; - flex-direction: column; - } - - [class*="col-md"] { - max-width: 100%; - } + .registration-split, + .registration-team-grid, + .registration-judge-grid { + grid-template-columns: 1fr; + } - .card { - margin-bottom: 1rem; + .registration-team-actions { + justify-self: start; } - .input-group { - flex-direction: column; + .seed-select { + width: 100%; - .input-group-prepend { + select { width: 100%; - - .input-group-text { - width: 100%; - justify-content: flex-start; - border-bottom-left-radius: 0.25rem; - border-bottom-right-radius: 0.25rem; - } } + } - .form-control { - width: 100%; - } + .registration-field { + grid-template-columns: 1fr; } - .seed-select { + .registration-field .input-group-prepend { width: 100%; + } - select { - width: 100%; - } + .registration-field .input-group-text { + width: 100%; + justify-content: flex-start; + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + border-right: 1px solid rgba(0, 0, 0, 0.125); } - .registration-summary .card { - margin-bottom: 0; + .registration-field .form-control { + grid-column: 1; + width: 100%; + border-left: 1px solid rgba(0, 0, 0, 0.125); + border-top-left-radius: 0; + border-top-right-radius: 0; + min-height: 2rem; } - .registration-info-panel__toggle { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; + .registration-field + .text-danger { + margin-left: 0; } .registration-quick-actions { grid-template-columns: 1fr; } + .registration-info-panel__toggle { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + .registration-quick-actions__panel { height: auto; } @@ -158,4 +372,4 @@ height: auto !important; } } -} \ No newline at end of file +} diff --git a/assets/js/registration/portal.js b/assets/js/registration/portal.js index 68532423a..cd78c9bf9 100644 --- a/assets/js/registration/portal.js +++ b/assets/js/registration/portal.js @@ -1,4 +1,7 @@ import $ from "jquery"; +import "select2"; +import "select2/dist/css/select2.css"; +import "@ttskch/select2-bootstrap4-theme/dist/select2-bootstrap4.min.css"; const NEW_VALUE = "__new__"; const DEB_URL = (id) => `/registration/api/debaters/${id}/`; @@ -6,11 +9,47 @@ const DEB_URL = (id) => `/registration/api/debaters/${id}/`; const customSchools = []; const customDebaters = {}; +const getRegistrationSelect = () => + $("[data-school-select='registration']").first(); + +const getRegistrationSchoolValue = () => getRegistrationSelect().val() || ""; + +const getOptionLabel = ($select, value) => { + if (!value) return ""; + const option = $select.find(`option[value="${value}"]`); + return option.length ? option.text() : ""; +}; + +const getRegistrationSchoolLabel = () => { + const $select = getRegistrationSelect(); + return getOptionLabel($select, $select.val()); +}; + const setPlaceholder = ($select, message, disabled = true) => { $select.empty().append(``); $select.prop("disabled", disabled); }; +const sortOptions = ($select) => { + const current = $select.val(); + const options = $select.find("option").get(); + if (!options.length) return; + const [first, ...rest] = options; + rest.sort((a, b) => + (a.text || "").localeCompare(b.text || "", undefined, { + sensitivity: "base", + }), + ); + $select.empty(); + if (first) { + $select.append(first); + } + rest.forEach((option) => $select.append(option)); + if (current && $select.find(`option[value="${current}"]`).length) { + $select.val(current); + } +}; + const toggleNewSchoolInput = ($select) => { const targetId = $select.attr("id"); $(`[data-new-school-container="${targetId}"]`).each((_, el) => { @@ -83,6 +122,7 @@ const renderDebaterList = ($select, entries = [], schoolValue = "") => { $select.append(option); }); } + sortOptions($select); $select.prop("disabled", false); }; @@ -101,6 +141,7 @@ const broadcastCustomDebater = (schoolValue, debater) => { option.textContent = debater.name; option.dataset.debater = JSON.stringify(debater); $debaterSelect.append(option); + sortOptions($debaterSelect); }); }; @@ -135,18 +176,40 @@ const loadDebaters = ($schoolSelect) => { }; const configureSchoolSelect = ($select) => { + syncSchoolOptionsFromRegistration($select); toggleNewSchoolInput($select); + maybePrefillFromRegistration($select); loadDebaters($select); }; const toggleAddTeamButton = () => { const $addButton = $("[data-add-form='team']"); - const hasSchool = Boolean( - $("[data-school-select='registration']").first().val(), - ); + const hasSchool = Boolean(getRegistrationSchoolValue()); $addButton.prop("disabled", !hasSchool); }; +const initJudgeSchoolSelect = ($scope = $(document)) => { + $scope.find("[data-judge-school-select]").each((_, el) => { + const $select = $(el); + if ($select.data("select2")) return; + $select.select2({ + theme: "bootstrap4", + width: "100%", + placeholder: "Select schools", + closeOnSelect: false, + }); + }); +}; + +const refreshJudgeSchoolSelects = () => { + $("[data-judge-school-select]").each((_, el) => { + const $select = $(el); + if ($select.data("select2")) { + $select.trigger("change.select2"); + } + }); +}; + const updateManagementForm = (prefix, delta) => { const $total = $(`[name="${prefix}-TOTAL_FORMS"]`); $total.val(parseInt($total.val(), 10) + delta); @@ -165,6 +228,8 @@ const addForm = (type, maxTeams) => { $element.find("[data-school-select]").each((_, el) => { configureSchoolSelect($(el)); }); + initJudgeSchoolSelect($element); + prefillTeamSchools(); }; const removeForm = (button) => { @@ -178,6 +243,99 @@ const removeForm = (button) => { } }; +const appendSchoolOption = (value, name) => { + $("[data-school-select]").each((_, el) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = name; + el.append(option); + }); + $("[data-judge-school-select]").each((_, el) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = name; + el.append(option); + }); + refreshJudgeSchoolSelects(); +}; + +const ensureOptionExists = ($select, value, label) => { + if (!value) return; + if (!$select.find(`option[value="${value}"]`).length) { + $select.append($("