diff --git a/.pylintrc b/.pylintrc index a21fd086..58e8b6ae 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 fadafb11..f778aea8 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 4e21ea67..b6f57806 100644 --- a/assets/css/mobile.scss +++ b/assets/css/mobile.scss @@ -214,23 +214,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; } } @@ -280,8 +325,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 2c41b22d..836d3ee8 100644 --- a/assets/css/public-home.scss +++ b/assets/css/public-home.scss @@ -126,7 +126,7 @@ body { } .links { @include card-shell; - padding: 2rem 2.5rem; + padding: 1.5rem 1.75rem; color: #1d2b44; @@ -153,6 +153,79 @@ body { } } +.register-cta { + display: flex; + width: 100%; + border: 1px solid rgba(28, 54, 94, 0.16); + border-radius: 0.85rem; + padding: 1.1rem 1.4rem; + background: linear-gradient(135deg, rgba(42, 102, 196, 0.09), rgba(25, 73, 154, 0.04)); + align-items: flex-start; + gap: 1rem; + + &__text { + display: flex; + flex-direction: column; + gap: 0.25rem; + color: #16243a; + flex: 1 1 65%; + min-width: 0; + + .eyebrow { + font-size: 0.85rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(18, 42, 72, 0.7); + } + + .headline { + font-size: 1.1rem; + font-weight: 700; + } + + .body { + margin-bottom: 0; + font-size: 0.9rem; + color: rgba(18, 34, 58, 0.85); + } + } + + &__btn { + white-space: nowrap; + padding: 0.7rem 1.2rem; + font-size: 0.95rem; + min-width: 8rem; + } +} + +.registration-actions { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; + + .btn { + flex: 1 1 0; + } + + @media (min-width: 992px) { + flex-wrap: nowrap; + .btn { + flex: 0 0 auto; + } + } +} + +.register-cta__actions { + flex: 0 0 auto; +} + +.registration-actions--mobile { + flex-direction: column; + align-items: stretch; +} + .public-links { position: relative; @include flex-column(1rem); @@ -229,6 +302,45 @@ 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; + flex: 1 1 0; + text-align: center; +} + .public-sidebar-card { @include card-shell; padding: 2rem 2.5rem; @@ -297,6 +409,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 +483,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 00000000..5a3d8831 --- /dev/null +++ b/assets/css/registration.scss @@ -0,0 +1,379 @@ +.btn { + height : auto; +} + +:root { + color-scheme: light; +} + +#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: 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.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 { + min-width: 120px; + 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; + 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: var(--rg-space-2); + + &__panel { + border: var(--rg-card-border); + border-radius: var(--rg-card-radius); + padding: 0.48rem 0.52rem; + background: #fff; + box-shadow: var(--rg-card-shadow); + display: grid; + gap: var(--rg-space-2); + } + + .btn-block { + margin-top: 0; + padding: 0.28rem 0.48rem; + font-size: 0.85rem; + height: var(--rg-control-height); + } + } +} + +@media (max-width: 767.98px) { + #registration-app { + .registration-split, + .registration-team-grid, + .registration-judge-grid { + grid-template-columns: 1fr; + } + + .registration-team-actions { + justify-self: start; + } + + .seed-select { + width: 100%; + + select { + width: 100%; + } + } + + .registration-field { + grid-template-columns: 1fr; + } + + .registration-field .input-group-prepend { + 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-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-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; + } + + .h-100 { + height: auto !important; + } + } +} diff --git a/assets/js/index.js b/assets/js/index.js index e080883c..212b786f 100644 --- a/assets/js/index.js +++ b/assets/js/index.js @@ -48,17 +48,15 @@ function initializeTooltips() { } function initializeSettingsForm() { - $(".custom-control-input").on( + $("[data-toggle-label]").on( "change", function handleCustomControlInputChange() { const label = $(this).siblings(".custom-control-label"); - if ($(this).is(":checked")) { - label.text("Enabled"); - } else { - label.text("Disabled"); - } + const onText = $(this).data("label-on") || "Enabled"; + const offText = $(this).data("label-off") || "Disabled"; + label.text($(this).is(":checked") ? onText : offText); }, - ); + ).trigger("change"); } $(document).ready(() => { diff --git a/assets/js/registration/index.js b/assets/js/registration/index.js new file mode 100644 index 00000000..93efda1b --- /dev/null +++ b/assets/js/registration/index.js @@ -0,0 +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 new file mode 100644 index 00000000..c2046e4a --- /dev/null +++ b/assets/js/registration/portal.js @@ -0,0 +1,465 @@ +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}/`; + +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) => { + 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) { + noviceField.value = debaterData?.status === "Novice" ? "1" : "0"; + } + if (qualifiedField) { + qualifiedField.value = debaterData?.apda_id ? "on" : ""; + } +}; + +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 + } + } + fillDebaterData(selectEl); +}; + +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); + }); + } + sortOptions($select); + $select.prop("disabled", false); +}; + +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); + } + const option = document.createElement("option"); + option.value = `custom:${debater.id}`; + option.textContent = debater.name; + option.dataset.debater = JSON.stringify(debater); + $debaterSelect.append(option); + sortOptions($debaterSelect); + }); +}; + +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; + } + if (value.startsWith("custom:")) { + renderDebaterList($debaterSelect, [], value); + return; + } + if (!value.startsWith("apda:")) { + setPlaceholder($debaterSelect, "Select a school first"); + return; + } + setPlaceholder($debaterSelect, "Loading debaters..."); + $.getJSON(DEB_URL(value.split(":")[1])) + .done((data) => { + const entries = Array.isArray(data) ? data : data.debaters || []; + renderDebaterList($debaterSelect, entries, value); + syncDebater($debaterSelect[0]); + }) + .fail(() => { + setPlaceholder($debaterSelect, "Unable to load debaters"); + }); +}; + +const configureSchoolSelect = ($select) => { + syncSchoolOptionsFromRegistration($select); + toggleNewSchoolInput($select); + maybePrefillFromRegistration($select); + loadDebaters($select); +}; + +const toggleAddTeamButton = () => { + const $addButton = $("[data-add-form='team']"); + 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; + if (!$select.find('option[value=""]').length) { + $select.prepend(''); + } + $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); +}; + +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)); + }); + initJudgeSchoolSelect($element); + prefillTeamSchools(); +}; + +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(); + } +}; + +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($("'); + $mainSchool.find("option").each((_, opt) => { + if (opt.value) { + $target.append($("