feat: alumni chapters — models and routes - #58
Merged
Conversation
…etween user's university, chapter's school, and tournament location
…with University Model
…or Tournament and AlumniChapter
… a single alembic migration file
…server to in-memory sqlite like the other test files
…chapter member responses
…ent admin/public/lead sections
Closed
6 tasks
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
🚅 Deployed to the nexus-pr-58 environment in nexus
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
Alumni chapters let Science Olympiad alumni organize by university — a lead/officer/member hierarchy, join-code-based membership, and a canonical
Universitytable that also normalizesUser.universityandTournamentlocation references. This PR is backend-first; the chapter dashboard, join page, and dashboard chapter section are tracked separately from #42 rather than bundled here. It does, however, include the minimal frontend fallout from switchingUser.universityto an FK — see Frontend below.Changes
Models + migration
University— canonical lookup (nameunique,abbreviation,location); referenced byUser,Tournament, andAlumniChapterAlumniChapter,ChapterMembership(role:lead | officer | member, unique onuser_id— one chapter per user),ChapterJoinCode(8-char alphanumeric excluding ambiguous chars, optional label/expiry,is_active,use_count),TournamentChapterjunction table (exists for future tournament-chapter affiliation UI, unused otherwise)Tournament.university_id+Tournament.locationfallback, with a@validatesschema check requiring at least one of the twoChapterMembership.user_idcascades on user delete;ChapterJoinCode.created_byintentionally does not — a user who's created join codes can't be deleted while those rows exist2cc03b477680_alumni_chapters.py), edited in place rather than stacking incremental migrations since the branch never shippedmodels.pyreorganized:Universitymoved above the models that reference it; relationship names fixed to match cardinality — singular refs (alumni_chapter,user,tournament,chapter) instead of pluralized names on scalar/one-to-one relationships, andtournament_chaptersinstead ofchapters/tournamentson the junction table so it's clear those returnTournamentChapterrows, notTournament/AlumniChapterobjects directlyRoutes
/admin/chapters/...,/admin/universities/...): chapter/university CRUD, lead assignment — mirrors the existing/admin/users/convention (publicGET, admin-gatedPOST/PATCH/DELETEunder/admin/)GET /chapters/{id}/,GET /universities/PATCH /chapters/{id}/(update name/university)GET /chapters/{id}/members/— officers get read access to the member list; arequire_officer_or_leaddependency was added alongside the existingrequire_lead/require_chapter_lead_or_admin, all three now sharing one_require_chapter_rolehelper instead of duplicating the same membership-role query three timesChapterMemberUpdateschema, not a raw string), member removal, member profile view, join-code CRUD (PATCHnow supports updating label/expiry, not just deactivating — one-wayis_activeenforced in the route, not the schema)POST /chapters/join/— validates a join code and creates the membership; incrementsChapterJoinCode.use_countin the same transactionGET /chapters/{id}/being public covers the same need without a second response shapecreate_alumni_chapter,assign_chapter_lead,is_join_code_expired,create_university_record) had their leading underscores removed since underscore was signaling module-private when they're actually imported elsewhereSchemas
ChapterMemberResponse/ChapterMemberProfileResponseflatten the member's user fields onto the response directly (via amodel_validator(mode="before")) instead of nesting under auserkey, withmembership_idkept distinct from the user'sidChapterUserSlimReponsein favor of the realUserSlimResponse/UserFullResponseschemasschemas/user.pyby audience:UserSlimResponseis now a minimal public-safe shape (no account-internal fields),AdminUserSlimResponse/AdminUserFullResponsecarryrole/status/email_verified/timestamps for admin-facing routes and auth responses,UserMeSlimResponse/UserMeFullResponsecompose both via multiple inheritance for self-view. Net effect: chapter leads viewing a member's profile see profile data only, not account/role state — by construction, not a special caseSeed data
app/db/seed_universities.py(idempotent, sameON CONFLICT DO NOTHINGpattern asseed_canon_events.py) — the 9 undergrad UC campuses, Stanford, Caltech, and Harvey Mudd. Wired into the lifespan alongside the events seed, runs on every startup in every environmentFrontend
The
User.universityFK migration turnedusersApi/adminUsersApitypes (UserFull.university) from a free-textstringinto aUniversity | nullobject, which broke type-checking wherever the frontend still treated it as text. This PR includes the direct consumer fixes, not the broader chapter UI:Combobox.tsxgained an optionalgetSearchText?: (option: T) => stringprop (defaults togetLabel) so callers can search on more than just the displayed label without hardcoding that logic into the shared componentUniversityField(ProfileFields.tsx) now renders aCombobox<University>fetched fromuniversitiesApi.list()instead of a plain textInput—allowFreeText={false}since universities are admin-managed/seeded, not user-created; search matches on name or abbreviation (getSearchText), but the displayed label and stored value are just the name — an earlier version appended(ABBR)to the label, which desynced fromgetSearchTextand made a freshly-selected value fail its own exact-match checkapp/onboarding/page.tsxandapp/profile/[id]/edit/page.tsx: fetchuniversitiesApi.list()alongside the existingcanonicalEventsApi.list()call;ProfileDraft.university(string) replaced withuniversity_id/university_name(mirrors the existingevent_id/event_namedraft pattern used for competition/volunteer experience); PATCH payloads senduniversity_idand strip the display-onlyuniversity_namefield before hittingusersApi.updateMe()EducationCareerSection.tsx(read-only profile view): rendersuniversity?.nameinstead of the old string fieldTesting
Backend: full suite (
pytest tests/), 544 passed.test_chapters.py/test_universities.pyrewritten to match the final route paths, permission boundaries, and response shapes — including explicit coverage for both crash bugs above (they only shipped broken because the success paths were untested), the officer/lead/member permission boundary on the member list, invalid-role rejection (422), and partial join-code updates.Frontend:
npx tsc --noEmitclean ononboarding/page.tsx,profile/[id]/edit/page.tsx,ProfileFields.tsx,Combobox.tsx, andEducationCareerSection.tsx. Manually verified the university combobox search-by-abbreviation and selection round-trip in the browser.Notes
# TODOthroughout)/leads/endpoint) is intentional, not a privilege-escalation gap