From 58332fb6b8ad4631856484790f6cdd71c27505bd Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:57:37 -0700 Subject: [PATCH 1/2] Link team repos to problem statements on approval; cache the issues proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix-forward for the project-page discoverability gap: approve_team created the GitHub repo and wrote github_links on the team doc only, so problem statements' github field stayed empty and /project/ showed no code. - approve_team now also appends {name, link} to the github array of the team's linked problem statement(s) via _link_repo_to_problem_statements: team.problem_statements refs preferred (string ids and DocumentReferences both handled); falls back to the nonprofit's problem statement only when it has exactly one (ambiguous cases skip + log). Dedupes by normalized link, converts legacy string-shaped github values, clears the cached get_single_problem_statement_old read, and never blocks the approval flow. - GET /api/github/issues responses are now cached 10 min per (org, repo, state) — the public project pages fetch this per repo, so the cache protects the shared GITHUB_TOKEN rate budget. Errors are not cached so transient GitHub failures retry. Note: GITHUB_TOKEN currently returns 401 Bad credentials in prod — the issues proxy (and admin issue summaries) won't work until it's rotated. Co-Authored-By: Claude Fable 5 --- api/github/github_service.py | 18 +++++++- api/teams/teams_service.py | 83 +++++++++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/api/github/github_service.py b/api/github/github_service.py index cccff5b..670779b 100644 --- a/api/github/github_service.py +++ b/api/github/github_service.py @@ -1,11 +1,18 @@ import logging from typing import Dict, Any, List +from cachetools import TTLCache from db.db import get_db from common.utils.github import create_issue, get_issues logger = logging.getLogger("api.github.github_service") logger.setLevel(logging.DEBUG) +# Live issue reads are hit by the public project pages, so cache per +# (org, repo, state) to protect the shared GITHUB_TOKEN rate budget. +# Only successful responses are cached — errors stay uncached so transient +# GitHub failures retry on the next request. +_ISSUES_CACHE = TTLCache(maxsize=512, ttl=600) + def get_github_organization_data(org_name: str) -> Dict[str, Any]: """ Get GitHub organization data including repositories and contributors. @@ -360,16 +367,23 @@ def get_github_issues(org_name: str, repo_name: str, state: str ) -> Dict[str, A if not org_name or not repo_name: return {"error": "Organization name and repository name are required"} + cache_key = (org_name, repo_name, state) + cached_result = _ISSUES_CACHE.get(cache_key) + if cached_result is not None: + return cached_result + # Get issues using the utility function issues = get_issues(org_name=org_name, repo_name=repo_name, state=state) - + if "error" in issues: return {"error": issues["error"]} - return { + result = { "success": True, "issues": issues } + _ISSUES_CACHE[cache_key] = result + return result except Exception as e: logger.error("Error getting GitHub issues for %s/%s: %s", org_name, repo_name, e) diff --git a/api/teams/teams_service.py b/api/teams/teams_service.py index 3f809b5..7c53287 100644 --- a/api/teams/teams_service.py +++ b/api/teams/teams_service.py @@ -752,6 +752,81 @@ def get_teams_by_hackathon_id(hackathon_id): logger.error(f"Error getting teams for hackathon {hackathon_id}: {str(e)}") return {"teams": []} + +def _normalize_repo_link(link): + return (link or "").strip().rstrip("/").lower() + + +def _link_repo_to_problem_statements(db, team_data, nonprofit, repo_name, repo_url): + """Append the team's newly created GitHub repo to the `github` array of the + problem statement(s) the team is working on, so the public /project/ + page shows the code. Historically the repo was only written to the team + doc, leaving most problem statements with an empty `github` field. + + Prefers the team's own `problem_statements` refs; falls back to the + nonprofit's problem statements only when there is exactly one (anything + else is ambiguous — skip and log). Never raises: repo linkage is + best-effort and must not fail the approval flow. + """ + try: + ps_refs = [] + for ps in team_data.get("problem_statements") or []: + if isinstance(ps, str): + ps_refs.append(db.collection("problem_statements").document(ps)) + elif ps is not None and hasattr(ps, "get"): # DocumentReference + ps_refs.append(ps) + + if not ps_refs: + # nonprofit comes from get_single_npo → doc_to_json, so entries are id strings + npo_ps = (nonprofit or {}).get("problem_statements") or [] + if len(npo_ps) == 1 and isinstance(npo_ps[0], str): + ps_refs.append(db.collection("problem_statements").document(npo_ps[0])) + else: + logger.info( + "Not linking repo %s to a problem statement: team has no " + "problem_statements and nonprofit has %d (ambiguous)", + repo_url, len(npo_ps), + ) + return + + for ps_ref in ps_refs: + ps_doc = ps_ref.get() + if not ps_doc.exists: + logger.warning("Problem statement %s not found; skipping repo link", ps_ref.id) + continue + ps_data = ps_doc.to_dict() or {} + existing = ps_data.get("github") + if isinstance(existing, list): + github_list = list(existing) + elif isinstance(existing, str) and existing.strip(): + # Legacy shape: a bare URL string — convert to the {name, link} form + legacy_link = existing.strip() + github_list = [{"name": legacy_link.rstrip("/").split("/")[-1], "link": legacy_link}] + else: + github_list = [] + + already_linked = { + _normalize_repo_link(entry.get("link") if isinstance(entry, dict) else entry) + for entry in github_list + } + if _normalize_repo_link(repo_url) in already_linked: + continue + + github_list.append({"name": repo_name, "link": repo_url}) + ps_ref.set({"github": github_list}, merge=True) + logger.info("Linked repo %s to problem statement %s", repo_url, ps_ref.id) + + # The public single-problem-statement read is cached (10 min) and NOT + # in the shared cache registry — clear it explicitly + try: + from api.messages.messages_service import get_single_problem_statement_old + get_single_problem_statement_old.cache_clear() + except Exception: + logger.warning("Could not clear problem statement cache", exc_info=True) + except Exception: + logger.error("Failed linking repo %s to problem statement(s)", repo_url, exc_info=True) + + def approve_team(admin_user_id, json): """ Admin function to approve a team and pair with nonprofit @@ -929,7 +1004,13 @@ def approve_team(admin_user_id, json): "approved_by": admin_user_id, "approved_at": datetime.now().isoformat() }, merge=True) - + + # Fix-forward: also link the repo to the problem statement(s) so the + # public project page shows the code (best-effort, never blocks approval) + _link_repo_to_problem_statements( + db, team_data, nonprofit, repo_name, full_github_repo_url + ) + # Clear cache clear_cache() From b6fab35131d826f28fba9b99279018aaffc1a60c Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:17:08 -0700 Subject: [PATCH 2/2] [Hearts] Fix certificate generation: OpenAI images.generate now requires model OpenAI removed the default model for images.generate, so sending a certificate 400'd with "Missing required parameter: 'model'". Use gpt-image-1 (matching common/utils/openai_api.py) and handle its base64 response with a URL fallback. Co-Authored-By: Claude Fable 5 --- services/hearts_service.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/services/hearts_service.py b/services/hearts_service.py index 2bac6c4..71ec0f7 100644 --- a/services/hearts_service.py +++ b/services/hearts_service.py @@ -7,6 +7,7 @@ from PIL import ImageDraw from PIL import Image, ImageEnhance +import base64 import urllib.request from dotenv import load_dotenv import os @@ -318,21 +319,28 @@ def generate_certificate_image(userid, name, reasons, hearts, generate_backround # Generate a unique background image if generate_backround_image: - response = client.images.generate(prompt="without text a mesmerizing background with geometric shapes and fireworks no text high resolution 4k", - n=1, - size="1024x1024") - # Example response object: ImagesResponse(created=1721966968, data=[Image(b64_json=None, revised_prompt=None, url='https://oaidalleapiprodscus.blob.core.windows.net/private/org-EzLrpl9lBdn7NnQA25JOTnpt/user-AqP0NOd4VCetE82ZaR72KYLD/img-ahgzSnHmp5CT7NRxK2Ceczd3.png?st=2024-07-26T03%3A09%3A28Z&se=2024-07-26T05%3A09%3A28Z&sp=r&sv=2023-11-03&sr=b&rscd=inline&rsct=image/png&skoid=6aaadede-4fb3-4698-a8f6-684d7786b067&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skt=2024-07-25T22%3A07%3A56Z&ske=2024-07-26T22%3A07%3A56Z&sks=b&skv=2023-11-03&sig=GyYscH4FV8vExnSHl8vsNL6usvEjqeRITl8CStdVfRQ%3D')]) - + response = client.images.generate( + model="gpt-image-1", + prompt="without text a mesmerizing background with geometric shapes and fireworks no text high resolution 4k", + n=1, + size="1024x1024", + timeout=90.0) + # Check if response is valid if not response.data: # Print error from response - error(logger, "Error with OpenAI API", response=response) + error(logger, "Error with OpenAI API", response=response) raise Exception("OpenAI response is not valid") - - image_url = response.data[0].url - info(logger, "Generated image from OpenAI", image_url=image_url) - urllib.request.urlretrieve(image_url, "./generated_image.png") + # gpt-image-1 returns base64 data; fall back to URL for other models + image_data = response.data[0] + if getattr(image_data, "b64_json", None): + with open("./generated_image.png", "wb") as f: + f.write(base64.b64decode(image_data.b64_json)) + info(logger, "Generated image from OpenAI (base64)") + else: + info(logger, "Generated image from OpenAI", image_url=image_data.url) + urllib.request.urlretrieve(image_data.url, "./generated_image.png") background_image = Image.open('generated_image.png') enhancer = ImageEnhance.Brightness(background_image)