Skip to content
Merged

Lots #228

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ These are DIFFERENT VALUES. When bundling user data for the frontend, include bo
Two scripts live in `scripts/` for diagnosing and backfilling team rosters on `/hack/<event_id>`:

- `audit_hackathon_team_users.py --event-id <id>` (read-only) — walks `hackathons/{id}.teams[] -> teams/{id}.users[]` and reports per-team member counts, dangling refs (team points to deleted user doc), and "ghost" users (no name + no propel_id = imported but never logged in).
- `import_hackathon_users_from_csv.py --csv <path> --event-id <id> --csv-type {registrants|projects|roster} [--apply]` — dry-run by default. `projects` parses Devpost projects CSVs (variable-length team-member triplets starting at col 22, 1-indexed); `roster` parses a generic `team,email[,first_name,last_name,name]` CSV for backfilling memberships; `registrants` just seeds user docs. Users are matched by `email_address` (case-insensitive). Imported users get `imported=True`, `import_source`, `import_event_id`, blank `user_id`/`propel_id`. Team membership writes are additive — never removes existing members. Re-runnable.
- `import_hackathon_users_from_csv.py --csv <path> --event-id <id> --csv-type {registrants|projects|roster} [--apply]` — dry-run by default. `projects` parses Devpost projects CSVs; the team-member triplet offset is resolved by header lookup (`Team Member 1 First Name`), since old 23-col exports have no "Team Number" column while newer 24-col ones do. Each parsed "email" is validated with the email regex — rows where the triplet shifted off-axis are skipped with a warning rather than written as bogus user docs. `roster` parses a generic `team,email[,first_name,last_name,name]` CSV for backfilling memberships; `registrants` just seeds user docs. Users are matched by `email_address` (case-insensitive). Imported users get `imported=True`, `import_source`, `import_event_id`, blank `user_id`/`propel_id`. Team membership writes are additive — never removes existing members. Re-runnable.
- `cleanup_bogus_imported_users.py [--event-id <id>] [--apply]` — finds and removes the user docs left behind by the older off-by-one `parse_projects` bug. Fingerprint: `imported=True` AND `propel_id=""` AND `email_address` present but not a valid email AND `import_source` starts with `projects-`. For each matched user it prunes the doc-ref from every team's `users[]` that references it, then deletes the user doc. Dry-run by default. After running, re-run `import_hackathon_users_from_csv.py --csv-type projects` against the affected events to import the real members.
- `backfill_devpost_winners.py --event-id <id> --devpost-url <url> [--projects-csv <path>] [--apply]` — scrapes the Devpost project gallery for EVERY project tile, flagging winners (`aside.entry-badge img.winner`). For each project it matches to a Firestore team via a layered strategy: `teams.devpost_link` exact-URL → team name (case-insensitive) → email-overlap via Devpost projects CSV (auto-discovered from `/tmp/devpost_files/<event_id>/projects-*.csv`). Two backfills happen in one pass: (1) any matched team with an empty `devpost_link` gets the gallery URL written; (2) matched WINNERS additionally get `/software/<slug>` fetched for prize text + member names, with prize strings mapped to status — "1st place" → `FOUNDING_ENGINEERS`, "Completion" or "2nd place" → `COMPLETION_SUPPORT`, anything else marked Winner → `CATEGORY_WINNER` (rank-based; multi-prize teams get the best status, all prize text retained in `awards: []`). Conflicts (team already has a different `devpost_link`) are logged but never overwritten. Unmatched winners exit with code 2 so a human notices; unmatched non-winners are listed for visibility but don't fail the run (typical for teams that registered only on Devpost). Only sets `status`, `awards`, `winners_backfilled_at/source`, and `devpost_link`; never touches `users[]`. Re-runnable. Adds `beautifulsoup4` to requirements.

