Skip to content

Project submissions, deadlines, mentor availability, GitHub activity, Hackers' Choice (plan + implementation) - #284

Merged
gregv merged 11 commits into
developfrom
feat/submissions-peer-vote
Sep 20, 2026
Merged

gregv merged 11 commits into
developfrom
feat/submissions-peer-vote

Conversation

@gregv

@gregv gregv commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

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, public GET /api/hackathons/<event>/submissions/window, deadline reminders (POST …/deadlines/remind admin-or-cron-key, POST /api/hackathons/deadlines/remind-due). Team-membership gate + 409 submissions_closed after 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, shown vs exposure_shown, ballots_detail), void, publish (appends "Hackers' Choice" to awards[]; 409 when nobody voted), public summary.
  • GET /api/github/activity?org&repo (exactly 3 GitHub calls, 5-min success-only cache).
  • Hackathon deadlines + constraints.peer_vote_* validated and persisted by save_hackathon (DELETE_FIELD for explicit nulls); project_story stripped from the event payload for size.
  • GET /api/volunteer/<event>/me?type=hacker; edit_team accepts project_* + project_submission_status for admin overrides.
  • Judging: get_team_details / format_team_for_judge return demo_video_url (+ legacy video_url). Nothing else in judging changes.

Bugs fixed along the way

  • Team creation crashed with KeyError: 'teams' on hackathon docs that have no teams key (e.g. events created via the admin UI), AFTER inserting the team doc — the team was orphaned from its event. Found while testing fall-2026 on test.ohack.dev; fixed via a tolerant, idempotent _append_team_to_hackathon helper (also used by remove_team).
  • Security: POST /api/team/<id>/devpost and /demo-video accepted any logged-in user — no team-membership check.
  • Judges never received the team's demo video.
  • get_bulk_judge_details always returned empty (undefined name); PUT /api/judge/assignments/<id> always 400'd (looked up by an empty judge id).
  • /api/github/issues let a missing org through and mis-logged counts.
  • Stale test_cache_invalidation patch target.
  • From the adversarial review of this branch: publish could crown an arbitrary team with zero ballots; shown used raw exposure (a voter who opened the page and never voted penalised every team on their slate); naive/Z deadline strings 500'd the whole submissions surface; /devpost+/demo-video skipped the hackathon cache clear; sanitizer bypasses; reminder double-fire near the deadline; idempotent submit after close; voided ballots rendered as "voted".
  • Documented-only (decision needed): legacy POST /api/messages/team create path calls create_github_repo with an old signature; hackathon.devpost_url is read but never writable; public hacker-applications endpoint exposes user_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.py286 passed. pylint -E api/*.py (CI gate) clean. Live on the test-Firestore backend: /submissions/window, /peer-vote/summary, /github/activity respond as specified. Authenticated write paths were verified through pytest only (Slack tokens in .env are 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.

  1. /hack/2024_fall_copy/manageteam — save story (POST /api/team/<t>/projectdraft), Submit (submitted), move the deadline into the past as admin → saves return 409 submissions_closed; toggle mentor availability; code activity card (/api/github/activity); as a user NOT on the team, saving a demo video → 403.
  2. /hack/2024_fall_copy/vote — slate excludes your own team; ballot; re-vote; admin void → page shows the voided state.
  3. /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.
  4. /admin/hackathons/2024_fall_copy?section=judging&subtab=peer-vote — results (shown vs exposure_shown), void, Publish → awards[] gains "Hackers' Choice"; /hack/2024_fall_copy/results shows it; publish with zero ballots → 409.
  5. /judge/2024_fall_copy/team/<t> — demo video present; scores still submit.
  6. Direct: 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 carry project_* but no project_story).

🤖 Generated with Claude Code

gregv and others added 8 commits September 19, 2026 17:42
…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)
Comment thread api/github/github_views.py Outdated
gregv and others added 3 commits September 19, 2026 21:06
…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>
@gregv
gregv marked this pull request as ready for review September 20, 2026 15:53
@gregv
gregv merged commit 182661e into develop Sep 20, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants