Project submissions, deadlines, mentor availability, GitHub activity, Hackers' Choice (plan + implementation) - #284
Merged
Merged
Conversation
…ivity, Hackers' Choice (WS-A) Backend slice of the DevPost-replacement program: data contracts + API table, ordered tasks, and the bugs-found table (incl. the missing membership check on /devpost and /demo-video and the demo video never reaching judges). Full plan lives in frontend-ohack.dev/docs/plans/team-dashboard-devpost-replacement.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- common/utils/validators.py: normalize_deadline_iso, validate_deadlines (naive datetime -> event timezone, unknown-key rejection, ordering checks), sanitize_markdown (denylist-based, preserves generic "<"), validate_https_url, and the constraints.peer_vote_* range checks, all wired into validate_hackathon_data_partial. - services/hackathons_service.py::save_hackathon persists `deadlines`, turning an explicit null into a Firestore DELETE_FIELD on update (vs. simply omitted on create). get_single_hackathon_event strips project_story off every team in the event payload (size guard — the dashboard fetches it per-team instead). - Tests: test/common/utils/test_validators.py (+), api/messages/tests/test_hackathon_deadlines.py (new). Part of the team-dashboard-devpost-replacement plan, WS-A task 1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mentor-availability, deadline reminders; fix devpost/demo-video auth gap New self-serve, deadline-aware team writes, split from the admin-only api.teams.teams_service.edit_team path: - POST /api/team/<id>/project — partial update of project_tagline/ project_story/project_built_with/project_links/project_thumbnail_url/ project_images; sets project_submission_status=draft on first save; 400 invalid_project, 403 not_team_member, 409 submissions_closed. - POST /api/team/<id>/project/submit — requires tagline+story; idempotent; submitted|late depending on the event's submission window. - POST /api/team/<id>/mentor-availability — signal-only "open to mentors / heads-down" toggle, no deadline gate. - GET /api/hackathons/<event_id>/submissions/window — public server-clock window state for the dashboard's deadline strip. - Deadline reminders: build_reminder_message (never nags a done team), send_deadline_reminders (idempotent per event+kind+hours, only_if_due for the hourly cron), send_due_reminders_for_current_events. Security fix (Part 9 bug #1): POST /api/team/<id>/devpost and /demo-video used to call edit_team directly with NO membership check — any logged-in user could overwrite any team's Devpost link or demo video. Both now route through self_serve_team_edit, which enforces team membership (or admin) and the submission deadline. Tests: api/submissions/tests (service + view-signature + route dispatch), api/teams/tests (devpost/demo-video regression coverage). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
services/volunteers_service.py::get_volunteer_self_status mirrors
api.mentors.mentors_service.get_mentor_self_status's shape/leanness
({f"is_{type}": bool, "volunteer": {"name","isSelected"}|None}) for any
volunteer_type via the existing find_volunteer_by_caller_identity
resolver. type=mentor still delegates to the original mentor-specific
service unchanged; type=hacker is new. Backs the team dashboard's "am I
an approved hacker for this event" gate and Hackers' Choice eligibility.
Part of the team-dashboard-devpost-replacement plan, WS-A task 5.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
common/utils/github.py::get_repo_activity(org, repo) makes exactly 3 GitHub API calls (get_repo, get_commits().get_page(0), get_pulls( state="open").totalCount) and derives last-commit info, last-24h commit count, and top-8 contributors from that single commit page. A 409 "empty repository" from get_commits is a valid all-zeros result (a fresh team repo), not an error. api/github/github_service.py::get_github_activity validates org/repo names, caches successes only (5 min TTL, separate from the existing issues cache), and translates UnknownObjectException/ RateLimitExceededException into 404/503. Part 9 bug #7 fixed while touching this file: GET /api/github/issues let a request through with no `org` (200 instead of 400), and its log line printed len(issues) where `issues` is the response dict — that logged the dict's key count, not the issue count. Tests: api/github/tests (exactly-3-calls assertion via a fake Github client, 24h math, contributor cap, empty-repo, service-layer caching and error translation, the /issues and /activity route fixes). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An assigned-slate approval vote, deliberately not a popularity contest:
each eligible voter (an isSelected hacker; optionally gated on their own
team having submitted, via constraints.peer_vote_requires_submission)
gets a deterministic, exposure-balanced slate of submitted projects
(never their own team) seeded on sha256(event_id:propel_id), and
approves up to peer_vote_max_picks of them. Scoring is the Wilson score
interval lower bound of approvals/shown (not a raw rate), so a team
shown to a handful of people isn't buried by exposure alone. No tallies
are ever shown to a voter, only to admins.
- GET .../peer-vote/slate, POST .../ballot (voter-facing, require_user)
- GET .../results, POST .../ballots/<propel_id>/void,
POST .../publish (admin) — publish appends "Hackers' Choice" to the
winning team's awards[] exactly once and writes a public summary doc
- GET .../summary (public)
constraints.peer_vote_enabled defaults to False and is checked on every
voter-facing route; a disabled/unconfigured event returns
{"status":"disabled"} from the slate route regardless of anything else.
The slate's first-ever build for a voter runs inside a Firestore
transaction that re-checks ballot existence, so a double-request can't
double-persist a slate or double-increment exposure counts.
Tests: own-team exclusion, unsubmitted/inactive teams excluded, slate
capped and exposure-balanced, repeat-GET idempotence, the full ballot
validation matrix, voided-ballot exclusion from results, Wilson lower
bound spec values, idempotent publish, summary gating.
Part of the team-dashboard-devpost-replacement plan, WS-A task 7.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No rubric/scoring/round/results logic changes. get_team_details and format_team_for_judge now return demo_video_url (the field a team's dashboard actually writes) and fill the legacy video_url key from it, so judge pages can finally show a team's demo video next to the existing GitHub/Devpost links. Bug fixes found and fixed along the way (Part 9 #3, #4): - get_bulk_judge_details always returned an empty judges list — it called an undefined name, fetch_judge_scores_by_event (the correctly imported fetch_judge_scores_by_event_id was never used), which NameError'd straight into the function's own blanket except. - update_judge_assignment_details (PUT /api/judge/assignments/<id>) always 400'd "Assignment not found" — it looked assignments up via fetch_judge_assignments_by_judge_id("") (an empty judge_id) instead of by the assignment's own id. Added fetch_judge_assignment_by_id (direct doc-get) to db/firestore.py + db/db.py and used that instead. Tests: api/judging/tests/test_format_team.py. Part of the team-dashboard-devpost-replacement plan, WS-A task 9. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- api/__init__.py: register api.submissions.submissions_views and api.peer_votes.peer_votes_views after broadcasts_views. Verified the full app (all blueprints) registers with no route conflicts (306 total routes). - .github/workflows/deadline-reminders.yml: hourly cron hitting POST /api/hackathons/deadlines/remind-due with X-Api-Key. Requires the BACKEND_CRON_TOKEN secret to be set on Fly (backend env) and in GitHub Actions — NOT done by this change; see open questions. - CLAUDE.md: new "Project submissions + Hackers' Choice (Sep 2026)" section (data contracts, endpoints, deploy-order note, documented-only Part 9 findings #5/#6/#14/#15), plus a new gotcha entry for the common.utils.slack-before-common.utils.firebase import-order requirement (load_dotenv() timing) that bit this work directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| if not (auth_user and auth_user.user_id): | ||
| return _unauthorized() | ||
| body = request.get_json() or {} | ||
| return submit_ballot(auth_user.user_id, event_id, body.get("picks")) |
| if not is_admin(auth_user): | ||
| return _forbidden() | ||
| body = request.get_json() or {} | ||
| return publish_results(event_id, auth_user.user_id, team_id=body.get("team_id")) |
| if not (auth_user and auth_user.user_id): | ||
| return _unauthorized() | ||
| payload = request.get_json() or {} | ||
| return save_project(auth_user.user_id, teamid, payload, admin=is_admin(auth_user)) |
| only_if_due = bool(body.get("only_if_due", False)) | ||
| force = bool(body.get("force", False)) | ||
| actor = auth_user.user_id if is_admin_caller else "cron" | ||
| return send_deadline_reminders(event_id, kind, hours_before, only_if_due=only_if_due, force=force, actor=actor) |
…bugs Review findings on api/peer_votes/peer_votes_service.py: - HIGH: publish_results crowned an arbitrary "winner" when 0 ballots were cast (compute_results emits a row per submitted team regardless of vote count, so the sort fell through to team name). Now 409s "no_ballots" when results["ballots"] == 0 OR the rank-1 team has 0 approvals. - HIGH: compute_results scored teams against the raw exposure-doc count (how many slates a team was ever persisted into) instead of ballots actually cast. `shown` now counts non-voided ballots that have picks and include the team in their slate; the raw count is kept separately as exposure_shown. Voiding a ballot now removes it from both shown and approvals. - LOW: compute_voting_window's end_date fallback double-appended "T23:59:59" onto an end_date that already carried a time, producing an unparseable string and silently falling back to permanently closed. Only bare dates get the suffix now. - LOW: naive or "Z"-suffixed stored deadline strings crashed compute_voting_window (TypeError/ValueError) instead of degrading to "closed"; now parsed via normalize_deadline_iso with a warning log on failure. - LOW: ballot writes (submit_ballot, void_ballot) used set(merge=True); switched to a full set() of the rebuilt doc per spec. - LOW: a voided ballot rendered status "voted" with stale picks on the voter's own slate page; now renders "voided" with picks:null. get_results now also returns ballots_detail (voter_propel_id, voted_at, voided, picks_count only — no names/emails/picks) so the admin UI can drive void. - LOW: peer_vote_max_picks could be >= slate_size if the two fields were set inconsistently across separate saves (the validator only checks the relationship within one payload); _settings() now clamps at read time. Regression tests added for every case above. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…r bypasses
common/utils/validators.py::sanitize_markdown (LOW):
- Loop the tag-strip pass to a fixpoint so a nested bypass like
"<scr<script>ipt>" can't reassemble into a live tag on a single pass.
- Match on*= attributes preceded by "/" as well as whitespace, catching
"<img/onerror=...>".
- Neutralize javascript:/vbscript:/data: targets in unquoted HTML
attributes, not just quoted ones.
- Also neutralize those targets in markdown link/image syntax
("[text](javascript:...)" -> "[text](#)").
- List<String>/Map<K,V> still survive untouched; regexes stay linear.
api/submissions/submissions_service.py (HIGH/MEDIUM/LOW):
- compute_submission_window now parses stored submission/late_submission_until
strings through normalize_deadline_iso before comparing against `now` — a
naive or "Z"-suffixed stored value used to crash (TypeError/ValueError)
instead of degrading to no_deadline; unparseable values are logged and
treated as absent.
- send_deadline_reminders' only_if_due window narrowed from
[deadline-h, deadline) to a one-hour-wide [deadline-h, deadline-h+1h) —
the old window let a deadline set with only a few hours' notice fall due
for both the 24h and 6h tiers on the same cron tick and fire both at once.
- submit_project now checks the already-submitted idempotent path BEFORE
the deadline gate, so a team that submitted on time never gets a
spurious 409 revisiting the endpoint after close.
- self_serve_team_edit (the /devpost and /demo-video bridge) now also
busts the hackathons_service event cache after delegating to edit_team,
which alone only clears the generic per-function caches — fixes a stale
DevPost link/demo video on the event page for up to 10 minutes.
api/teams/teams_service.py::edit_team (MEDIUM):
- field_mappings now carries project_tagline, project_story,
project_built_with, project_links, project_thumbnail_url, project_images,
and project_submission_status, making the documented admin override path
(PATCH /api/team/edit) actually work. project_submission_status is
validated against draft/submitted/late (400 + no write otherwise);
tagline/story get the same sanitize_markdown treatment as the self-serve
save_project path; a status change stamps project_updated_at.
Regression tests added for every case above.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…minders api/messages/tests/test_cache_invalidation.py (LOW, Part 9 bug #16): test_save_hackathon_clears_cache patched 'validate_hackathon_data', which was renamed to 'validate_hackathon_data_partial' — the patch context manager raised AttributeError since hackathons_service no longer imports the old name at all. Retargeted the patch and its return value (now a (cleaned_data, skipped_fields) tuple, matching the real signature). api/messages/tests/test_hackathon_deadlines.py (LOW): added regression tests confirming save_hackathon's actual behavior for `deadlines: {}` and top-level `deadlines: null` on an update — both are a no-op (an empty map merges zero sub-fields under set(merge=True), leaving the stored deadlines map untouched), not a clear-all. CLAUDE.md / api/submissions/README.md: documented all of this session's behavior changes — admin project_* override now real and validated, the narrower reminder due-window, safe naive/"Z"-suffixed deadline parsing, the deadlines {}/null no-op semantics, ballots_detail + voided-ballot rendering, the shown vs exposure_shown split, and the sanitize_markdown hardening. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
Project submissions, deadlines, mentor availability, GitHub activity, Hackers' Choice (backend)
Backend half of the DevPost replacement. Plan:
docs/plans/submissions-peer-vote.md(full plan in opportunity-hack/frontend-ohack.dev #367). Deploy this before the frontend PR. New env:BACKEND_CRON_TOKEN(Fly secret + GitHub Actions secret) for the hourly deadline-reminder cron — reminders and the admin "Send reminder now" button do nothing in prod until it's set.New
api/submissions/:POST /api/team/<id>/project(partial write-up save),/project/submit,/mentor-availability, publicGET /api/hackathons/<event>/submissions/window, deadline reminders (POST …/deadlines/remindadmin-or-cron-key,POST /api/hackathons/deadlines/remind-due). Team-membership gate + 409submissions_closedafter the deadline; admins bypass.api/peer_votes/: Hackers' Choice assigned-slate approval vote — slate (5 submitted projects, never your own, exposure-balanced, persisted per voter), ballot (≤2 picks, re-vote until close), admin results (Wilson lower bound,shownvsexposure_shown,ballots_detail), void, publish (appends "Hackers' Choice" toawards[]; 409 when nobody voted), public summary.GET /api/github/activity?org&repo(exactly 3 GitHub calls, 5-min success-only cache).deadlines+constraints.peer_vote_*validated and persisted bysave_hackathon(DELETE_FIELDfor explicit nulls);project_storystripped from the event payload for size.GET /api/volunteer/<event>/me?type=hacker;edit_teamacceptsproject_*+project_submission_statusfor admin overrides.get_team_details/format_team_for_judgereturndemo_video_url(+ legacyvideo_url). Nothing else in judging changes.Bugs fixed along the way
KeyError: 'teams'on hackathon docs that have noteamskey (e.g. events created via the admin UI), AFTER inserting the team doc — the team was orphaned from its event. Found while testingfall-2026on test.ohack.dev; fixed via a tolerant, idempotent_append_team_to_hackathonhelper (also used byremove_team).POST /api/team/<id>/devpostand/demo-videoaccepted any logged-in user — no team-membership check.get_bulk_judge_detailsalways returned empty (undefined name);PUT /api/judge/assignments/<id>always 400'd (looked up by an empty judge id)./api/github/issueslet a missingorgthrough and mis-logged counts.test_cache_invalidationpatch target.shownused raw exposure (a voter who opened the page and never voted penalised every team on their slate); naive/Zdeadline strings 500'd the whole submissions surface;/devpost+/demo-videoskipped the hackathon cache clear; sanitizer bypasses; reminder double-fire near the deadline; idempotent submit after close; voided ballots rendered as "voted".POST /api/messages/teamcreate path callscreate_github_repowith an old signature;hackathon.devpost_urlis read but never writable; public hacker-applications endpoint exposesuser_id/isSelected; judge API naming split (devpost_url/video_url).Verification
ENVIRONMENT=test pytest api/submissions/tests api/peer_votes/tests api/github/tests api/judging/tests api/volunteers/tests api/teams/tests test/common/utils api/messages/tests/test_hackathon_deadlines.py api/messages/tests/test_cache_invalidation.py→ 286 passed.pylint -E api/*.py(CI gate) clean. Live on the test-Firestore backend:/submissions/window,/peer-vote/summary,/github/activityrespond as specified. Authenticated write paths were verified through pytest only (Slack tokens in.envare real — no Slack-posting endpoint was exercised live).Test plan — pages/endpoints to exercise
Run this branch on :6060 (test Firestore) with frontend #367 pointed at it; follow the seeding steps in #367's test plan.
/hack/2024_fall_copy/manageteam— save story (POST /api/team/<t>/project→draft), Submit (submitted), move the deadline into the past as admin → saves return 409submissions_closed; toggle mentor availability; code activity card (/api/github/activity); as a user NOT on the team, saving a demo video → 403./hack/2024_fall_copy/vote— slate excludes your own team; ballot; re-vote; admin void → page shows the voided state./admin/hackathons/2024_fall_copy?section=deadlines— deadlines persist (PATCH /api/messages/hackathon); "Send reminder now" — posts to the teams' real Slack channels; verify via the audit webhook or skip./admin/hackathons/2024_fall_copy?section=judging&subtab=peer-vote— results (shownvsexposure_shown), void, Publish →awards[]gains "Hackers' Choice";/hack/2024_fall_copy/resultsshows it; publish with zero ballots → 409./judge/2024_fall_copy/team/<t>— demo video present; scores still submit.GET /api/hackathons/2024_fall_copy/submissions/window,GET …/peer-vote/summary,GET /api/volunteer/2024_fall_copy/me?type=hacker,GET /api/messages/hackathon/2024_fall_copy(teams carryproject_*but noproject_story).🤖 Generated with Claude Code