## Resend audience sync
`scripts/sync_resend_audience.py --source {all|profiles|volunteers|mentors|judges|sponsors|helpers|leads} --audience "<name>" [--event-id <id>] [--selected-only] [--apply]` — pulls emails from Firestore (`users.email_address`, `volunteers.email` filtered by `volunteer_type`, `leads.email`) and upserts contacts into a Resend audience (creates if missing). Dry-run by default. Re-runnable: lists existing audience contacts first and only POSTs new emails. Needs `RESEND_API_KEY` with audiences scope — the existing `RESEND_WELCOME_EMAIL_KEY` is send-only and will 401. Uses the deprecated `resend.Audiences` SDK class (now an alias for Segments) — fine for now, but if it breaks switch to `resend.Segments`.
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ ENV GUNICORN_CMD_ARGS="--bind=[::]:6060 --workers=2"
# Copy project
COPY . /app/
# Run the application
CMD ["venv/bin/gunicorn", "api.wsgi:app", "--log-file=-", "--log-level", "debug", "--preload", "--workers", "1"]
CMD ["venv/bin/gunicorn", "api.wsgi:app", "--log-file=-", "--log-level", "debug", "--preload", "--workers", "1", "--timeout", "120"]
71 changes: 62 additions & 9 deletions api/messages/messages_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,18 +335,71 @@ def get_profile_metadata_old(propel_id):
}


# Fields returned to the admin /admin/profiles consumers (page + UserSearchDialog).
# Keep this in sync with frontend src/pages/admin/profile/index.js and
# src/components/admin/UserSearchDialog.js. Drop anything heavy (history) or
# unused (mailing address, propel_id, want_stickers) — those routes have their
# own /profile/<id> fetch when a row is opened.
_ADMIN_PROFILE_LEAN_FIELDS = (
"name",
"nickname",
"email_address",
"user_id",
"profile_image",
"last_login",
"github",
"linkedin_url",
"instagram_url",
"company",
"education",
"role",
"shirt_size",
"expertise",
"why",
)


def _lean_admin_profile(doc):
"""Project a Firestore user doc into the lean shape the admin search uses.

Resolves DocumentReference lists (badges/teams/hackathons) to id strings
inline, since the frontend only reads `.length` on these arrays. Avoids
`doc_to_json`'s broader behavior and the heavy `history` field entirely.
"""
d = doc.to_dict() or {}
out = {"id": doc.id}
for key in _ADMIN_PROFILE_LEAN_FIELDS:
v = d.get(key)
if v is not None:
out[key] = v

for ref_key in ("badges", "teams", "hackathons"):
value = d.get(ref_key)
if isinstance(value, list):
out[ref_key] = [
v.id if isinstance(v, firestore.DocumentReference) else v
for v in value
]

vol = d.get("volunteering")
if isinstance(vol, list):
out["volunteering"] = [
{"hours": v.get("hours", 0)}
for v in vol if isinstance(v, dict)
]

return out


# 5-minute TTL is enough to absorb tab refreshes / multiple admins loading the
# page in close succession while still picking up new signups within minutes.
@cached(cache=TTLCache(maxsize=1, ttl=300), key=lambda: "all")
def get_all_profiles():
db = get_db()
docs = db.collection('users').stream() # steam() gets all records
if docs is None:
return {[]}
else:
results = []
for doc in docs:
results.append(doc_to_json(docid=doc.id, doc=doc))

docs = db.collection('users').stream()
results = [_lean_admin_profile(doc) for doc in docs]
logger.info(f"get_all_profiles returned {len(results)} profiles")
return { "profiles": results }
return {"profiles": results}


# Caching is not needed because the parent method already is caching
Expand Down
5 changes: 4 additions & 1 deletion api/messages/messages_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@
logger.info("PATCH /hackathon called")
user_id = get_authenticated_user_id()
if user_id:
return vars(save_hackathon(request.get_json(), user_id))
result = save_hackathon(request.get_json(), user_id)
if isinstance(result, tuple):
return result

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.
return vars(result)
return {"error": "Unauthorized"}, 401


