From 3c2561c8e21c8d2ec9f73c595184c1de8568a9e6 Mon Sep 17 00:00:00 2001 From: Greg V Date: Tue, 24 Mar 2026 22:41:51 -0700 Subject: [PATCH] Add problem statement tooling, API enhancements, and volunteer email fix - Add list_problem_statements action to scripts/util.py with --search filtering - Add get_all_problem_statements() and get_all_nonprofits() to firebase utils - Add scripts to create Client Case Management and WIAL problem statements - Add GET /problem_statement//nonprofit reverse lookup endpoint - Add PATCH /hackathon/problem_statements for visible problem statements - Update volunteer confirmation email wording from "volunteering" to "applying" Co-Authored-By: Claude Opus 4.6 (1M context) --- api/messages/messages_views.py | 15 ++ common/utils/firebase.py | 20 ++ ...eate_client_case_mgmt_problem_statement.py | 185 ++++++++++++++++++ scripts/create_wial_problem_statement.py | 163 +++++++++++++++ scripts/util.py | 29 ++- services/hackathons_service.py | 29 +++ services/nonprofits_service.py | 25 +++ services/volunteers_service.py | 8 +- 8 files changed, 469 insertions(+), 5 deletions(-) create mode 100644 scripts/create_client_case_mgmt_problem_statement.py create mode 100644 scripts/create_wial_problem_statement.py diff --git a/api/messages/messages_views.py b/api/messages/messages_views.py index 2537f86..4946705 100644 --- a/api/messages/messages_views.py +++ b/api/messages/messages_views.py @@ -49,6 +49,7 @@ get_npo_list, get_npo_by_hackathon_id, get_npos_by_hackathon_id, + get_nonprofits_by_problem_statement_id, save_npo_legacy as save_npo, update_npo_legacy as update_npo, remove_npo_legacy as remove_npo, @@ -68,6 +69,7 @@ get_hackathon_request_by_id, update_hackathon_request, update_hackathon_volunteers, + update_hackathon_visible_problem_statements, get_hackathon_list, get_volunteer_by_event, ) @@ -227,6 +229,14 @@ def remove_nonprofit_from_hackathon_api(): if auth_user and auth_user.user_id: return remove_nonprofit_from_hackathon(request.get_json()) +@bp.route("/hackathon/problem_statements", methods=["PATCH"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def update_hackathon_visible_problem_statements_api(): + logger.info("PATCH /hackathon/problem_statements called") + if auth_user and auth_user.user_id: + return vars(update_hackathon_visible_problem_statements(request.get_json(), auth_user.user_id)) + @bp.route("/hackathon//mentor", methods=["POST"]) @auth.require_user @@ -569,6 +579,11 @@ def get_single_problem(project_id): logger.info(f"GET /problem_statement/{project_id} called") return (get_single_problem_statement_old(project_id)) +@bp.route("/problem_statement//nonprofit", methods=["GET"]) +def get_nonprofit_for_problem_statement(project_id): + logger.info(f"GET /problem_statement/{project_id}/nonprofit called") + return get_nonprofits_by_problem_statement_id(project_id) + # # --------------------- TO BE REPLACED ROUTES ------------------------------------------# diff --git a/common/utils/firebase.py b/common/utils/firebase.py index 5bab7eb..f5a745b 100644 --- a/common/utils/firebase.py +++ b/common/utils/firebase.py @@ -465,6 +465,16 @@ def create_new_nonprofit(name, description, website, slack_channel, contact_peop return nonprofit +def get_all_nonprofits(): + db = get_db() # this connects to our Firestore database + docs = db.collection('nonprofits').stream() + results = [] + for doc in docs: + adict = doc.to_dict() + adict["id"] = doc.id + results.append(adict) + return results + def get_nonprofit_by_name(name): db = get_db() # this connects to our Firestore database logger.info(f"Getting nonprofit {name}") @@ -689,6 +699,16 @@ def get_hackathon_reference_by_title(hackathon_title): for doc in docs: return doc.reference +def get_all_problem_statements(): + db = get_db() # this connects to our Firestore database + docs = db.collection('problem_statements').stream() + results = [] + for doc in docs: + adict = doc.to_dict() + adict["id"] = doc.id + results.append(adict) + return results + def get_problem_statement_by_id(id): db = get_db() # this connects to our Firestore database doc = db.collection('problem_statements').document(id).get() diff --git a/scripts/create_client_case_mgmt_problem_statement.py b/scripts/create_client_case_mgmt_problem_statement.py new file mode 100644 index 0000000..55709cd --- /dev/null +++ b/scripts/create_client_case_mgmt_problem_statement.py @@ -0,0 +1,185 @@ +""" +Script to create the "Nonprofit Client & Case Management Platform" problem statement +and link it to the associated nonprofits from the SRD. + +Usage: + python create_client_case_mgmt_problem_statement.py + python create_client_case_mgmt_problem_statement.py --dry-run +""" +import argparse +import os +import sys +import logging +from dotenv import load_dotenv + +sys.path.append("../") +load_dotenv() + +logger = logging.getLogger(__name__) +logger.addHandler(logging.StreamHandler()) +logger.setLevel(logging.INFO) + +from common.utils.firebase import ( + create_new_problem_statement, + link_nonprofit_to_problem_statement, + add_reference_link_to_problem_statement, + get_nonprofit_by_name, +) + +# --- Problem Statement Definition (from the SRD) --- + +TITLE = "Nonprofit Client & Case Management Platform" + +DESCRIPTION = ( + "Build a lightweight, open-source client and case management web application " + "that any nonprofit can deploy for under $30/month. The system handles client " + "registration, demographics, visit scheduling, treatment/service logging, " + "role-based access, and basic reporting. It is the generalized version of " + "problems submitted by 9+ OHack nonprofits across 7 hackathons (2016-2024). " + "Key insight: every one of these nonprofits submitted fundamentally the same " + "problem - 'We need to register clients, record what we do for them, and " + "report on it.' The only differences are domain-specific vocabulary (patients " + "vs. animals vs. alumni vs. families). A configurable system with customizable " + "fields solves all of them." +) + +STATUS = "hackathon" +SLACK_CHANNEL = "#npo-client-case-mgmt" +FIRST_THOUGHT_OF = "2016" +SKILLS = [ + "React/Next.js", + "Python/FastAPI", + "PostgreSQL/Supabase", + "Authentication/RBAC", + "CSV Import/Export", + "AI/LLM Integration", +] + +# Nonprofits to link (exact names as they appear in the DB) +NONPROFITS = [ + "Chandler CARE Center", + "Lost Our Home Pet Rescue", + "SEED SPOT", + "NMTSA - Education Platform", + "NMTSA - Website", + "Tranquility Trail Animal Sanctuary", +] + +# Nonprofits from the SRD that are NOT in the DB yet +MISSING_NONPROFITS = [ + "Will2Walk", + "ICM Food & Clothing Bank", + "Sunshine Acres", +] + +# Reference links +REFERENCES = [ + { + "name": "SRD: Client & Case Management", + "link": "https://docs.google.com/document/d/1smz8xouHO2AzkEaa8iJj95JQZqvyxgOTDOO_BC8N6iI/edit", + }, + { + "name": "OHack 2020 Summer Internship (EHR + CRM)", + "link": "https://github.com/opportunity-hack/2020-summer-volunteer-internship", + }, + { + "name": "Chandler CARE Center 2019 (2nd Place)", + "link": "https://devpost.com/software/chandler-care-center-data-intake", + }, + { + "name": "NMTSA 2019 Schedule App", + "link": "https://devpost.com/software/nmtsa-scheduleapp", + }, + { + "name": "NMTSA 2017 Project", + "link": "https://devpost.com/software/team-3-nmtsa", + }, +] + + +def main(): + parser = argparse.ArgumentParser( + description="Create the Client & Case Management problem statement and link nonprofits" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be done without making changes", + ) + args = parser.parse_args() + + if args.dry_run: + print("=== DRY RUN ===\n") + print(f"Would create problem statement:") + print(f" Title: {TITLE}") + print(f" Status: {STATUS}") + print(f" Slack: {SLACK_CHANNEL}") + print(f" First thought of: {FIRST_THOUGHT_OF}") + print(f" Skills: {', '.join(SKILLS)}") + print(f" Description: {DESCRIPTION[:150]}...") + print(f"\nWould link to nonprofits:") + for np_name in NONPROFITS: + print(f" - {np_name}") + print(f"\nWould add references:") + for ref in REFERENCES: + print(f" - {ref['name']}: {ref['link']}") + print(f"\nWARNING: These nonprofits are NOT in the DB and will be skipped:") + for np_name in MISSING_NONPROFITS: + print(f" - {np_name}") + return + + # 1. Create the problem statement + print("Creating problem statement...") + result = create_new_problem_statement( + title=TITLE, + description=DESCRIPTION, + status=STATUS, + slack_channel=SLACK_CHANNEL, + first_thought_of=FIRST_THOUGHT_OF, + skills=SKILLS, + ) + + # Handle case where it already exists + if isinstance(result, list): + # Already exists - returned list of DocumentSnapshots + ps_id = result[0].id + print(f"Problem statement already exists with ID: {ps_id}") + else: + ps_id = result["id"] + print(f"Created problem statement with ID: {ps_id}") + + # 2. Link to nonprofits + print("\nLinking to nonprofits...") + for np_name in NONPROFITS: + try: + # Verify nonprofit exists first + np = get_nonprofit_by_name(np_name) + if not np: + print(f" SKIP: '{np_name}' not found in database") + continue + link_nonprofit_to_problem_statement(np_name, ps_id) + print(f" OK: Linked '{np_name}'") + except Exception as e: + print(f" ERROR linking '{np_name}': {e}") + + # 3. Add reference links + print("\nAdding references...") + for ref in REFERENCES: + try: + add_reference_link_to_problem_statement( + problem_statement_id=ps_id, + name=ref["name"], + link=ref["link"], + ) + print(f" OK: Added '{ref['name']}'") + except Exception as e: + print(f" ERROR adding ref '{ref['name']}': {e}") + + print(f"\nDone! Problem statement ID: {ps_id}") + print(f"\nNOTE: These nonprofits need to be created in the DB before they can be linked:") + for np_name in MISSING_NONPROFITS: + print(f" - {np_name}") + + +if __name__ == "__main__": + main() diff --git a/scripts/create_wial_problem_statement.py b/scripts/create_wial_problem_statement.py new file mode 100644 index 0000000..cb2b478 --- /dev/null +++ b/scripts/create_wial_problem_statement.py @@ -0,0 +1,163 @@ +""" +Script to create the "WIAL Global Chapter Network Platform" problem statement +and link it to the World Institute for Action Learning nonprofit. + +Usage: + python create_wial_problem_statement.py + python create_wial_problem_statement.py --dry-run +""" +import argparse +import os +import sys +import logging +from dotenv import load_dotenv + +sys.path.append("../") +load_dotenv() + +logger = logging.getLogger(__name__) +logger.addHandler(logging.StreamHandler()) +logger.setLevel(logging.INFO) + +from common.utils.firebase import ( + create_new_problem_statement, + link_nonprofit_to_problem_statement, + add_reference_link_to_problem_statement, +) + +# --- Problem Statement Definition (from the WIAL SRD) --- + +TITLE = "WIAL Global Chapter Network Platform" + +DESCRIPTION = ( + "Build a multi-site website platform for the World Institute for Action Learning (WIAL), " + "a global nonprofit that certifies Action Learning Coaches across 20+ countries. WIAL needs " + "a system where chapter leads can provision branded chapter websites from a shared template, " + "manage a local coach directory that syncs to a global directory, collect membership dues via " + "Stripe/PayPal, and maintain consistent branding across all sites. The platform must support " + "multi-language content, role-based access (Super Admin, Chapter Lead, Content Creator, Coach), " + "and low-bandwidth design for chapters in Africa and SE Asia. AI features include cross-lingual " + "semantic coach directory search, AI-generated chapter content with cultural adaptation, and " + "smart coach matching for prospective clients." +) + +STATUS = "hackathon" +SLACK_CHANNEL = "#npo-wial" +FIRST_THOUGHT_OF = "2025" +SKILLS = [ + "Next.js/React", + "Cloudflare Pages", + "Multi-site Architecture", + "Stripe/PayPal Integration", + "Multi-language/i18n", + "AI/LLM Integration", + "Semantic Search/Embeddings", + "Low-bandwidth Design", +] + +# Nonprofit to link - provided by user +NONPROFIT_ID = "O5AOBkkTAsUhSrcVaX7V" + +# Reference links +REFERENCES = [ + { + "name": "SRD: WIAL Global Chapter Network", + "link": "https://docs.google.com/document/d/1smz8xouHO2AzkEaa8iJj95JQZqvyxgOTDOO_BC8N6iI/edit", + }, + { + "name": "WIAL Global Website", + "link": "https://wial.org", + }, + { + "name": "WIAL-USA Chapter (example)", + "link": "https://wial-usa.org", + }, + { + "name": "WIAL Nigeria Chapter (example)", + "link": "https://wialnigeria.org", + }, +] + + +def main(): + parser = argparse.ArgumentParser( + description="Create the WIAL problem statement and link nonprofit" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be done without making changes", + ) + args = parser.parse_args() + + if args.dry_run: + print("=== DRY RUN ===\n") + print(f"Would create problem statement:") + print(f" Title: {TITLE}") + print(f" Status: {STATUS}") + print(f" Slack: {SLACK_CHANNEL}") + print(f" First thought of: {FIRST_THOUGHT_OF}") + print(f" Skills: {', '.join(SKILLS)}") + print(f" Description: {DESCRIPTION[:150]}...") + print(f"\nWould link to nonprofit ID: {NONPROFIT_ID}") + print(f"\nWould add references:") + for ref in REFERENCES: + print(f" - {ref['name']}: {ref['link']}") + return + + # 1. Create the problem statement + print("Creating problem statement...") + result = create_new_problem_statement( + title=TITLE, + description=DESCRIPTION, + status=STATUS, + slack_channel=SLACK_CHANNEL, + first_thought_of=FIRST_THOUGHT_OF, + skills=SKILLS, + ) + + # Handle case where it already exists + if isinstance(result, list): + ps_id = result[0].id + print(f"Problem statement already exists with ID: {ps_id}") + else: + ps_id = result["id"] + print(f"Created problem statement with ID: {ps_id}") + + # 2. Link to nonprofit by directly updating Firestore + # link_nonprofit_to_problem_statement uses name lookup, but we have the ID. + # We'll use the firebase utils to link by looking up the nonprofit name first. + print("\nLinking to World Institute for Action Learning...") + try: + from common.utils.firebase import get_db + db = get_db() + # Get the nonprofit doc to find its name + np_doc = db.collection("nonprofits").document(NONPROFIT_ID).get() + if np_doc.exists: + np_name = np_doc.to_dict().get("name", "") + print(f" Found nonprofit: {np_name}") + link_nonprofit_to_problem_statement(np_name, ps_id) + print(f" OK: Linked '{np_name}'") + else: + print(f" ERROR: Nonprofit {NONPROFIT_ID} not found in database") + except Exception as e: + print(f" ERROR linking nonprofit: {e}") + + # 3. Add reference links + print("\nAdding references...") + for ref in REFERENCES: + try: + add_reference_link_to_problem_statement( + problem_statement_id=ps_id, + name=ref["name"], + link=ref["link"], + ) + print(f" OK: Added '{ref['name']}'") + except Exception as e: + print(f" ERROR adding ref '{ref['name']}': {e}") + + print(f"\nDone! Problem statement ID: {ps_id}") + + +if __name__ == "__main__": + main() diff --git a/scripts/util.py b/scripts/util.py index ce135c4..3464813 100644 --- a/scripts/util.py +++ b/scripts/util.py @@ -14,7 +14,7 @@ # from common.utils.slack import send_slack, get_active_users #TODO: Bunch of unused imports here -from common.utils.firebase import get_hackathon_by_event_id, create_new_hackathon, add_reference_link_to_problem_statement, create_new_problem_statement, link_nonprofit_to_problem_statement, link_problem_statement_to_hackathon_event, get_nonprofit_by_id, add_image_to_nonprofit_by_nonprofit_id, add_image_to_nonprofit, add_nonprofit_to_hackathon, create_new_problem_statement, create_new_nonprofit, create_new_hackathon, link_nonprofit_to_problem_statement, link_problem_statement_to_hackathon_event, get_nonprofit_by_name, create_team, add_user_by_email_to_team, add_user_by_slack_id_to_team, add_team_to_hackathon, add_problem_statement_to_team, get_user_by_user_id, add_reference_link_to_problem_statement, get_user_by_email, create_user, add_user_to_team, delete_user_by_id, get_team_by_name, get_user_by_id, remove_user_from_team +from common.utils.firebase import get_hackathon_by_event_id, create_new_hackathon, add_reference_link_to_problem_statement, create_new_problem_statement, link_nonprofit_to_problem_statement, link_problem_statement_to_hackathon_event, get_nonprofit_by_id, add_image_to_nonprofit_by_nonprofit_id, add_image_to_nonprofit, add_nonprofit_to_hackathon, create_new_problem_statement, create_new_nonprofit, create_new_hackathon, link_nonprofit_to_problem_statement, link_problem_statement_to_hackathon_event, get_nonprofit_by_name, create_team, add_user_by_email_to_team, add_user_by_slack_id_to_team, add_team_to_hackathon, add_problem_statement_to_team, get_user_by_user_id, add_reference_link_to_problem_statement, get_user_by_email, create_user, add_user_to_team, delete_user_by_id, get_team_by_name, get_user_by_id, remove_user_from_team, get_all_problem_statements from common.utils.cdn import upload_to_cdn import re import urllib.request @@ -117,6 +117,7 @@ def util_add_image_to_nonprofit(nonprofit_id=None, nonprofit_name=None, image_ur parser.add_argument('--slack_channel', type=str, help='user slack channel') parser.add_argument('--slack_message', type=str, help='slack message') +parser.add_argument('--search', type=str, help='search keyword for filtering problem statements') args = parser.parse_args() @@ -192,6 +193,32 @@ def util_add_image_to_nonprofit(nonprofit_id=None, nonprofit_name=None, image_ur elif args.action == "link_problem_statement_to_hackathon_event": link_problem_statement_to_hackathon_event(hackathon_event_id=args.hackathon_event_id, problem_statement_id=args.problem_statement_id) +elif args.action == "list_problem_statements": + problem_statements = get_all_problem_statements() + search = args.search.lower() if args.search else None + if search: + problem_statements = [ + ps for ps in problem_statements + if search in ps.get("title", "").lower() + or search in ps.get("description", "").lower() + ] + logger.info(f"Found {len(problem_statements)} problem statement(s)") + for ps in problem_statements: + title = ps.get("title", "N/A") + desc = ps.get("description", "N/A") + if len(desc) > 200: + desc = desc[:200] + "..." + status = ps.get("status", "N/A") + refs = ps.get("references", []) + ref_strs = [f"{r.get('name', '')}: {r.get('link', '')}" for r in refs] if refs else [] + print(f"\n{'='*80}") + print(f"ID: {ps.get('id', 'N/A')}") + print(f"Title: {title}") + print(f"Description: {desc}") + print(f"Status: {status}") + if ref_strs: + print(f"References: {'; '.join(ref_strs)}") + else: # Print help parser.print_help() diff --git a/services/hackathons_service.py b/services/hackathons_service.py index a77976a..16faddb 100644 --- a/services/hackathons_service.py +++ b/services/hackathons_service.py @@ -649,6 +649,8 @@ def save_hackathon(json_data, propel_id): hackathon_data["nonprofits"] = [db.collection("nonprofits").document(npo) for npo in json_data["nonprofits"]] if "teams" in json_data: hackathon_data["teams"] = [db.collection("teams").document(team) for team in json_data["teams"]] + if "visible_problem_statements" in json_data: + hackathon_data["visible_problem_statements"] = json_data["visible_problem_statements"] @firestore.transactional def update_hackathon(transaction): @@ -676,3 +678,30 @@ def update_hackathon(transaction): except Exception as e: logger.error(f"Error saving/updating hackathon: {str(e)}") return {"error": "An unexpected error occurred"}, 500 + + +@limits(calls=50, period=ONE_MINUTE) +def update_hackathon_visible_problem_statements(json_data, propel_id): + """Update the visible_problem_statements list for a hackathon.""" + db = _get_db() + hackathon_id = json_data.get("hackathonId") + problem_statement_ids = json_data.get("problemStatementIds", []) + + if not hackathon_id: + return {"error": "hackathonId is required"}, 400 + + try: + hackathon_ref = db.collection('hackathons').document(hackathon_id) + hackathon_ref.update({ + "visible_problem_statements": problem_statement_ids, + "last_updated": firestore.SERVER_TIMESTAMP, + "last_updated_by": propel_id, + }) + + clear_cache() + + logger.info(f"Updated visible_problem_statements for hackathon {hackathon_id}") + return Message("Updated visible problem statements") + except Exception as e: + logger.error(f"Error updating visible_problem_statements: {str(e)}") + return {"error": "An unexpected error occurred"}, 500 diff --git a/services/nonprofits_service.py b/services/nonprofits_service.py index 66eed18..9bdaeb0 100644 --- a/services/nonprofits_service.py +++ b/services/nonprofits_service.py @@ -85,6 +85,31 @@ def delete_npo(id): # ==================== Raw-Firestore functions (from messages_service) ==================== +@limits(calls=100, period=ONE_MINUTE) +def get_nonprofits_by_problem_statement_id(problem_statement_id): + """Reverse lookup: given a problem statement ID, find the nonprofit(s) that own it.""" + logger.debug(f"get_nonprofits_by_problem_statement_id start ps_id={problem_statement_id}") + db = _get_db() + ps_ref = db.collection('problem_statements').document(problem_statement_id) + + try: + docs = db.collection('nonprofits').where( + 'problem_statements', 'array_contains', ps_ref + ).stream() + + results = [] + for doc in docs: + npo = doc_to_json(docid=doc.id, doc=doc) + if npo: + results.append(npo) + + logger.info(f"get_nonprofits_by_problem_statement_id found {len(results)} nonprofits") + return {"nonprofits": results} + except Exception as e: + logger.error(f"Error in get_nonprofits_by_problem_statement_id: {e}") + return {"nonprofits": []} + + @limits(calls=1000, period=ONE_MINUTE) def get_single_npo(npo_id): logger.debug(f"get_npo start npo_id={npo_id}") diff --git a/services/volunteers_service.py b/services/volunteers_service.py index d907cc8..e84976b 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -251,19 +251,19 @@ def send_volunteer_confirmation_email(first_name: str, last_name: str, email: st params = { "from": "Opportunity Hack ", "to": [email], - "subject": f"Thank you for volunteering as an Opportunity Hack {volunteer_type_readable}", + "subject": f"Thank you for applying as an Opportunity Hack {volunteer_type_readable}", "html": f"""
Opportunity Hack Logo
-

Thank you for volunteering with Opportunity Hack!

+

Thank you for applying as an Opportunity Hack {volunteer_type_readable}!

Hello {full_name},

-

Thank you for signing up as a {volunteer_type_readable} for Opportunity Hack. +

Thank you for applying as a {volunteer_type_readable} for Opportunity Hack. We've received your information and our team will review it shortly.

{calendar_note} {volunteer_specific_content} -

We'll be in touch with next steps.

+

We'll be in touch with next steps. In the meantime, feel free to explore our website and learn more about our mission and past events.

The Opportunity Hack Team

Website: ohack.dev