Expand Down
223 changes: 219 additions & 4 deletions api/teams/teams_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import datetime
from db.db import get_db, get_user_doc_reference
from api.messages.messages_service import get_problem_statement_from_id_old
from services.teams_service import get_teams_list
from services.teams_service import get_teams_list, get_team
from services.nonprofits_service import get_single_npo
from common.utils.firestore_helpers import clear_all_caches as clear_cache
from services.users_service import (
Expand All @@ -20,6 +20,31 @@

logger = logging.getLogger("myapp")

# Slack user IDs for OHack admins who are auto-invited to every team channel
# and CCed on completion broadcasts. Single source of truth for both call sites.
TEAM_COMPLETION_SLACK_ADMINS = [
"UCQKX6LPR",
"U035023T81Z",
"UC31XTRT5",
"UC2JW3T3K",
"UPD90QV17",
"UEP2U69AA",
]

# Canonical 8-item Definition of Done. Must stay in lockstep with the frontend
# COMPLETION_ITEMS array in src/components/Teams/TeamCompletionChecklist.js.
COMPLETION_ITEMS = [
{"slug": "deployed", "label": "Deployed", "blurb": "Code is live in production (AWS, fly.io, GCP)."},
{"slug": "nonprofit_signoff", "label": "Nonprofit Signoff", "blurb": "Your nonprofit partner agrees the software meets their needs."},
{"slug": "login_details", "label": "Login Details for Testing", "blurb": "Test credentials shared securely (changeable later)."},
{"slug": "code_updated", "label": "Code Updated", "blurb": "All code, README, and docs in the designated GitHub repo."},
{"slug": "tasks_closed", "label": "Tasks Closed", "blurb": "GitHub issues/tasks closed or addressed."},
{"slug": "sensitive_info_security", "label": "Sensitive Info Secured", "blurb": "No secrets in the repo; shared securely elsewhere."},
{"slug": "documentation", "label": "Documentation", "blurb": "How to use, deploy, update, and configure."},
{"slug": "open_source", "label": "Open-Sourced (MIT)", "blurb": "Repo is public under MIT."},
]
COMPLETION_ITEM_SLUGS = {item["slug"]: item for item in COMPLETION_ITEMS}

def add_team_member(team_id, user_id):
"""
Admin function to add a member to a team
Expand Down Expand Up @@ -423,8 +448,7 @@ def queue_team(propel_user_id, json):


# Add Slack admins
slack_admins = ["UCQKX6LPR", "U035023T81Z", "UC31XTRT5", "UC2JW3T3K", "UPD90QV17", "UEP2U69AA"]
for admin in slack_admins:
for admin in TEAM_COMPLETION_SLACK_ADMINS:
logger.info("Inviting admin %s to slack channel %s", admin, slack_channel)
invite_user_to_channel(admin, slack_channel)

Expand Down Expand Up @@ -988,4 +1012,195 @@ def send_team_message(admin_user, teamid, json):
"message": f"Message sent to team {teamid}",
"success": True,
"team_id": teamid
}
}


def user_is_on_team(propel_user_id, team_id):
"""
Returns True if the caller (identified by their PropelAuth UUID) is one of
the team's users[]. Translates propel_id -> OAuth user_id via
get_propel_user_details_by_id (same pattern as get_my_teams_by_event_id),
then walks team.users[] DocumentReferences and compares each user doc's
`user_id` field (or its `propel_id` field as a fallback).
"""
if not propel_user_id or not team_id:
return False
db = get_db()
team_doc = db.collection("teams").document(team_id).get()
if not team_doc.exists:
return False

try:
details = get_propel_user_details_by_id(propel_user_id) or ()
caller_oauth_user_id = details[1] if len(details) > 1 else None
except Exception as e:
logger.warning("user_is_on_team: get_propel_user_details_by_id failed: %s", e)
caller_oauth_user_id = None

team_data = team_doc.to_dict() or {}
for ref in team_data.get("users", []):
try:
snap = ref.get() if hasattr(ref, "get") else db.collection("users").document(ref).get()
if not snap.exists:
continue
user_data = snap.to_dict() or {}
stored_user_id = user_data.get("user_id")
stored_propel_id = user_data.get("propel_id")
if caller_oauth_user_id and stored_user_id and stored_user_id == caller_oauth_user_id:
return True
if stored_propel_id and stored_propel_id == propel_user_id:
return True
except Exception as e:
logger.warning("user_is_on_team: failed to resolve a user ref on team %s: %s", team_id, e)
continue
return False


def _completion_done_count(checklist):
if not isinstance(checklist, dict):
return 0
return sum(1 for slug in COMPLETION_ITEM_SLUGS if checklist.get(slug, {}).get("done"))


def _project_link(team_data, team_id):
event_id = team_data.get("hackathon_event_id") or ""
if event_id:
return f"https://ohack.dev/hack/{event_id}/team/{team_id}"
return f"https://ohack.dev/hack/team/{team_id}"


def _completer_name(propel_user_id):
try:
details = get_propel_user_details_by_id(propel_user_id) or {}
return details.get("firstName") or details.get("email") or "A teammate"
except Exception:
return "A teammate"


def toggle_completion_item(propel_user_id, team_id, item_slug):
"""
Mark a single Definition-of-Done item complete for a team. Sends a Slack
message to the team's channel. Idempotent in the strict sense: once an item
is `done=True`, this returns 409 (no double-Slack, no unchecking).
"""
if item_slug not in COMPLETION_ITEM_SLUGS:
return {"error": f"Unknown checklist item: {item_slug}"}, 400

if not user_is_on_team(propel_user_id, team_id):
return {"error": "You must be on this team to update its completion checklist."}, 403

db = get_db()
team_doc = db.collection("teams").document(team_id)
snap = team_doc.get()
if not snap.exists:
return {"error": "Team not found"}, 404
team_data = snap.to_dict() or {}
checklist = dict(team_data.get("completion_checklist") or {})
if checklist.get(item_slug, {}).get("done"):
return {"error": "Already complete"}, 409

name = _completer_name(propel_user_id)
now_iso = datetime.now().isoformat()
checklist[item_slug] = {
"done": True,
"completed_at": now_iso,
"completed_by_propel_id": propel_user_id,
"completed_by_name": name,
}
done_n = _completion_done_count(checklist)
total_n = len(COMPLETION_ITEMS)
new_status = "complete" if done_n == total_n else "in_progress"

update = {
"completion_checklist": checklist,
"completion_status": new_status,
}
team_doc.set(update, merge=True)

item = COMPLETION_ITEM_SLUGS[item_slug]
slack_channel = team_data.get("slack_channel")
if slack_channel:
msg = (
f":white_check_mark: *{name}* checked off *{item['label']}* "
f"({done_n}/{total_n} complete!)\n"
f"{item['blurb']}\n"
f"See the full Definition of Done → https://ohack.dev/about/completion"
)
try:
send_slack(message=msg, channel=slack_channel)
except Exception as e:
logger.error("toggle_completion_item: send_slack failed for team %s: %s", team_id, e)

send_slack_audit(
action="completion_toggle",
message=f"Team {team_id} item {item_slug} marked done by {name} ({done_n}/{total_n})",
payload={"team_id": team_id, "item": item_slug, "by": propel_user_id},
)
clear_cache()

# Route through get_team so DocumentReferences on users[] are flattened
# via doc_to_json + enriched into profile dicts (Flask can't JSON-serialize
# raw DocumentReference objects).
fresh = (get_team(team_id) or {}).get("team") or {}
return {"success": True, "team": fresh, "done": done_n, "total": total_n}, 200


def mark_team_complete(propel_user_id, team_id):
"""
Finalize a team's project. Requires all 8 checklist items to be done. Posts
a celebration message to the team's Slack channel CCing the OHack admins.
Idempotent: returns 409 if already complete.
"""
if not user_is_on_team(propel_user_id, team_id):
return {"error": "You must be on this team to mark it complete."}, 403

db = get_db()
team_doc = db.collection("teams").document(team_id)
snap = team_doc.get()
if not snap.exists:
return {"error": "Team not found"}, 404
team_data = snap.to_dict() or {}

if team_data.get("completion_status") == "complete":
return {"error": "Project already marked complete"}, 409

checklist = team_data.get("completion_checklist") or {}
missing = [s for s in COMPLETION_ITEM_SLUGS if not checklist.get(s, {}).get("done")]
if missing:
return {"error": "Cannot mark complete: items remaining", "missing": missing}, 409

name = _completer_name(propel_user_id)
now_iso = datetime.now().isoformat()
team_doc.set({
"completion_status": "complete",
"completion_completed_at": now_iso,
"completion_completed_by_propel_id": propel_user_id,
"completion_completed_by_name": name,
}, merge=True)

slack_channel = team_data.get("slack_channel")
team_name = team_data.get("name", "Your team")
project_link = _project_link(team_data, team_id)
admin_mentions = " ".join(f"<@{uid}>" for uid in TEAM_COMPLETION_SLACK_ADMINS)
msg = (
f":tada: :rocket: :tada: *{team_name} marked their project COMPLETE!*\n"
f"Congratulations team — you shipped it. 🏆\n\n"
f"cc {admin_mentions}\n\n"
f"Project link: {project_link}\n"
f"See examples of completed projects: https://ohack.dev/about/success-stories"
)
if slack_channel:
try:
send_slack(message=msg, channel=slack_channel)
except Exception as e:
logger.error("mark_team_complete: send_slack failed for team %s: %s", team_id, e)

send_slack_audit(
action="completion_complete",
message=f"Team {team_id} ({team_name}) marked PROJECT COMPLETE by {name}",
payload={"team_id": team_id, "by": propel_user_id},
)
clear_cache()

fresh = (get_team(team_id) or {}).get("team") or {}
return {"success": True, "team": fresh}, 200
Loading
Loading