diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..c54ed69 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,108 @@ +# Copilot Instructions for backend-ohack.dev + +## Project Overview +This is the Python/Flask backend API for [Opportunity Hack](https://www.ohack.dev) — a nonprofit hackathon platform. It serves the frontend at [ohack.dev](https://www.ohack.dev) and is accessible at [api.ohack.dev](https://api.ohack.dev). + +## Tech Stack +- **Language**: Python 3.9.13 +- **Framework**: Flask +- **Database**: Google Cloud Firestore (Firebase) +- **Authentication**: PropelAuth (`propelauth_flask`) +- **Caching**: Redis (optional locally) +- **Deployment**: Fly.io +- **Testing**: pytest +- **Linting**: pylint + +## Project Structure +``` +api/ # Flask blueprints (one subdirectory per feature) + certificates/ # Certificate generation + contact/ # Contact form + github/ # GitHub integration + hearts/ # Kudos/hearts feature + judging/ # Hackathon judging + leaderboard/ # Leaderboard endpoints + llm/ # LLM/AI features + messages/ # Messaging endpoints + newsletters/ # Newsletter management + problemstatements/ # Nonprofit problem statements + slack/ # Slack integration + teams/ # Team management + users/ # User profile endpoints + validate/ # Validation endpoints + volunteers/ # Volunteer management + __init__.py # App factory (create_app) +common/ # Shared utilities + auth.py # PropelAuth setup and helpers + exceptions.py # Custom exception classes + utils/ # Utility helpers +db/ # Database layer + db.py # Main DB module + firestore.py # Firestore client wrapper + interface.py # DB interface/abstraction + mem.py # In-memory caching +model/ # Data models (dataclasses) +services/ # Business logic services +test/ # Integration / service tests +``` + +## Development Commands +```bash +# Install dependencies +pip install -r requirements.txt + +# Run the app (development) +flask run + +# Run tests +pytest + +# Run a single test +pytest path/to/test_file.py::test_function_name + +# Run linter +pylint api/ common/ db/ model/ services/ +pylint -E api/*.py # errors only (used in CI) +``` + +## Environment Setup +Copy `.env.example` to `.env` and fill in real values (obtain secrets from the team Slack channel): +```bash +cp .env.example .env +``` + +Key environment variables: +- `CLIENT_ORIGIN_URL` — allowed CORS origins (comma-separated or `*` for dev) +- `FIREBASE_CERT_CONFIG` — Firebase service account JSON +- `PROPEL_AUTH_KEY` / `PROPEL_AUTH_URL` — PropelAuth credentials +- `OPENAI_API_KEY` — OpenAI key for LLM features +- `SLACK_BOT_TOKEN` / `SLACK_WEBHOOK` — Slack integration +- `ENC_DEC_KEY` — encryption/decryption key +- `REDIS_URL` — Redis URL (optional for local development) + +## Code Style Guidelines +- **Python version**: 3.9.13 +- **Naming**: `snake_case` for variables and functions; `PascalCase` for classes +- **Imports**: standard library → third-party → local (one blank line between groups) +- **Type hints**: use `from typing import *` and annotate function parameters and return types +- **Error handling**: use `try/except` with specific exception types; avoid bare `except` +- **Docstrings**: recommended for complex logic, not required for every function +- **Linting**: pylint with `.pylintrc` disabling `missing-module-docstring` (C0114), `missing-function-docstring` (C0116), and `too-few-public-methods` (R0903) + +## Architecture Patterns +- **App factory**: `api/__init__.py` exports `create_app()` which registers all Flask blueprints +- **Blueprints**: each feature lives in its own subdirectory under `api/` and registers a Flask `Blueprint` +- **Service layer**: business logic lives in `services/` or in `*_service.py` files inside the blueprint directory, not in view files +- **DB abstraction**: all Firestore access goes through `db/` — do not call Firestore directly from views +- **Auth**: use `common/auth.py`'s `auth` object and `auth_user` / `getOrgId()` helpers for authentication; do not import PropelAuth directly in views +- **Utilities**: use `common/utils/safe_get_env_var()` to read environment variables (never `os.environ[]` directly in business logic) + +## Testing Guidelines +- Tests live alongside the code they test inside a `tests/` subdirectory of each blueprint, or in the top-level `test/` directory for integration tests +- Use `pytest` fixtures defined in `conftest.py` +- Mock external services (Firestore, PropelAuth, Slack, OpenAI) in unit tests +- Run the full suite with `pytest` before opening a PR + +## CI / CD +- GitHub Actions workflow (`.github/workflows/main.yml`) runs pylint and tests on every push and PR +- Merges to `develop` deploy to the staging environment; merges to `main` deploy to production via Fly.io diff --git a/api/contact/contact_service.py b/api/contact/contact_service.py index 5187604..c99226c 100644 --- a/api/contact/contact_service.py +++ b/api/contact/contact_service.py @@ -120,6 +120,55 @@ def verify_recaptcha(token: str) -> bool: logger.exception("Error verifying recaptcha: %s", str(e)) return False +def get_all_contact_submissions() -> Dict[str, Any]: + """ + Get all contact form submissions for admin viewing. + + Returns: + Dict with list of submissions sorted by timestamp desc + """ + try: + db = get_db() + submissions = [] + docs = db.collection('contact_submissions').order_by('timestamp', direction='DESCENDING').stream() + for doc in docs: + data = doc.to_dict() + data['id'] = doc.id + submissions.append(data) + return {"success": True, "submissions": submissions} + except Exception as e: + logger.error("Error fetching contact submissions: %s", str(e)) + return {"success": False, "error": str(e), "submissions": []} + + +def admin_update_contact_submission(doc_id: str, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Update a contact submission's status and admin notes. + + Args: + doc_id: Firestore document ID + data: Dict with status and/or adminNotes + + Returns: + Dict with success status + """ + try: + db = get_db() + update_data = {} + if 'status' in data: + update_data['status'] = data['status'] + if 'adminNotes' in data: + update_data['adminNotes'] = data['adminNotes'] + update_data['updatedAt'] = _get_current_timestamp() + + db.collection('contact_submissions').document(doc_id).update(update_data) + logger.info("Updated contact submission %s", doc_id) + return {"success": True} + except Exception as e: + logger.error("Error updating contact submission %s: %s", doc_id, str(e)) + return {"success": False, "error": str(e)} + + def send_confirmation_email(contact_data) -> bool: """ Send a confirmation email to the contact form submitter. diff --git a/api/contact/contact_views.py b/api/contact/contact_views.py index eacc332..722871f 100644 --- a/api/contact/contact_views.py +++ b/api/contact/contact_views.py @@ -1,7 +1,12 @@ from flask import Blueprint, jsonify, request from common.log import get_logger from common.exceptions import InvalidInputError -from api.contact.contact_service import submit_contact_form +from common.auth import auth, auth_user +from api.contact.contact_service import ( + submit_contact_form, + get_all_contact_submissions, + admin_update_contact_submission, +) logger = get_logger(__name__) bp = Blueprint('contact', __name__, url_prefix='/api') @@ -87,4 +92,40 @@ def handle_contact_form(): return jsonify({ "success": False, "error": "An error occurred while processing your request" - }), 500 \ No newline at end of file + }), 500 + + +def getOrgId(req): + return req.headers.get("X-Org-Id") + + +@bp.route("/contact/submissions", methods=["GET"]) +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_list_contact_submissions(): + """Admin endpoint to list all contact form submissions.""" + try: + result = get_all_contact_submissions() + if result.get('success'): + return jsonify(result), 200 + return jsonify(result), 500 + except Exception as e: + logger.exception("Error listing contact submissions: %s", str(e)) + return jsonify({"success": False, "error": str(e)}), 500 + + +@bp.route("/contact/submissions/", methods=["PATCH"]) +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_update_contact_submission_route(submission_id): + """Admin endpoint to update a contact submission's status and notes.""" + try: + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "Empty request body"}), 400 + + result = admin_update_contact_submission(submission_id, data) + if result.get('success'): + return jsonify(result), 200 + return jsonify(result), 500 + except Exception as e: + logger.exception("Error updating contact submission: %s", str(e)) + return jsonify({"success": False, "error": str(e)}), 500 \ No newline at end of file diff --git a/api/judging/judging_service.py b/api/judging/judging_service.py index 5f53a63..2570673 100644 --- a/api/judging/judging_service.py +++ b/api/judging/judging_service.py @@ -27,8 +27,8 @@ from model.judge_assignment import JudgeAssignment from model.judge_score import JudgeScore from model.judge_panel import JudgePanel -from api.messages.messages_service import ( - get_single_hackathon_event, +from services.hackathons_service import get_single_hackathon_event +from services.teams_service import ( get_team, get_teams_batch ) diff --git a/api/messages/messages_service.py b/api/messages/messages_service.py index c1f8496..ce7a63c 100644 --- a/api/messages/messages_service.py +++ b/api/messages/messages_service.py @@ -1,1742 +1,66 @@ from common.utils import safe_get_env_var -from common.utils.slack import send_slack_audit, create_slack_channel, send_slack, async_send_slack, invite_user_to_channel, get_user_info -from common.utils.firebase import get_hackathon_by_event_id, upsert_news, upsert_praise, get_github_contributions_for_user,get_volunteer_from_db_by_event, get_volunteer_checked_in_from_db_by_event, get_user_by_user_id, get_recent_praises, get_praises_by_user_id -from common.utils.openai_api import generate_and_save_image_to_cdn -from common.utils.github import create_github_repo, get_all_repos, validate_github_username -from common.utils.oauth_providers import extract_slack_user_id +from common.utils.slack import send_slack_audit, send_slack, invite_user_to_channel +from common.utils.firebase import get_github_contributions_for_user from api.messages.message import Message from google.cloud.exceptions import NotFound from services.users_service import get_propel_user_details_by_id, get_slack_user_from_propel_user_id, get_user_from_slack_id, save_user import json import uuid -from datetime import datetime, timedelta -import pytz -import time -from functools import wraps +from datetime import datetime -from common.log import get_logger, info, debug, warning, error, exception +from common.log import get_logger, debug, error import firebase_admin -from firebase_admin.firestore import DocumentReference, DocumentSnapshot from firebase_admin import credentials, firestore -import requests -from common.utils.validators import validate_email, validate_url, validate_hackathon_data -from common.exceptions import InvalidInputError +from cachetools import cached, TTLCache -from cachetools import cached, LRUCache, TTLCache -from cachetools.keys import hashkey - -from ratelimit import limits -from datetime import datetime, timedelta -import os - -from db.db import fetch_user_by_user_id, get_user_doc_reference -import resend -import random - - -logger = get_logger("messages_service") - -resend_api_key = os.getenv("RESEND_WELCOME_EMAIL_KEY") -if not resend_api_key: - logger.error("RESEND_WELCOME_EMAIL_KEY not set") -else: - resend.api_key = resend_api_key - -google_recaptcha_key = safe_get_env_var("GOOGLE_CAPTCHA_SECRET_KEY") - -CDN_SERVER = os.getenv("CDN_SERVER") -ONE_MINUTE = 1*60 -THIRTY_SECONDS = 30 -def get_public_message(): - logger.debug("~ Public ~") - return Message( - "aaThis is a public message." - ) - - -def get_protected_message(): - logger.debug("~ Protected ~") - - return Message( - "This is a protected message." - ) - - -def get_admin_message(): - logger.debug("~ Admin ~") - - return Message( - "This is an admin message." - ) - -def hash_key(docid, doc=None, depth=0): - return hashkey(docid) - -def log_execution_time(func): - @wraps(func) - def wrapper(*args, **kwargs): - start_time = time.time() - result = func(*args, **kwargs) - end_time = time.time() - execution_time = end_time - start_time - logger.debug(f"{func.__name__} execution time: {execution_time:.4f} seconds") - return result - return wrapper - -# Generically handle a DocumentSnapshot or a DocumentReference -@cached(cache=TTLCache(maxsize=2000, ttl=3600), key=hash_key) -def doc_to_json(docid=None, doc=None, depth=0): - if not docid: - logger.debug("docid is NoneType") - return - if not doc: - logger.debug("doc is NoneType") - return - - # Check if type is DocumentSnapshot - if isinstance(doc, firestore.DocumentSnapshot): - logger.debug("doc is DocumentSnapshot") - d_json = doc.to_dict() - # Check if type is DocumentReference - elif isinstance(doc, firestore.DocumentReference): - logger.debug("doc is DocumentReference") - d = doc.get() - d_json = d.to_dict() - else: - return doc - - if d_json is None: - logger.warn(f"doc.to_dict() is NoneType | docid={docid} doc={doc}") - return - - # If any values in d_json is a list, add only the document id to the list for DocumentReference or DocumentSnapshot - for key, value in d_json.items(): - if isinstance(value, list): - #logger.debug(f"doc_to_json - key={key} value={value}") - for i, v in enumerate(value): - logger.debug(f"doc_to_json - i={i} v={v}") - if isinstance(v, firestore.DocumentReference): - #logger.debug(f"doc_to_json - v is DocumentReference") - value[i] = v.id - elif isinstance(v, firestore.DocumentSnapshot): - #logger.debug(f"doc_to_json - v is DocumentSnapshot") - value[i] = v.id - else: - #logger.debug(f"doc_to_json - v is not DocumentReference or DocumentSnapshot") - value[i] = v - d_json[key] = value - - - - d_json["id"] = docid - return d_json - - - - -# handle DocumentReference or DocumentSnapshot and recursefuly call doc_to_json -def doc_to_json_recursive(doc=None): - # Log - logger.debug(f"doc_to_json_recursive start doc={doc}") - - if not doc: - logger.debug("doc is NoneType") - return - - docid = "" - # Check if type is DocumentSnapshot - if isinstance(doc, DocumentSnapshot): - logger.debug("doc is DocumentSnapshot") - d_json = doc_to_json(docid=doc.id, doc=doc) - docid = doc.id - # Check if type is DocumentReference - elif isinstance(doc, DocumentReference): - logger.debug("doc is DocumentReference") - d = doc.get() - docid = d.id - d_json = doc_to_json(docid=doc.id, doc=d) - else: - logger.debug(f"Not DocumentSnapshot or DocumentReference, skipping - returning: {doc}") - return doc - - d_json["id"] = docid - return d_json - - -# Global variable to store singleton instance -_db_client = None - -def get_db(): - """ - Returns a singleton instance of the Firestore client. - This prevents creating too many connections. - """ - global _db_client - - if _db_client is None: - if safe_get_env_var("ENVIRONMENT") == "test": - from mockfirestore import MockFirestore - _db_client = MockFirestore() - else: - _db_client = firestore.client() - - return _db_client - - -def add_nonprofit_to_hackathon(json): - hackathonId = json["hackathonId"] - nonprofitId = json["nonprofitId"] - - logger.info(f"Add Nonprofit to Hackathon Start hackathonId={hackathonId} nonprofitId={nonprofitId}") - - db = get_db() - # Get the hackathon document - hackathon_doc = db.collection('hackathons').document(hackathonId) - # Get the nonprofit document - nonprofit_doc = db.collection('nonprofits').document(nonprofitId) - # Check if the hackathon document exists - hackathon_data = hackathon_doc.get() - if not hackathon_data.exists: - logger.warning(f"Add Nonprofit to Hackathon End (no results)") - return { - "message": "Hackathon not found" - } - # Check if the nonprofit document exists - nonprofit_data = nonprofit_doc.get() - if not nonprofit_data.exists: - logger.warning(f"Add Nonprofit to Hackathon End (no results)") - return { - "message": "Nonprofit not found" - } - # Get the hackathon document data - hackathon_dict = hackathon_data.to_dict() - # Add the nonprofit document reference to the hackathon document - if "nonprofits" not in hackathon_dict: - hackathon_dict["nonprofits"] = [] - # Check if the nonprofit is already in the hackathon document - if nonprofit_doc in hackathon_dict["nonprofits"]: - logger.warning(f"Add Nonprofit to Hackathon End (no results)") - return { - "message": "Nonprofit already in hackathon" - } - # Add the nonprofit document reference to the hackathon document - hackathon_dict["nonprofits"].append(nonprofit_doc) - # Update the hackathon document - hackathon_doc.set(hackathon_dict, merge=True) - - # Clear cache to ensure fresh data is served - clear_cache() - - return { - "message": "Nonprofit added to hackathon" - } - -def remove_nonprofit_from_hackathon(json): - hackathonId = json["hackathonId"] - nonprofitId = json["nonprofitId"] - - logger.info(f"Remove Nonprofit from Hackathon Start hackathonId={hackathonId} nonprofitId={nonprofitId}") - - db = get_db() - # Get the hackathon document - hackathon_doc = db.collection('hackathons').document(hackathonId) - # Get the nonprofit document - nonprofit_doc = db.collection('nonprofits').document(nonprofitId) - - # Check if the hackathon document exists - if not hackathon_doc: - logger.warning(f"Remove Nonprofit from Hackathon End (hackathon not found)") - return { - "message": "Hackathon not found" - } - - # Get the hackathon document data - hackathon_data = hackathon_doc.get().to_dict() - - # Check if nonprofits array exists - if "nonprofits" not in hackathon_data or not hackathon_data["nonprofits"]: - logger.warning(f"Remove Nonprofit from Hackathon End (no nonprofits in hackathon)") - return { - "message": "No nonprofits in hackathon" - } - - # Check if the nonprofit is in the hackathon document - nonprofit_found = False - updated_nonprofits = [] - - for np in hackathon_data["nonprofits"]: - if np.id != nonprofitId: - updated_nonprofits.append(np) - else: - nonprofit_found = True - - if not nonprofit_found: - logger.warning(f"Remove Nonprofit from Hackathon End (nonprofit not found in hackathon)") - return { - "message": "Nonprofit not found in hackathon" - } - - # Update the hackathon document with the filtered nonprofits list - hackathon_data["nonprofits"] = updated_nonprofits - hackathon_doc.set(hackathon_data, merge=True) - - # Clear cache to ensure fresh data is served - clear_cache() - - logger.info(f"Remove Nonprofit from Hackathon End (nonprofit removed)") - return { - "message": "Nonprofit removed from hackathon" - } - - -@cached(cache=TTLCache(maxsize=100, ttl=20)) -@limits(calls=2000, period=ONE_MINUTE) -def get_single_hackathon_id(id): - logger.debug(f"get_single_hackathon_id start id={id}") - db = get_db() - doc = db.collection('hackathons').document(id) - - if doc is None: - logger.warning("get_single_hackathon_id end (no results)") - return {} - else: - result = doc_to_json(docid=doc.id, doc=doc) - result["id"] = doc.id - - logger.info(f"get_single_hackathon_id end (with result):{result}") - return result - return {} - -@cached(cache=TTLCache(maxsize=100, ttl=10)) -@limits(calls=2000, period=ONE_MINUTE) -def get_volunteer_by_event(event_id, volunteer_type, admin=False): - logger.debug(f"get {volunteer_type} start event_id={event_id}") - - if event_id is None: - logger.warning(f"get {volunteer_type} end (no results)") - return [] - - results = get_volunteer_from_db_by_event(event_id, volunteer_type, admin=admin) - - if results is None: - logger.warning(f"get {volunteer_type} end (no results)") - return [] - else: - logger.debug(f"get {volunteer_type} end (with result):{results}") - return results - -@cached(cache=TTLCache(maxsize=100, ttl=5)) -def get_volunteer_checked_in_by_event(event_id, volunteer_type): - logger.debug(f"get {volunteer_type} start event_id={event_id}") - - if event_id is None: - logger.warning(f"get {volunteer_type} end (no results)") - return [] - - results = get_volunteer_checked_in_from_db_by_event(event_id, volunteer_type) - - if results is None: - logger.warning(f"get {volunteer_type} end (no results)") - return [] - else: - logger.debug(f"get {volunteer_type} end (with result):{results}") - return results - -@cached(cache=TTLCache(maxsize=100, ttl=600)) -@limits(calls=2000, period=ONE_MINUTE) -def get_single_hackathon_event(hackathon_id): - logger.debug(f"get_single_hackathon_event start hackathon_id={hackathon_id}") - result = get_hackathon_by_event_id(hackathon_id) - - if result is None: - logger.warning("get_single_hackathon_event end (no results)") - return {} - else: - if "nonprofits" in result and result["nonprofits"]: - result["nonprofits"] = [doc_to_json(doc=npo, docid=npo.id) for npo in result["nonprofits"]] - else: - result["nonprofits"] = [] - if "teams" in result and result["teams"]: - result["teams"] = [doc_to_json(doc=team, docid=team.id) for team in result["teams"]] - else: - result["teams"] = [] - - logger.info(f"get_single_hackathon_event end (with result):{result}") - return result - return {} - -# 12 hour cache for 100 objects LRU -@limits(calls=1000, period=ONE_MINUTE) -def get_single_npo(npo_id): - logger.debug(f"get_npo start npo_id={npo_id}") - db = get_db() - doc = db.collection('nonprofits').document(npo_id) - - if doc is None: - logger.warning("get_npo end (no results)") - return {} - else: - result = doc_to_json(docid=doc.id, doc=doc) - - logger.info(f"get_npo end (with result):{result}") - return { - "nonprofits": result - } - return {} - - -@cached(cache=TTLCache(maxsize=100, ttl=3600), key=lambda is_current_only: str(is_current_only)) -@limits(calls=200, period=ONE_MINUTE) -@log_execution_time -def get_hackathon_list(is_current_only=None): - """ - Retrieve a list of hackathons based on specified criteria. - - Args: - is_current_only: Filter type - 'current', 'previous', or None for all hackathons - - Returns: - Dictionary containing list of hackathons with document references resolved - """ - logger.debug(f"Hackathon List - Getting {is_current_only or 'all'} hackathons") - db = get_db() - - # Prepare query based on filter type - query = db.collection('hackathons') - today_str = datetime.now().strftime("%Y-%m-%d") - - if is_current_only == "current": - logger.debug(f"Querying current events (end_date >= {today_str})") - query = query.where(filter=firestore.FieldFilter("end_date", ">=", today_str)).order_by("end_date", direction=firestore.Query.ASCENDING) - - elif is_current_only == "previous": - # Look back 3 years for previous events - target_date = datetime.now() + timedelta(days=-3*365) - target_date_str = target_date.strftime("%Y-%m-%d") - logger.debug(f"Querying previous events ({target_date_str} <= end_date <= {today_str})") - query = query.where("end_date", ">=", target_date_str).where("end_date", "<=", today_str) - query = query.order_by("end_date", direction=firestore.Query.DESCENDING).limit(50) - - else: - query = query.order_by("start_date") - - # Execute query - try: - logger.debug(f"Executing query: {query}") - docs = query.stream() - results = _process_hackathon_docs(docs) - logger.debug(f"Retrieved {len(results)} hackathon results") - return {"hackathons": results} - except Exception as e: - logger.error(f"Error retrieving hackathons: {str(e)}") - return {"hackathons": [], "error": str(e)} - - -def _process_hackathon_docs(docs): - """ - Process hackathon documents and resolve references. - - This helper function processes document references and nested objects - more efficiently without excessive logging. - - Args: - docs: Firestore document stream - - Returns: - List of processed hackathon documents with references resolved - """ - if not docs: - return [] - - results = [] - for doc in docs: - try: - d = doc_to_json(doc.id, doc) - - # Process lists of references more efficiently - for key, value in d.items(): - if isinstance(value, list): - d[key] = [doc_to_json_recursive(item) for item in value] - elif isinstance(value, (DocumentReference, DocumentSnapshot)): - d[key] = doc_to_json_recursive(value) - - results.append(d) - except Exception as e: - logger.error(f"Error processing hackathon doc {doc.id}: {str(e)}") - # Continue processing other docs instead of failing completely - - return results - - -@limits(calls=2000, period=THIRTY_SECONDS) -def get_teams_list(id=None): - logger.debug(f"Teams List Start team_id={id}") - db = get_db() - if id is not None: - logger.debug(f"Teams List team_id={id} | Start") - # Get by id - doc = db.collection('teams').document(id).get() - if doc is None: - return {} - else: - #log - logger.info(f"Teams List team_id={id} | End (with result):{doc_to_json(docid=doc.id, doc=doc)}") - logger.debug(f"Teams List team_id={id} | End") - return doc_to_json(docid=doc.id, doc=doc) - else: - # Get all - logger.debug("Teams List | Start") - docs = db.collection('teams').stream() # steam() gets all records - if docs is None: - logger.debug("Teams List | End (no results)") - return {[]} - else: - results = [] - for doc in docs: - results.append(doc_to_json(docid=doc.id, doc=doc)) - - logger.debug(f"Found {len(results)} results {results}") - return { "teams": results } - -@limits(calls=2000, period=THIRTY_SECONDS) -@cached(cache=TTLCache(maxsize=100, ttl=600), key=lambda id: id) -@log_execution_time -def get_team(id): - if id is None: - logger.warning("get_team called with None id") - return {"team": {}} - - logger.debug(f"Fetching team with id={id}") - - db = get_db() - doc_ref = db.collection('teams').document(id) - - try: - doc = doc_ref.get() - if not doc.exists: - logger.info(f"Team with id={id} not found") - return {} - - team_data = doc_to_json(docid=doc.id, doc=doc) - logger.info(f"Successfully retrieved team with id={id}") - return { - "team" : team_data - } - - except Exception as e: - logger.error(f"Error retrieving team with id={id}: {str(e)}") - return {} - - finally: - logger.debug(f"get_team operation completed for id={id}") - - -def get_teams_by_event_id(event_id): - """Get teams for a specific hackathon event (for admin judging assignment)""" - logger.debug(f"Getting teams for event_id={event_id}") - db = get_db() - - try: - # Query teams by hackathon_event_id field - docs = db.collection('teams').where('hackathon_event_id', '==', event_id).stream() - - results = [] - for doc in docs: - team_data = doc_to_json(docid=doc.id, doc=doc) - - # Format team data for admin judging assignment interface - formatted_team = { - "id": team_data.get("id"), - "name": team_data.get("name", ""), - "members": team_data.get("members", []), - "problem_statement": { - "title": team_data.get("problem_statement", {}).get("title", ""), - "nonprofit": team_data.get("problem_statement", {}).get("nonprofit", "") - } - } - results.append(formatted_team) - - logger.debug(f"Found {len(results)} teams for event {event_id}") - return {"teams": results} - - except Exception as e: - logger.error(f"Error fetching teams for event {event_id}: {str(e)}") - return {"teams": [], "error": "Failed to fetch teams"} - - -def get_teams_batch(json): - # Handle json["team_ids"] will have a list of teamids - - if "team_ids" not in json: - logger.error("get_teams_batch called without team_ids in json") - # TODO: Return for a batch of team ids to speed up the frontend - return [] - team_ids = json["team_ids"] - logger.debug(f"get_teams_batch start team_ids={team_ids}") - db = get_db() - if not team_ids: - logger.warning("get_teams_batch end (no team_ids provided)") - return [] - # Get all teams by team_ids, using correct Firestore Python syntax and where FieldPath doesn't exist also make sure the id works for id fields - try: - docs = db.collection('teams').where( - '__name__', 'in', [db.collection('teams').document(team_id) for team_id in team_ids]).stream() - - results = [] - for doc in docs: - team_data = doc_to_json(docid=doc.id, doc=doc) - results.append(team_data) - - logger.debug(f"get_teams_batch end (with {len(results)} results)") - return results - except Exception as e: - logger.error(f"Error in get_teams_batch: {str(e)}") - # print stack trace - import traceback - traceback.print_exc() - return [] - - - -@limits(calls=40, period=ONE_MINUTE) -def get_npos_by_hackathon_id(id): - logger.debug(f"get_npos_by_hackathon_id start id={id}") - db = get_db() - doc = db.collection('hackathons').document(id) - - try: - doc_dict = doc.get().to_dict() - if doc_dict is None: - logger.warning("get_npos_by_hackathon_id end (no results)") - return { - "nonprofits": [] - } - - # Get all nonprofits from hackathon - npos = [] - if "nonprofits" in doc_dict and doc_dict["nonprofits"]: - npo_refs = doc_dict["nonprofits"] - logger.info(f"get_npos_by_hackathon_id found {len(npo_refs)} nonprofit references") - - # Get all nonprofits - for npo_ref in npo_refs: - try: - # Convert DocumentReference to dict - npo_doc = npo_ref.get() - if npo_doc.exists: - npo = doc_to_json(docid=npo_doc.id, doc=npo_doc) - npos.append(npo) - except Exception as e: - logger.error(f"Error processing nonprofit reference: {e}") - continue - - return { - "nonprofits": npos - } - except Exception as e: - logger.error(f"Error in get_npos_by_hackathon_id: {e}") - return { - "nonprofits": [] - } - - -@limits(calls=40, period=ONE_MINUTE) -def get_npo_by_hackathon_id(id): - logger.debug(f"get_npo_by_hackathon_id start id={id}") - db = get_db() - doc = db.collection('hackathons').document(id) - - if doc is None: - logger.warning("get_npo_by_hackathon_id end (no results)") - return {} - else: - result = doc_to_json(docid=doc.id, doc=doc) - - logger.info(f"get_npo_by_hackathon_id end (with result):{result}") - return result - return {} - - -@limits(calls=20, period=ONE_MINUTE) -def get_npo_list(word_length=30): - logger.debug("NPO List Start") - db = get_db() - # steam() gets all records - docs = db.collection('nonprofits').order_by( "rank" ).stream() - if docs is None: - return {[]} - else: - results = [] - for doc in docs: - logger.debug(f"Processing doc {doc.id} {doc}") - results.append(doc_to_json_recursive(doc=doc)) - - # log result - logger.debug(f"Found {len(results)} results {results}") - return { "nonprofits": results } - -def save_team(propel_user_id, json): - send_slack_audit(action="save_team", message="Saving", payload=json) - - email, user_id, last_login, profile_image, name, nickname = get_propel_user_details_by_id(propel_user_id) - slack_user_id = user_id - - # Extract the raw Slack user ID (handles both OAuth formats) - root_slack_user_id = extract_slack_user_id(slack_user_id) - user = get_user_doc_reference(root_slack_user_id) - - db = get_db() # this connects to our Firestore database - logger.debug("Team Save") - - logger.debug(json) - doc_id = uuid.uuid1().hex # Generate a new team id - - team_name = json["name"] - - - - slack_channel = json["slackChannel"] - - hackathon_event_id = json["eventId"] - problem_statement_id = json["problemStatementId"] if "problemStatementId" in json else None - nonprofit_id = json["nonprofitId"] if "nonprofitId" in json else None - - github_username = json["githubUsername"] - if validate_github_username(github_username) == False: - return { - "message": "Error: Invalid GitHub Username - don't give us your email, just your username without the @ symbol." - } - - - #TODO: This is a hack, but get the nonprofit if provided, then get the first problem statement - nonprofit = None - nonprofit_name = "" - if nonprofit_id is not None: - logger.info(f"Nonprofit ID provided {nonprofit_id}") - nonprofit = get_single_npo(nonprofit_id)["nonprofits"] - nonprofit_name = nonprofit["name"] - logger.info(f"Nonprofit {nonprofit}") - # See if the nonprofit has a least 1 problem statement - if "problem_statements" in nonprofit and len(nonprofit["problem_statements"]) > 0: - problem_statement_id = nonprofit["problem_statements"][0] - logger.info(f"Problem Statement ID {problem_statement_id}") - else: - return { - "message": "Error: Nonprofit does not have any problem statements" - } - - - problem_statement = None - if problem_statement_id is not None: - problem_statement = get_problem_statement_from_id_old(problem_statement_id) - logger.info(f"Problem Statement {problem_statement}") - - if nonprofit is None and problem_statement is None: - return "Error: Please provide either a Nonprofit or a Problem Statement" - - - team_slack_channel = slack_channel - raw_problem_statement_title = problem_statement.get().to_dict()["title"] - - # Remove all spaces from problem_statement_title - problem_statement_title = raw_problem_statement_title.replace(" ", "").replace("-", "") - logger.info(f"Problem Statement Title: {problem_statement_title}") - - nonprofit_title = nonprofit_name.replace(" ", "").replace("-", "") - # Truncate nonprofit name to first 20 chars to support github limits - nonprofit_title = nonprofit_title[:20] - logger.info(f"Nonprofit Title: {nonprofit_title}") - - repository_name = f"{team_name}-{nonprofit_title}-{problem_statement_title}" - logger.info(f"Repository Name: {repository_name}") - - # truncate repostory name to first 100 chars to support github limits - repository_name = repository_name[:100] - logger.info(f"Truncated Repository Name: {repository_name}") - - slack_name_of_creator = name - - nonprofit_url = f"https://ohack.dev/nonprofit/{nonprofit_id}" - project_url = f"https://ohack.dev/project/{problem_statement_id}" - # Create github repo - try: - logger.info(f"Creating github repo {repository_name} for {json}") - repo = create_github_repo(repository_name, hackathon_event_id, slack_name_of_creator, team_name, team_slack_channel, problem_statement_id, raw_problem_statement_title, github_username, nonprofit_name, nonprofit_id) - except ValueError as e: - return { - "message": f"Error: {e}" - } - logger.info(f"Created github repo {repo} for {json}") - - logger.info(f"Creating slack channel {slack_channel}") - create_slack_channel(slack_channel) - - logger.info(f"Inviting user {slack_user_id} to slack channel {slack_channel}") - invite_user_to_channel(slack_user_id, slack_channel) - - # Add all Slack admins too - slack_admins = ["UC31XTRT5", "UCQKX6LPR", "U035023T81Z", "UC31XTRT5", "UC2JW3T3K", "UPD90QV17", "U05PYC0LMHR"] - for admin in slack_admins: - logger.info(f"Inviting admin {admin} to slack channel {slack_channel}") - invite_user_to_channel(admin, slack_channel) - - # Send a slack message to the team channel - slack_message = f''' -:rocket: Team *{team_name}* is ready for launch! :tada: - -*Channel:* #{team_slack_channel} -*Nonprofit:* <{nonprofit_url}|{nonprofit_name}> -*Project:* <{project_url}|{raw_problem_statement_title}> -*Created by:* <@{root_slack_user_id}> (add your other team members here) - -:github_parrot: *GitHub Repository:* {repo['full_url']} -All code goes here! Remember, we're building for the public good (MIT license). - -:question: *Need help?* -Join <#C01E5CGDQ74> or <#C07KYG3CECX> for questions and updates. - -:clipboard: *Next Steps:* -1. Add team to GitHub repo: -2. Create DevPost project: -3. Submit to -4. Study your nonprofit slides and software requirements doc and chat with mentors -5. Code, collaborate, and create! -6. Share your progress on the socials: `#ohack2024` @opportunityhack -7. -8. Post-hack: Update LinkedIn with your amazing experience! -9. Update for a chance to win prizes! -10. Follow the schedule at - - -Let's make a difference! :muscle: :heart: -''' - send_slack(slack_message, slack_channel) - send_slack(slack_message, "log-team-creation") - - repo_name = repo["repo_name"] - full_github_repo_url = repo["full_url"] - - my_date = datetime.now() - collection = db.collection('teams') - insert_res = collection.document(doc_id).set({ - "team_number" : -1, - "users": [user], - "problem_statements": [problem_statement], - "name": team_name, - "slack_channel": slack_channel, - "created": my_date.isoformat(), - "active": "True", - "github_links": [ - { - "link": full_github_repo_url, - "name": repo_name - } - ] - }) - - logger.debug(f"Insert Result: {insert_res}") - - # Look up the new team object that was just created - new_team_doc = db.collection('teams').document(doc_id) - user_doc = user.get() - user_dict = user_doc.to_dict() - user_teams = user_dict["teams"] - user_teams.append(new_team_doc) - user.set({ - "teams": user_teams - }, merge=True) - - # Get the hackathon (event) - add the team to the event - hackathon_db_id = get_hackathon_by_event_id(hackathon_event_id)["id"] - event_collection = db.collection("hackathons").document(hackathon_db_id) - event_collection_dict = event_collection.get().to_dict() - - new_teams = [] - for t in event_collection_dict["teams"]: - new_teams.append(t) - new_teams.append(new_team_doc) - - event_collection.set({ - "teams" : new_teams - }, merge=True) - - # Clear the cache - logger.info(f"Clearing cache for event_id={hackathon_db_id} problem_statement_id={problem_statement_id} user_doc.id={user_doc.id} doc_id={doc_id}") - clear_cache() - - # get the team from get_teams_list - team = get_teams_list(doc_id) - - - return { - "message" : f"Saved Team and GitHub repo created. See your Slack channel --> #{slack_channel} for more details.", - "success" : True, - "team": team, - "user": { - "name" : user_dict["name"], - "profile_image": user_dict["profile_image"], - } - } - -def get_github_repos(event_id): - logger.info(f"Get Github Repos for event_id={event_id}") - # Get hackathon by event_id - hackathon = get_hackathon_by_event_id(event_id) - if hackathon is None: - logger.warning(f"Get Github Repos End (no results)") - return {} - else: - org_name = hackathon["github_org"] - return get_all_repos(org_name) - - -def join_team(propel_user_id, json): - logger.info(f"Join Team UserId: {propel_user_id} Json: {json}") - team_id = json["teamId"] - - db = get_db() - - # Get user ID once - slack_user = get_slack_user_from_propel_user_id(propel_user_id) - userid = get_user_from_slack_id(slack_user["sub"]).id - - # Reference the team document - team_ref = db.collection('teams').document(team_id) - user_ref = db.collection('users').document(userid) - - @firestore.transactional - def update_team_and_user(transaction): - # Read operations - team_doc = team_ref.get(transaction=transaction) - user_doc = user_ref.get(transaction=transaction) - - if not team_doc.exists: - raise ValueError("Team not found") - if not user_doc.exists: - raise ValueError("User not found") - - team_data = team_doc.to_dict() - user_data = user_doc.to_dict() - - team_users = team_data.get("users", []) - user_teams = user_data.get("teams", []) - - # Check if user is already in team - if user_ref in team_users: - logger.warning(f"User {userid} is already in team {team_id}") - return False, None - - # Prepare updates - new_team_users = list(set(team_users + [user_ref])) - new_user_teams = list(set(user_teams + [team_ref])) - - # Write operations - transaction.update(team_ref, {"users": new_team_users}) - transaction.update(user_ref, {"teams": new_user_teams}) - - logger.debug(f"User {userid} added to team {team_id}") - # Return slack_channel from within transaction to avoid race condition - return True, team_data.get("slack_channel") - - # Execute the transaction - try: - transaction = db.transaction() - success, team_slack_channel = update_team_and_user(transaction) - if success: - send_slack_audit(action="join_team", message="Added", payload=json) - message = "Joined Team" - # Add person to Slack channel using data from transaction - if team_slack_channel: - invite_user_to_channel(userid, team_slack_channel) - else: - message = "User was already in the team" - except Exception as e: - logger.error(f"Error in join_team: {str(e)}") - return Message(f"Error: {str(e)}") - - # Clear caches - clear_cache() - - logger.debug("Join Team End") - return Message(message) - - -def unjoin_team(propel_user_id, json): - logger.info(f"Unjoin for UserId: {propel_user_id} Json: {json}") - team_id = json["teamId"] - - db = get_db() - - # Get user ID once - slack_user = get_slack_user_from_propel_user_id(propel_user_id) - userid = get_user_from_slack_id(slack_user["sub"]).id - - # Reference the team document - team_ref = db.collection('teams').document(team_id) - user_ref = db.collection('users').document(userid) - - @firestore.transactional - def update_team_and_user(transaction): - # Read operations - team_doc = team_ref.get(transaction=transaction) - user_doc = user_ref.get(transaction=transaction) - - if not team_doc.exists: - raise ValueError("Team not found") - if not user_doc.exists: - raise ValueError("User not found") - - team_data = team_doc.to_dict() - user_data = user_doc.to_dict() - - user_list = team_data.get("users", []) - user_teams = user_data.get("teams", []) - - # Check if user is in team - if user_ref not in user_list: - logger.warning(f"User {userid} not found in team {team_id}") - return False - - # Prepare updates - new_user_list = [u for u in user_list if u.id != userid] - new_user_teams = [t for t in user_teams if t.id != team_id] - - # Write operations - transaction.update(team_ref, {"users": new_user_list}) - transaction.update(user_ref, {"teams": new_user_teams}) - - logger.debug(f"User {userid} removed from team {team_id}") - return True - - # Execute the transaction - try: - transaction = db.transaction() - success = update_team_and_user(transaction) - if success: - send_slack_audit(action="unjoin_team", message="Removed", payload=json) - message = "Removed from Team" - else: - message = "User was not in the team" - except Exception as e: - logger.error(f"Error in unjoin_team: {str(e)}") - return Message(f"Error: {str(e)}") - - # Clear caches - clear_cache() - - logger.debug("Unjoin Team End") - return Message(message) - -@limits(calls=100, period=ONE_MINUTE) -def update_npo_application( application_id, json, propel_id): - send_slack_audit(action="update_npo_application", message="Updating", payload=json) - db = get_db() - logger.info("NPO Application Update") - doc = db.collection('project_applications').document(application_id) - if doc: - doc_dict = doc.get().to_dict() - send_slack_audit(action="update_npo_application", - message="Updating", payload=doc_dict) - doc.update(json) - - # Clear cache for get_npo_applications - logger.info(f"Clearing cache for application_id={application_id}") - - clear_cache() - - return Message( - "Updated NPO Application" - ) - - -@limits(calls=100, period=ONE_MINUTE) -def get_npo_applications(): - logger.info("get_npo_applications Start") - db = get_db() - - # Use a transaction to ensure consistency - @firestore.transactional - def get_latest_docs(transaction): - docs = db.collection('project_applications').get(transaction=transaction) - return [doc_to_json(docid=doc.id, doc=doc) for doc in docs] - - - # Use a transaction to get the latest data - transaction = db.transaction() - results = get_latest_docs(transaction) - - if not results: - return {"applications": []} - - logger.info(results) - logger.info("get_npo_applications End") - - return {"applications": results} - - -@limits(calls=100, period=ONE_MINUTE) -def save_npo_application(json): - send_slack_audit(action="save_npo_application", message="Saving", payload=json) - db = get_db() # this connects to our Firestore database - logger.debug("NPO Application Save") - - # Check Google Captcha - token = json["token"] - recaptcha_response = requests.post( - f"https://www.google.com/recaptcha/api/siteverify?secret={google_recaptcha_key}&response={token}") - recaptcha_response_json = recaptcha_response.json() - logger.info(f"Recaptcha Response: {recaptcha_response_json}") - - if recaptcha_response_json["success"] == False: - return Message( - "Recaptcha failed" - ) - - - ''' - Save this data into the Firestore database in the project_applications collection - name: '', - email: '', - organization: '', - idea: '', - isNonProfit: false, - ''' - doc_id = uuid.uuid1().hex - - name = json["name"] - email = json["email"] - organization = json["organization"] - idea = json["idea"] - isNonProfit = json["isNonProfit"] - - collection = db.collection('project_applications') - - insert_res = collection.document(doc_id).set({ - "name": name, - "email": email, - "organization": organization, - "idea": idea, - "isNonProfit": isNonProfit, - "timestamp": datetime.now().isoformat() - }) - - logger.info(f"Insert Result: {insert_res}") - - logger.info(f"Sending welcome email to {name} {email}") - - send_nonprofit_welcome_email(organization, name, email) - - logger.info(f"Sending slack message to nonprofit-form-submissions") - - # Send a Slack message to nonprofit-form-submissions with all content - slack_message = f''' -:rocket: New NPO Application :rocket: -Name: `{name}` -Email: `{email}` -Organization: `{organization}` -Idea: `{idea}` -Is Nonprofit: `{isNonProfit}` -''' - send_slack(channel="nonprofit-form-submissions", message=slack_message, icon_emoji=":rocket:") - - logger.info(f"Sent slack message to nonprofit-form-submissions") - - return Message( - "Saved NPO Application" - ) - -@limits(calls=100, period=ONE_MINUTE) -def save_npo(json): - send_slack_audit(action="save_npo", message="Saving", payload=json) - db = get_db() # this connects to our Firestore database - logger.info("NPO Save - Starting") - - try: - # Input validation and sanitization - required_fields = ['name', 'description', 'website', 'slack_channel'] - for field in required_fields: - if field not in json or not json[field].strip(): - raise InvalidInputError(f"Missing or empty required field: {field}") - - name = json['name'].strip() - description = json['description'].strip() - website = json['website'].strip() - slack_channel = json['slack_channel'].strip() - contact_people = json.get('contact_people', []) - contact_email = json.get('contact_email', []) - problem_statements = json.get('problem_statements', []) - image = json.get('image', '').strip() - rank = int(json.get('rank', 0)) - - # Validate email addresses - contact_email = [email.strip() for email in contact_email if validate_email(email.strip())] - - # Validate URL - if not validate_url(website): - raise InvalidInputError("Invalid website URL") - - # Convert problem_statements from IDs to DocumentReferences - problem_statement_refs = [ - db.collection("problem_statements").document(ps) - for ps in problem_statements - if ps.strip() - ] - - # Prepare data for Firestore - npo_data = { - "name": name, - "description": description, - "website": website, - "slack_channel": slack_channel, - "contact_people": contact_people, - "contact_email": contact_email, - "problem_statements": problem_statement_refs, - "image": image, - "rank": rank, - "created_at": firestore.SERVER_TIMESTAMP, - "updated_at": firestore.SERVER_TIMESTAMP - } - - # Use a transaction to ensure data consistency - @firestore.transactional - def save_npo_transaction(transaction): - # Check if NPO with the same name already exists - existing_npo = db.collection('nonprofits').where("name", "==", name).limit(1).get() - if len(existing_npo) > 0: - raise InvalidInputError(f"Nonprofit with name '{name}' already exists") - - # Generate a new document ID - new_doc_ref = db.collection('nonprofits').document() - - # Set the data in the transaction - transaction.set(new_doc_ref, npo_data) - - return new_doc_ref - - # Execute the transaction - transaction = db.transaction() - new_npo_ref = save_npo_transaction(transaction) - - logger.info(f"NPO Save - Successfully saved nonprofit: {new_npo_ref.id}") - send_slack_audit(action="save_npo", message="Saved successfully", payload={"id": new_npo_ref.id}) - - # Clear cache - clear_cache() - - return Message(f"Saved NPO with ID: {new_npo_ref.id}") - - except InvalidInputError as e: - logger.error(f"NPO Save - Invalid input: {str(e)}") - return Message(f"Failed to save NPO: {str(e)}", status="error") - except Exception as e: - logger.exception("NPO Save - Unexpected error occurred") - return Message("An unexpected error occurred while saving the NPO", status="error") +from ratelimit import limits +import os -def clear_cache(): - doc_to_json.cache_clear() - get_single_hackathon_event.cache_clear() - get_single_hackathon_id.cache_clear() - get_hackathon_list.cache_clear() - +from db.db import fetch_user_by_user_id, get_db -@limits(calls=100, period=ONE_MINUTE) -def remove_npo(json): - logger.debug("Start NPO Delete") - doc_id = json["id"] - db = get_db() # this connects to our Firestore database - doc = db.collection('nonprofits').document(doc_id) - if doc: - send_slack_audit(action="remove_npo", message="Removing", payload=doc.get().to_dict()) - doc.delete() - # TODO: Add a way to track what has been deleted - # Either by calling Slack or by using another DB/updating the DB with a hidden=True flag, etc. +logger = get_logger("messages_service") - logger.debug("End NPO Delete") +ONE_MINUTE = 1*60 +def get_public_message(): + logger.debug("~ Public ~") return Message( - "Delete NPO" + "aaThis is a public message." ) - -@limits(calls=20, period=ONE_MINUTE) -def update_npo(json): - db = get_db() # this connects to our Firestore database - - logger.debug("NPO Edit") - send_slack_audit(action="update_npo", message="Updating", payload=json) - - doc_id = json["id"] - temp_problem_statements = json["problem_statements"] - - doc = db.collection('nonprofits').document(doc_id) - if doc: - doc_dict = doc.get().to_dict() - send_slack_audit(action="update_npo", message="Updating", payload=doc_dict) - - # Extract all fields from the json - name = json.get("name", None) - contact_email = json.get("contact_email", None) - contact_people = json.get("contact_people", None) - slack_channel = json.get("slack_channel", None) - website = json.get("website", None) - description = json.get("description", None) - image = json.get("image", None) - rank = json.get("rank", None) - - # Convert contact_email and contact_people to lists if they're not already - if isinstance(contact_email, str): - contact_email = [email.strip() for email in contact_email.split(',')] - if isinstance(contact_people, str): - contact_people = [person.strip() for person in contact_people.split(',')] - - # We need to convert this from just an ID to a full object - # Ref: https://stackoverflow.com/a/59394211 - problem_statements = [] - for ps in temp_problem_statements: - problem_statements.append(db.collection("problem_statements").document(ps)) - - update_data = { - "contact_email": contact_email, - "contact_people": contact_people, - "name": name, - "slack_channel": slack_channel, - "website": website, - "description": description, - "problem_statements": problem_statements, - "image": image, - "rank": rank - } - - # Remove any fields that are None to avoid overwriting with null values - update_data = {k: v for k, v in update_data.items() if v is not None} - logger.debug(f"Update data: {update_data}") - - doc.update(update_data) - - logger.debug("NPO Edit - Update successful") - send_slack_audit(action="update_npo", message="Update successful", payload=update_data) - - # Clear cache - clear_cache() - - return Message("Updated NPO") - else: - logger.error(f"NPO Edit - Document with id {doc_id} not found") - return Message("NPO not found", status="error") - -@limits(calls=100, period=ONE_MINUTE) -def single_add_volunteer(event_id, json, volunteer_type, propel_id): - db = get_db() - logger.info("Single Add Volunteer") - logger.info("JSON: " + str(json)) - send_slack_audit(action="single_add_volunteer", message="Adding", payload=json) - - # Since we know this user is an admin, prefix all vars with admin_ - admin_email, admin_user_id, admin_last_login, admin_profile_image, admin_name, admin_nickname = get_propel_user_details_by_id(propel_id) - - if volunteer_type not in ["mentor", "volunteer", "judge"]: - return Message ( - "Error: Must be volunteer, mentor, judge" - ) - - json["volunteer_type"] = volunteer_type - json["event_id"] = event_id - name = json["name"] - - - # Add created_by and created_timestamp - json["created_by"] = admin_name - json["created_timestamp"] = datetime.now().isoformat() - - fields_that_should_always_be_present = ["name", "timestamp"] - - logger.info(f"Checking to see if person {name} is already in DB for event: {event_id}") - # We don't want to add the same name for the same event_id, so check that first - doc = db.collection('volunteers').where("event_id", "==", event_id).where("name", "==", name).stream() - # If we don't have a duplicate, then return - if len(list(doc)) > 0: - logger.warning("Volunteer already exists") - return Message("Volunteer already exists") - - logger.info(f"Checking to see if the event_id '{event_id}' provided exists") - # Query for event_id column in hackathons to ensure it exists - doc = db.collection('hackathons').where("event_id", "==", event_id).stream() - # If we don't find the event, return - if len(list(doc)) == 0: - logger.warning("No hackathon found") - return Message("No Hackathon Found") - - logger.info(f"Looks good! Adding to volunteers collection JSON: {json}") - # Add the volunteer - doc = db.collection('volunteers').add(json) - - get_volunteer_by_event.cache_clear() +def get_protected_message(): + logger.debug("~ Protected ~") return Message( - "Added Hackathon Volunteer" + "This is a protected message." ) -@limits(calls=50, period=ONE_MINUTE) -def update_hackathon_volunteers(event_id, volunteer_type, json, propel_id): - db = get_db() - logger.info(f"update_hackathon_volunteers for event_id={event_id} propel_id={propel_id}") - logger.info("JSON: " + str(json)) - send_slack_audit(action="update_hackathon_volunteers", message="Updating", payload=json) - - if "id" not in json: - logger.error("Missing id field") - return Message("Missing id field") - - volunteer_id = json["id"] - - # Since we know this user is an admin, prefix all vars with admin_ - admin_email, admin_user_id, admin_last_login, admin_profile_image, admin_name, admin_nickname = get_propel_user_details_by_id(propel_id) - - # Query for event_id column - doc_ref = db.collection("volunteers").document(volunteer_id) - doc = doc_ref.get() - doc_dict = doc.to_dict() - doc_volunteer_type = doc_dict.get("volunteer_type", "participant").lower() - - # If we don't find the event, return - if doc_ref is None: - return Message("No volunteer for Hackathon Found") - - - # Update doc with timestamp and admin_name - json["updated_by"] = admin_name - json["updated_timestamp"] = datetime.now().isoformat() - - # Update the volunteer record with the new data - doc_ref.update(json) - - slack_user_id = doc.get('slack_user_id') - - hackathon_welcome_message = f"🎉 Welcome <@{slack_user_id}> [{doc_volunteer_type}]." - - # Base welcome direct message - base_message = f"🎉 Welcome to Opportunity Hack {event_id}! You're checked in as a {doc_volunteer_type}.\n\n" - - # Role-specific guidance - if doc_volunteer_type == 'mentor': - role_guidance = """🧠 As a Mentor: -• Help teams with technical challenges and project direction -• Share your expertise either by staying with a specific team, looking at GitHub to find a team that matches your skills, or asking for who might need a mentor in #ask-a-mentor -• Guide teams through problem-solving without doing the work for them -• Connect with teams in their Slack channels or in-person""" - - elif doc_volunteer_type == 'judge': - role_guidance = """⚖️ As a Judge: -• Review team presentations and evaluate projects -• Focus on impact, technical implementation, and feasibility -• Provide constructive feedback during judging sessions -• Join the judges' briefing session for scoring criteria""" - - elif doc_volunteer_type == 'volunteer': - role_guidance = """🙋 As a Volunteer: -• Help with event logistics and participant support -• Assist with check-in, meals, and general questions -• Support the organizing team throughout the event -• Be a friendly face for participants who need help""" - - else: # hacker/participant - role_guidance = """💻 As a Hacker: -• Form or join a team to work on nonprofit challenges -• Collaborate with your team to build meaningful tech solutions -• Attend mentor sessions and utilize available resources -• Prepare for final presentations and judging""" - - slack_message_content = f"""{base_message}{role_guidance} - -📅 Important Links: -• Full schedule: https://www.ohack.dev/hack/{event_id}#countdown -• Slack channels: Watch #general for updates -• Need help? Ask in #help or find an organizer - -🚀 Ready to code for good? Let's build technology that makes a real difference for nonprofits and people in the world! -""" - - # If the incoming json has a value checkedIn that is true, and the doc from the db either doesn't have this field or it's false, we should send a Slack message to welcome this person to the hackathon giving them details about the event and their role. - # This should both be a DM to the person using their Slack ID and a message in the #hackathon-welcome channel. - if json.get("checkedIn") is True and "checkedIn" not in doc_dict.keys() and doc_dict.get("checkedIn") != True: - logger.info(f"Volunteer {volunteer_id} just checked in, sending welcome message to {slack_user_id}") - invite_user_to_channel(slack_user_id, "hackathon-welcome") - - logger.info(f"Sending Slack message to volunteer {volunteer_id} in channel #{slack_user_id} and #hackathon-welcome") - async_send_slack( - channel="#hackathon-welcome", - message=hackathon_welcome_message - ) - - logger.info(f"Sending Slack DM to volunteer {volunteer_id} in channel #{slack_user_id}") - async_send_slack( - channel=slack_user_id, - message=slack_message_content - ) - else: - logger.info(f"Volunteer {volunteer_id} checked in again, no welcome message sent.") - - # Clear cache for get_volunteer_by_event - get_volunteer_by_event.cache_clear() +def get_admin_message(): + logger.debug("~ Admin ~") return Message( - "Updated Hackathon Volunteers" + "This is an admin message." ) -def send_hackathon_request_email(contact_name, contact_email, request_id): - """ - Send a specialized confirmation email to someone who has submitted a hackathon request. - - Args: - contact_name: Name of the requestor - contact_email: Email address of the requestor - request_id: The unique ID of the hackathon request for edit link - - Returns: - True if email was sent successfully, False otherwise - """ - - # Rotate between images for better engagement - images = [ - "https://cdn.ohack.dev/ohack.dev/2023_hackathon_1.webp", - "https://cdn.ohack.dev/ohack.dev/2023_hackathon_2.webp", - "https://cdn.ohack.dev/ohack.dev/2023_hackathon_3.webp" - ] - chosen_image = random.choice(images) - image_number = images.index(chosen_image) + 1 - image_utm_content = f"hackathon_request_image_{image_number}" - - # Build the edit link for the hackathon request - base_url = os.getenv("FRONTEND_URL", "https://www.ohack.dev") - edit_link = f"{base_url}/hack/request/{request_id}" - - html_content = f""" - - - - - - Thank You for Your Hackathon Request - - - Opportunity Hack Event - -

Thank You for Your Hackathon Request!

- -

Dear {contact_name},

- -

We're thrilled you're interested in hosting an Opportunity Hack event! Your request has been received and our team is reviewing it now.

- -
-

Next Steps:

-
    -
  1. A member of our team will reach out within 3-5 business days
  2. -
  3. We'll schedule a call to discuss your goals and requirements
  4. -
  5. Together, we'll create a customized hackathon plan for your community
  6. -
-
- -

Need to make changes to your request?
- You can edit your request here at any time.

- -

Why Host an Opportunity Hack?

-
    -
  • Connect local nonprofits with skilled tech volunteers
  • -
  • Build lasting technology solutions for social good
  • -
  • Create meaningful community engagement opportunities
  • -
  • Develop technical skills while making a difference
  • -
- -

Have questions in the meantime? Feel free to reply to this email or reach out through our Slack community.

- -

Together, we can create positive change through technology!

- -

Warm regards,
The Opportunity Hack Team

- - - - - - """ - - # If name is none, or an empty string, or an unassigned string, or a unprintable character like a space string set it to "Event Organizer" - if contact_name is None or contact_name == "" or contact_name == "Unassigned" or contact_name.isspace(): - contact_name = "Event Organizer" - - params = { - "from": "Opportunity Hack ", - "to": f"{contact_name} <{contact_email}>", - "cc": "questions@ohack.org", - "reply_to": "questions@ohack.org", - "subject": "Your Opportunity Hack Event Request - Next Steps", - "html": html_content, - } - - try: - email = resend.Emails.SendParams(params) - resend.Emails.send(email) - logger.info(f"Sent hackathon request confirmation email to {contact_email}") - return True - except Exception as e: - logger.error(f"Error sending hackathon request email via Resend: {str(e)}") - return False - -def create_hackathon(json): - db = get_db() # this connects to our Firestore database - logger.debug("Hackathon Create") - send_slack_audit(action="create_hackathon", message="Creating", payload=json) - - # Save payload for potential hackathon in the database under the hackathon_requests collection - doc_id = uuid.uuid1().hex - collection = db.collection('hackathon_requests') - json["created"] = datetime.now().isoformat() - json["status"] = "pending" - insert_res = collection.document(doc_id).set(json) - json["id"] = doc_id - - if "contactEmail" in json and "contactName" in json: - # Send the specialized hackathon request email instead of the general welcome email - send_hackathon_request_email(json["contactName"], json["contactEmail"], doc_id) - - send_slack( - message=":rocket: New Hackathon Request :rocket: with json: " + str(json), channel="log-hackathon-requests", icon_emoji=":rocket:") - logger.debug(f"Insert Result: {insert_res}") - - return { - "message": "Hackathon Request Created", - "success": True, - "id": doc_id - } - -def get_hackathon_request_by_id(doc_id): - db = get_db() # this connects to our Firestore database - logger.debug("Hackathon Request Get") - doc = db.collection('hackathon_requests').document(doc_id) - if doc: - doc_dict = doc.get().to_dict() - send_slack_audit(action="get_hackathon_request_by_id", message="Getting", payload=doc_dict) - return doc_dict - else: - return None - - -def update_hackathon_request(doc_id, json): - db = get_db() # this connects to our Firestore database - logger.debug("Hackathon Request Update") - doc = db.collection('hackathon_requests').document(doc_id) - if doc: - doc_dict = doc.get().to_dict() - send_slack_audit(action="update_hackathon_request", message="Updating", payload=doc_dict) - # Send email for the update too - send_hackathon_request_email(json["contactName"], json["contactEmail"], doc_id) - # Update the date - doc_dict["updated"] = datetime.now().isoformat() - - doc.update(json) - return doc_dict - else: - return None - - - -@limits(calls=50, period=ONE_MINUTE) -def save_hackathon(json_data, propel_id): - db = get_db() - logger.info("Hackathon Save/Update initiated") - logger.debug(json_data) - send_slack_audit(action="save_hackathon", message="Saving/Updating", payload=json_data) +from common.utils.firestore_helpers import ( + hash_key, + log_execution_time, + doc_to_json, + doc_to_json_recursive, + clear_all_caches as _clear_all_caches, +) - try: - # Validate input data - validate_hackathon_data(json_data) - - # Check if this is an update or a new hackathon - doc_id = json_data.get("id") or uuid.uuid1().hex - is_update = "id" in json_data - - # Prepare data for Firestore - hackathon_data = { - "title": json_data["title"], - "description": json_data["description"], - "location": json_data["location"], - "start_date": json_data["start_date"], - "end_date": json_data["end_date"], - "type": json_data["type"], - "image_url": json_data["image_url"], - "event_id": json_data["event_id"], - "links": json_data.get("links", []), - "countdowns": json_data.get("countdowns", []), - "constraints": json_data.get("constraints", { - "max_people_per_team": 5, - "max_teams_per_problem": 10, - "min_people_per_team": 2, - }), - "donation_current": json_data.get("donation_current", { - "food": "0", - "prize": "0", - "swag": "0", - "thank_you": "", - }), - "donation_goals": json_data.get("donation_goals", { - "food": "0", - "prize": "0", - "swag": "0", - }), - "timezone": json_data.get("timezone", "America/Phoenix"), - "last_updated": firestore.SERVER_TIMESTAMP, - "last_updated_by": propel_id, - } - - # Handle nonprofits and teams - if "nonprofits" in json_data: - 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"]] - - # Use a transaction for atomic updates - @firestore.transactional - def update_hackathon(transaction): - hackathon_ref = db.collection('hackathons').document(doc_id) - if is_update: - # For updates, we need to merge with existing data - transaction.set(hackathon_ref, hackathon_data, merge=True) - else: - # For new hackathons, we can just set the data - hackathon_data["created_at"] = firestore.SERVER_TIMESTAMP - hackathon_data["created_by"] = propel_id - transaction.set(hackathon_ref, hackathon_data) - - # Run the transaction - transaction = db.transaction() - update_hackathon(transaction) - - # Clear all hackathon-related caches - clear_cache() - logger.info(f"Hackathon {'updated' if is_update else 'created'} successfully. ID: {doc_id}") - return Message( - "Saved Hackathon" - ) - - return { - "message": f"Hackathon {'updated' if is_update else 'saved'} successfully", - "id": doc_id - } - except ValueError as ve: - logger.error(f"Validation error: {str(ve)}") - return {"error": str(ve)}, 400 - except Exception as e: - logger.error(f"Error saving/updating hackathon: {str(e)}") - return {"error": "An unexpected error occurred"}, 500 - - +def clear_cache(): + from services.hackathons_service import clear_cache as _hackathon_clear_cache + _hackathon_clear_cache() # Ref: https://stackoverflow.com/questions/59138326/how-to-set-google-firebase-credentials-not-with-json-file-but-with-python-dict # Instead of giving the code a json file, we use environment variables so we don't have to source control a secrets file @@ -1750,410 +74,6 @@ def update_hackathon(transaction): if not firebase_admin._apps: firebase_admin.initialize_app(credential=cred) -def save_news(json): - # Take in Slack message and summarize it using GPT-3.5 - # Make sure these fields exist title, description, links (optional), slack_ts, slack_permalink, slack_channel - check_fields = ["title", "description", "slack_ts", "slack_permalink", "slack_channel"] - for field in check_fields: - if field not in json: - logger.error(f"Missing field {field} in {json}") - return Message("Missing field") - - cdn_dir = "ohack.dev/news" - news_image = generate_and_save_image_to_cdn(cdn_dir,json["title"]) - json["image"] = f"{CDN_SERVER}/{cdn_dir}/{news_image}" - json["last_updated"] = datetime.now().isoformat() - upsert_news(json) - - logger.info("Updated news successfully") - - get_news.cache_clear() - logger.info("Cleared cache for get_news") - - return Message("Saved News") - -def save_praise(json): - # Make sure these fields exist praise_receiver, praise_channel, praise_message - check_fields = ["praise_receiver", "praise_channel", "praise_message"] - for field in check_fields: - if field not in json: - logger.error(f"Missing field {field} in {json}") - return Message("Missing field") - - logger.debug(f"Detected required fields, attempting to save praise") - json["timestamp"] = datetime.now(pytz.utc).astimezone().isoformat() - - # Add ohack.dev user IDs for both sender and receiver - try: - # Get ohack.dev user ID for praise receiver - receiver_user = get_user_by_user_id(json["praise_receiver"]) - if receiver_user and "id" in receiver_user: - json["praise_receiver_ohack_id"] = receiver_user["id"] - logger.debug(f"Added praise_receiver_ohack_id: {receiver_user['id']}") - else: - logger.warning(f"Could not find ohack.dev user for praise_receiver: {json['praise_receiver']}") - json["praise_receiver_ohack_id"] = None - - # Get ohack.dev user ID for praise sender - sender_user = get_user_by_user_id(json["praise_sender"]) - if sender_user and "id" in sender_user: - json["praise_sender_ohack_id"] = sender_user["id"] - logger.debug(f"Added praise_sender_ohack_id: {sender_user['id']}") - else: - logger.warning(f"Could not find ohack.dev user for praise_sender: {json['praise_sender']}") - json["praise_sender_ohack_id"] = None - - except Exception as e: - logger.error(f"Error getting ohack.dev user IDs: {str(e)}") - json["praise_receiver_ohack_id"] = None - json["praise_sender_ohack_id"] = None - - logger.info(f"Attempting to save the praise with the json object {json}") - upsert_praise(json) - - logger.info("Updated praise successfully") - - get_praises_about_user.cache_clear() - logger.info("Cleared cache for get_praises_by_user_id") - - get_all_praises.cache_clear() - logger.info("Cleared cache for get_all_praises") - - return Message("Saved praise") - - -@cached(cache=TTLCache(maxsize=100, ttl=600)) -def get_all_praises(): - # Get the praises about user with user_id - results = get_recent_praises() - - # Get unique list of praise_sender and praise_receiver - slack_ids = set() - for r in results: - slack_ids.add(r["praise_receiver"]) - slack_ids.add(r["praise_sender"]) - - logger.info(f"SlackIDS: {slack_ids}") - slack_user_info = get_user_info(slack_ids) - logger.info(f"Slack User Info; {slack_user_info}") - - for r in results: - r['praise_receiver_details'] = slack_user_info[r['praise_receiver']] - r['praise_sender_details'] = slack_user_info[r['praise_sender']] - - logger.info(f"Here are the 20 most recently written praises: {results}") - return Message(results) - -@cached(cache=TTLCache(maxsize=100, ttl=600)) -def get_praises_about_user(user_id): - - # Get the praises about user with user_id - results = get_praises_by_user_id(user_id) - - slack_ids = set() - for r in results: - slack_ids.add(r["praise_receiver"]) - slack_ids.add(r["praise_sender"]) - logger.info(f"Slack IDs: {slack_ids}") - slack_user_info = get_user_info(slack_ids) - logger.info(f"Slack User Info: {slack_user_info}") - for r in results: - r['praise_receiver_details'] = slack_user_info[r['praise_receiver']] - r['praise_sender_details'] = slack_user_info[r['praise_sender']] - - logger.info(f"Here are all praises related to {user_id}: {results}") - return Message(results) - -# -------------------- Praises methods end here --------------------------- # - -async def save_lead(json): - token = json["token"] - - # If any field is missing, return False - if "name" not in json or "email" not in json: - # Log which fields are missing - logger.error(f"Missing field name or email {json}") - return False - - # If name or email length is not long enough, return False - if len(json["name"]) < 2 or len(json["email"]) < 3: - # Log - logger.error(f"Name or email too short name:{json['name']} email:{json['email']}") - return False - - recaptcha_response = requests.post( - f"https://www.google.com/recaptcha/api/siteverify?secret={google_recaptcha_key}&response={token}") - recaptcha_response_json = recaptcha_response.json() - logger.info(f"Recaptcha Response: {recaptcha_response_json}") - - if recaptcha_response_json["success"] == False: - return False - else: - logger.info("Recaptcha Success, saving...") - # Save lead to Firestore - db = get_db() - collection = db.collection('leads') - # Remove token from json - del json["token"] - - # Add timestamp - json["timestamp"] = datetime.now().isoformat() - insert_res = collection.add(json) - # Log name and email as success - logger.info(f"Lead saved for {json}") - - # Sent slack message to #ohack-dev-leads - slack_message = f"New lead! Name:`{json['name']}` Email:`{json['email']}`" - send_slack(slack_message, "ohack-dev-leads") - - success_send_email = send_welcome_email( json["name"], json["email"] ) - if success_send_email: - logger.info(f"Sent welcome email to {json['email']}") - # Update db to add when email was sent - collection.document(insert_res[1].id).update({ - "welcome_email_sent": datetime.now().isoformat() - }) - return True - -# Create an event loop and run the save_lead function asynchronously -@limits(calls=30, period=ONE_MINUTE) -async def save_lead_async(json): - await save_lead(json) - -def add_utm(url, source="email", medium="welcome", campaign="newsletter_signup", content=None): - utm_string = f"utm_source={source}&utm_medium={medium}&utm_campaign={campaign}" - if content: - utm_string += f"&utm_content={content}" - return f"{url}?{utm_string}" - -# This was only needed to send the first wave of emails for leads, no longer needed -def send_welcome_emails(): - logger.info("Sending welcome emails") - # Get all leads where welcome_email_sent is None - db = get_db() - - # Get all leads from the DB - query = db.collection('leads').stream() - # Go through each lead and remove the ones that have already been sent - leads = [] - for lead in query: - lead_dict = lead.to_dict() - if "welcome_email_sent" not in lead_dict and "email" in lead_dict and lead_dict["email"] is not None and lead_dict["email"] != "": - leads.append(lead) - - - send_email = False # Change to True to send emails - - # Don't send to duplicate emails - case insensitive - emails = set() - - for lead in leads: - lead_dict = lead.to_dict() - email = lead_dict["email"].lower() - if email in emails: - logger.info(f"Skipping duplicate email {email}") - continue - - logger.info(f"Sending welcome email to '{lead_dict['name']}' {email} for {lead.id}") - - if send_email: - success_send_email = send_welcome_email(lead_dict["name"], email) - if success_send_email: - logger.info(f"Sent welcome email to {email}") - # Update db to add when email was sent - lead.reference.update({ - "welcome_email_sent": datetime.now().isoformat() - }) - emails.add(email) - -def send_nonprofit_welcome_email(organization_name, contact_name, email): - resend.api_key = os.getenv("RESEND_WELCOME_EMAIL_KEY") - - subject = "Welcome to Opportunity Hack: Tech Solutions for Your Nonprofit!" - - images = [ - "https://cdn.ohack.dev/ohack.dev/2023_hackathon_1.webp", - "https://cdn.ohack.dev/ohack.dev/2023_hackathon_2.webp", - "https://cdn.ohack.dev/ohack.dev/2023_hackathon_3.webp" - ] - chosen_image = random.choice(images) - image_number = images.index(chosen_image) + 1 - image_utm_content = f"nonprofit_header_image_{image_number}" - - ''' - TODO: Add these pages and then move these down into the email we send - -
  • Access Your Nonprofit Dashboard - Track your project's progress
  • -
  • Nonprofit Resources - Helpful guides for working with tech teams
  • - -
  • Submit or Update Your Project
  • -
  • Tips for Communicating with Volunteers
  • - ''' - - html_content = f""" - - - - - - Welcome to Opportunity Hack - - - Opportunity Hack Event - -

    Welcome {organization_name} to Opportunity Hack!

    - -

    Dear {contact_name},

    - -

    We're excited to welcome {organization_name} to the Opportunity Hack community! We're here to connect your nonprofit with skilled tech volunteers to bring your ideas to life.

    - -

    What's Next?

    - - -

    Important Links:

    - - -

    Questions or need assistance? Reach out on our Slack channel or email us at support@ohack.org.

    - -

    We're excited to work with you to create tech solutions that amplify your impact!

    - -

    Best regards,
    The Opportunity Hack Team

    - - - - - - """ - - # If organization_name is none, or an empty string, or an unassigned string, or a unprintable character like a space string set it to "Nonprofit Partner" - if organization_name is None or organization_name == "" or organization_name == "Unassigned" or organization_name.isspace(): - organization_name = "Nonprofit Partner" - - # If contact_name is none, or an empty string, or an unassigned string, or a unprintable character like a space string set it to "Nonprofit Friend" - if contact_name is None or contact_name == "" or contact_name == "Unassigned" or contact_name.isspace(): - contact_name = "Nonprofit Friend" - - params = { - "from": "Opportunity Hack ", - "to": f"{contact_name} <{email}>", - "cc": "questions@ohack.org", - "reply_to": "questions@ohack.org", - "subject": subject, - "html": html_content, - } - logger.info(f"Sending nonprofit application email to {email}") - - email = resend.Emails.SendParams(params) - resend.Emails.send(email) - - logger.info(f"Sent nonprofit application email to {email}") - return True - -def send_welcome_email(name, email): - resend.api_key = os.getenv("RESEND_WELCOME_EMAIL_KEY") - - subject = "Welcome to Opportunity Hack: Code for Good!" - - # Rotate between images - images = [ - "https://cdn.ohack.dev/ohack.dev/2023_hackathon_1.webp", - "https://cdn.ohack.dev/ohack.dev/2023_hackathon_2.webp", - "https://cdn.ohack.dev/ohack.dev/2023_hackathon_3.webp" - ] - chosen_image = random.choice(images) - image_number = images.index(chosen_image) + 1 - image_utm_content = f"header_image_{image_number}" - - - html_content = f""" - - - - - - Welcome to Opportunity Hack - - - Opportunity Hack Event - -

    Hey {name}!! Welcome to Opportunity Hack!

    - -

    We're thrilled you've joined our community of tech volunteers making a difference!

    - -

    At Opportunity Hack, we believe in harnessing the power of code for social good. Our mission is simple: connect skilled volunteers like you with nonprofits that need tech solutions.

    - -

    Ready to dive in?

    - - -

    Got questions? Reach out on our Slack channel.

    - -

    Together, we can code for change!

    - -

    The Opportunity Hack Team

    - - - - - - """ - - - # If name is none, or an empty string, or an unassigned string, or a unprintable character like a space string set it to "OHack Friend" - if name is None or name == "" or name == "Unassigned" or name.isspace(): - name = "OHack Friend" - - - params = { - "from": "Opportunity Hack ", - "to": f"{name} <{email}>", - "cc": "questions@ohack.org", - "reply_to": "questions@ohack.org", - "subject": subject, - "html": html_content, - } - - email = resend.Emails.SendParams(params) - resend.Emails.send(email) - debug(logger, "Processing email", email=email) - return True - - -@cached(cache=TTLCache(maxsize=100, ttl=32600), key=lambda news_limit, news_id: f"{news_limit}-{news_id}") -def get_news(news_limit=3, news_id=None): - logger.debug("Get News") - db = get_db() # this connects to our Firestore database - if news_id is not None: - logger.info(f"Getting single news item for news_id={news_id}") - collection = db.collection('news') - doc = collection.document(news_id).get() - if doc is None: - return Message({}) - else: - return Message(doc.to_dict()) - else: - collection = db.collection('news') - docs = collection.order_by("slack_ts", direction=firestore.Query.DESCENDING).limit(news_limit).stream() - results = [] - for doc in docs: - doc_json = doc.to_dict() - doc_json["id"] = doc.id - results.append(doc_json) - logger.debug(f"Get News Result: {results}") - return Message(results) - # --------------------------- Problem Statement functions to be deleted ----------------- # @limits(calls=100, period=ONE_MINUTE) def save_helping_status_old(propel_user_id, json): @@ -2617,188 +537,6 @@ def get_user_by_id_old(id): return {} -@limits(calls=50, period=ONE_MINUTE) -def save_feedback(propel_user_id, json): - db = get_db() - logger.info("Saving Feedback") - send_slack_audit(action="save_feedback", message="Saving", payload=json) - - slack_user = get_slack_user_from_propel_user_id(propel_user_id) - user_db_id = get_user_from_slack_id(slack_user["sub"]).id - feedback_giver_id = slack_user["sub"] - - doc_id = uuid.uuid1().hex - feedback_receiver_id = json.get("feedback_receiver_id") - relationship = json.get("relationship") - duration = json.get("duration") - confidence_level = json.get("confidence_level") - is_anonymous = json.get("is_anonymous", False) - feedback_data = json.get("feedback", {}) - - collection = db.collection('feedback') - - insert_res = collection.document(doc_id).set({ - "feedback_giver_slack_id": feedback_giver_id, - "feedback_giver_id": user_db_id, - "feedback_receiver_id": feedback_receiver_id, - "relationship": relationship, - "duration": duration, - "confidence_level": confidence_level, - "is_anonymous": is_anonymous, - "feedback": feedback_data, - "timestamp": datetime.now().isoformat() - }) - - logger.info(f"Insert Result: {insert_res}") - - - notify_feedback_receiver(feedback_receiver_id) - - # Clear cache - get_user_feedback.cache_clear() - - return Message("Feedback saved successfully") - -def notify_feedback_receiver(feedback_receiver_id): - db = get_db() - user_doc = db.collection('users').document(feedback_receiver_id).get() - if user_doc.exists: - user_data = user_doc.to_dict() - user_email = user_data.get('email_address', '') - slack_user_id = user_data.get('user_id', '').split('-')[-1] # Extract Slack user ID - logger.info(f"User with ID {feedback_receiver_id} found") - logger.info(f"Sending notification to user {slack_user_id}") - - message = ( - f"Hello <@{slack_user_id}>! You've received new feedback. " - "Visit https://www.ohack.dev/myfeedback to view it." - ) - - # Send Slack message - send_slack(message=message, channel=slack_user_id) - logger.info(f"Notification sent to user {slack_user_id}") - - # Also send an email notification if user_email is available - if user_email: - subject = "New Feedback Received" - # Think like a senior UX person and re-use the email template from the welcome email and send using resend - html_content = f""" - - - - - - New Feedback Received - - -

    New Feedback Received

    -

    Hello,

    -

    You have received new feedback. Please visit My Feedback to view it.

    -

    Thank you for being a part of the Opportunity Hack community!

    -

    Best regards,
    The Opportunity Hack Team

    - - - """ - params = { - "from": "Opportunity Hack ", - "to": f"{user_data.get('name', 'User')} <{user_email}>", - "bcc": "greg@ohack.org", - "subject": subject, - "html": html_content, - } - email = resend.Emails.SendParams(params) - resend.Emails.send(email) - logger.info(f"Email notification sent to {user_email}") - else: - logger.warning(f"User with ID {feedback_receiver_id} not found") - -@cached(cache=TTLCache(maxsize=100, ttl=600)) -@limits(calls=100, period=ONE_MINUTE) -def get_user_feedback(propel_user_id): - logger.info(f"Getting feedback for propel_user_id: {propel_user_id}") - db = get_db() - - slack_user = get_slack_user_from_propel_user_id(propel_user_id) - db_user_id = get_user_from_slack_id(slack_user["sub"]).id - - feedback_docs = db.collection('feedback').where("feedback_receiver_id", "==", db_user_id).order_by("timestamp", direction=firestore.Query.DESCENDING).stream() - - feedback_list = [] - for doc in feedback_docs: - feedback = doc.to_dict() - if feedback.get("is_anonymous", False): - if "feedback_giver_id" in feedback: - feedback.pop("feedback_giver_id") - if "feedback_giver_slack_id" in feedback: - feedback.pop("feedback_giver_slack_id") - feedback_list.append(feedback) - - return {"feedback": feedback_list} - - -def save_giveaway(propel_user_id, json): - db = get_db() - logger.info("Submitting Giveaway") - send_slack_audit(action="submit_giveaway", message="Submitting", payload=json) - - slack_user = get_slack_user_from_propel_user_id(propel_user_id) - user_db_id = get_user_from_slack_id(slack_user["sub"]).id - giveaway_id = json.get("giveaway_id") - giveaway_data = json.get("giveaway_data", {}) - entries = json.get("entries", 0) - - collection = db.collection('giveaways') - - doc_id = uuid.uuid1().hex - insert_res = collection.document(doc_id).set({ - "user_id": user_db_id, - "giveaway_id": giveaway_id, - "entries": entries, - "giveaway_data": giveaway_data, - "timestamp": datetime.now().isoformat() - }) - - logger.info(f"Insert Result: {insert_res}") - - return Message("Giveaway submitted successfully") - -def get_user_giveaway(propel_user_id): - logger.info(f"Getting giveaway for propel_user_id: {propel_user_id}") - db = get_db() - - slack_user = get_slack_user_from_propel_user_id(propel_user_id) - db_user_id = get_user_from_slack_id(slack_user["sub"]).id - - giveaway_docs = db.collection('giveaways').where("user_id", "==", db_user_id).stream() - - giveaway_list = [] - for doc in giveaway_docs: - giveaway = doc.to_dict() - giveaway_list.append(giveaway) - - return { "giveaways" : giveaway_list} - -def get_all_giveaways(): - logger.info("Getting all giveaways") - db = get_db() - # Order by timestamp in descending order - docs = db.collection('giveaways').order_by("timestamp", direction=firestore.Query.DESCENDING).stream() - - # Get the most recent giveaway for each user - giveaways = {} - for doc in docs: - giveaway = doc.to_dict() - user_id = giveaway["user_id"] - if user_id not in giveaways: - user = get_user_by_id_old(user_id) - giveaway["user"] = user - giveaways[user_id] = giveaway - - - - return { "giveaways" : list(giveaways.values()) } - - def upload_image_to_cdn(request): """ Upload an image to CDN. Accepts binary data, base64, or standard image formats. @@ -3019,106 +757,3 @@ def _is_image_file(filename): logger.debug(f"File extension check for {filename}: {'valid' if is_image else 'invalid'} image file") return is_image -@limits(calls=50, period=ONE_MINUTE) -def save_onboarding_feedback(json_data): - """ - Save or update onboarding feedback to Firestore. - - The function handles the new data format and implements logic to either: - 1. Create new feedback if no existing feedback is found - 2. Update existing feedback if found based on contact info or client info - - Expected json_data format: - { - "overallRating": int, - "usefulTopics": [str], - "missingTopics": str, - "easeOfUnderstanding": str, - "improvements": str, - "additionalFeedback": str, - "contactForFollowup": { - "willing": bool, - "name": str (optional), - "email": str (optional) - }, - "clientInfo": { - "userAgent": str, - "ipAddress": str - } - } - """ - db = get_db() - logger.info("Processing onboarding feedback submission") - debug(logger, "Onboarding feedback data", data=json_data) - - try: - # Extract contact information - contact_info = json_data.get("contactForFollowup", {}) - client_info = json_data.get("clientInfo", {}) - - # Check if user provided contact information - has_contact_info = ( - contact_info.get("name") and - contact_info.get("email") and - contact_info.get("name").strip() and - contact_info.get("email").strip() - ) - - existing_feedback = None - - if has_contact_info: - # Search for existing feedback by name and email - logger.info("Searching for existing feedback by contact info") - existing_docs = db.collection('onboarding_feedbacks').where( - "contactForFollowup.name", "==", contact_info["name"] - ).where( - "contactForFollowup.email", "==", contact_info["email"] - ).limit(1).stream() - - for doc in existing_docs: - existing_feedback = doc - logger.info(f"Found existing feedback by contact info: {doc.id}") - break - else: - # Search for existing feedback by client info (anonymous user) - logger.info("Searching for existing feedback by client info") - existing_docs = db.collection('onboarding_feedbacks').where( - "clientInfo.userAgent", "==", client_info.get("userAgent", "") - ).where( - "clientInfo.ipAddress", "==", client_info.get("ipAddress", "") - ).limit(1).stream() - - for doc in existing_docs: - existing_feedback = doc - logger.info(f"Found existing feedback by client info: {doc.id}") - break - - # Prepare the feedback data - feedback_data = { - "overallRating": json_data.get("overallRating"), - "usefulTopics": json_data.get("usefulTopics", []), - "missingTopics": json_data.get("missingTopics", ""), - "easeOfUnderstanding": json_data.get("easeOfUnderstanding", ""), - "improvements": json_data.get("improvements", ""), - "additionalFeedback": json_data.get("additionalFeedback", ""), - "contactForFollowup": contact_info, - "clientInfo": client_info, - "timestamp": datetime.now(pytz.utc) - } - - if existing_feedback: - # Update existing feedback - logger.info(f"Updating existing feedback: {existing_feedback.id}") - existing_feedback.reference.update(feedback_data) - message = "Onboarding feedback updated successfully" - else: - # Create new feedback - logger.info("Creating new onboarding feedback") - db.collection('onboarding_feedbacks').add(feedback_data) - message = "Onboarding feedback submitted successfully" - - return Message(message) - - except Exception as e: - error(logger, f"Error saving onboarding feedback: {str(e)}", exc_info=True) - return Message("Failed to save onboarding feedback", status="error") \ No newline at end of file diff --git a/api/messages/messages_views.py b/api/messages/messages_views.py index 3cddeb0..2537f86 100644 --- a/api/messages/messages_views.py +++ b/api/messages/messages_views.py @@ -17,18 +17,48 @@ get_admin_message, get_single_problem_statement_old, get_user_by_id_old, - get_volunteer_checked_in_by_event, save_helping_status_old, - save_npo, save_profile_metadata_old, save_problem_statement_old, - update_npo, - remove_npo, - get_npo_list, + get_all_profiles, + get_github_profile, +) +from services.news_service import ( + save_news, + get_news, + get_all_praises, + get_praises_about_user, + save_praise, +) +from services.feedback_service import save_feedback, get_user_feedback +from services.giveaway_service import save_giveaway, get_user_giveaway, get_all_giveaways +from services.onboarding_service import save_onboarding_feedback +from services.teams_service import ( + get_teams_list, + get_team, + get_teams_batch, + get_teams_by_event_id, + save_team, + unjoin_team, + join_team, + get_github_repos, +) +from services.email_service import save_lead_async +from services.nonprofits_service import ( get_single_npo, + get_npo_list, get_npo_by_hackathon_id, get_npos_by_hackathon_id, - get_single_hackathon_event, + save_npo_legacy as save_npo, + update_npo_legacy as update_npo, + remove_npo_legacy as remove_npo, + save_npo_application, + get_npo_applications, + update_npo_application, +) +from services.hackathons_service import ( + get_volunteer_checked_in_by_event, + get_single_hackathon_event, single_add_volunteer, get_single_hackathon_id, add_nonprofit_to_hackathon, @@ -38,34 +68,8 @@ get_hackathon_request_by_id, update_hackathon_request, update_hackathon_volunteers, - get_teams_list, - get_team, - get_teams_batch, - get_teams_by_event_id, - save_team, - unjoin_team, - join_team, get_hackathon_list, - save_news, - save_lead_async, - get_news, - get_all_profiles, - save_npo_application, - get_npo_applications, - update_npo_application, - get_github_profile, - get_all_praises, - get_praises_about_user, - save_praise, - save_feedback, - get_user_feedback, get_volunteer_by_event, - get_github_repos, - get_user_giveaway, - save_giveaway, - get_all_giveaways, - upload_image_to_cdn, - save_onboarding_feedback, ) logger = get_logger("messages_views") @@ -669,6 +673,25 @@ def admin_get_all_giveaways(): return get_all_giveaways() +@bp.route("/admin/hackathon-requests", methods=["GET"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_get_all_hackathon_requests(): + logger.info("GET /admin/hackathon-requests called") + from services.hackathons_service import get_all_hackathon_requests + return get_all_hackathon_requests() + +@bp.route("/admin/hackathon-requests/", methods=["PATCH"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_update_hackathon_request_api(request_id): + logger.info(f"PATCH /admin/hackathon-requests/{request_id} called") + from services.hackathons_service import admin_update_hackathon_request + result = admin_update_hackathon_request(request_id, request.get_json()) + if result is None: + return {"error": "Hackathon request not found"}, 404 + return result + @bp.route("/create-hackathon", methods=["POST"]) def submit_create_hackathon(): logger.info("POST /create-hackathon called") diff --git a/api/messages/tests/test_cache_invalidation.py b/api/messages/tests/test_cache_invalidation.py index d7dd251..4b62e37 100644 --- a/api/messages/tests/test_cache_invalidation.py +++ b/api/messages/tests/test_cache_invalidation.py @@ -1,11 +1,11 @@ """ -Test cases for cache invalidation in messages_service. +Test cases for cache invalidation in hackathon operations. These tests verify that cache is properly invalidated when hackathon data is modified. """ import pytest from unittest.mock import patch, MagicMock, call -from api.messages.messages_service import ( +from services.hackathons_service import ( add_nonprofit_to_hackathon, remove_nonprofit_from_hackathon, save_hackathon, @@ -15,9 +15,9 @@ class TestCacheInvalidation: """Test cases for cache invalidation in hackathon operations.""" - - @patch('api.messages.messages_service.clear_cache') - @patch('api.messages.messages_service.get_db') + + @patch('services.hackathons_service.clear_cache') + @patch('services.hackathons_service._get_db') def test_add_nonprofit_to_hackathon_clears_cache(self, mock_db, mock_clear_cache): """Test that adding a nonprofit to a hackathon clears the cache.""" # Setup @@ -26,83 +26,83 @@ def test_add_nonprofit_to_hackathon_clears_cache(self, mock_db, mock_clear_cache mock_hackathon_data.exists = True mock_hackathon_data.to_dict.return_value = {"nonprofits": []} mock_hackathon_doc.get.return_value = mock_hackathon_data - + mock_nonprofit_doc = MagicMock() mock_nonprofit_data = MagicMock() mock_nonprofit_data.exists = True mock_nonprofit_doc.get.return_value = mock_nonprofit_data - + mock_collection = MagicMock() mock_collection.document.side_effect = lambda doc_id: ( mock_hackathon_doc if doc_id == "hackathon123" else mock_nonprofit_doc ) mock_db.return_value.collection.return_value = mock_collection - + json_data = { "hackathonId": "hackathon123", "nonprofitId": "nonprofit456" } - + # Execute result = add_nonprofit_to_hackathon(json_data) - + # Assert assert result["message"] == "Nonprofit added to hackathon" mock_clear_cache.assert_called_once() - - @patch('api.messages.messages_service.clear_cache') - @patch('api.messages.messages_service.get_db') + + @patch('services.hackathons_service.clear_cache') + @patch('services.hackathons_service._get_db') def test_remove_nonprofit_from_hackathon_clears_cache(self, mock_db, mock_clear_cache): """Test that removing a nonprofit from a hackathon clears the cache.""" # Setup mock_nonprofit_ref = MagicMock() mock_nonprofit_ref.id = "nonprofit456" - + mock_hackathon_doc = MagicMock() mock_hackathon_data = MagicMock() mock_hackathon_data.to_dict.return_value = {"nonprofits": [mock_nonprofit_ref]} mock_hackathon_doc.get.return_value = mock_hackathon_data - + mock_nonprofit_doc = MagicMock() - + mock_collection = MagicMock() mock_collection.document.side_effect = lambda doc_id: ( mock_hackathon_doc if doc_id == "hackathon123" else mock_nonprofit_doc ) mock_db.return_value.collection.return_value = mock_collection - + json_data = { "hackathonId": "hackathon123", "nonprofitId": "nonprofit456" } - + # Execute result = remove_nonprofit_from_hackathon(json_data) - + # Assert assert result["message"] == "Nonprofit removed from hackathon" mock_clear_cache.assert_called_once() - - @patch('api.messages.messages_service.clear_cache') - @patch('api.messages.messages_service.get_db') - @patch('api.messages.messages_service.validate_hackathon_data') + + @patch('services.hackathons_service.clear_cache') + @patch('services.hackathons_service._get_db') + @patch('services.hackathons_service.validate_hackathon_data') def test_save_hackathon_clears_cache(self, mock_validate, mock_db, mock_clear_cache): """Test that saving a hackathon clears the cache.""" # Setup mock_db_instance = MagicMock() mock_db.return_value = mock_db_instance mock_validate.return_value = None - + # Mock transaction mock_transaction = MagicMock() mock_db_instance.transaction.return_value = mock_transaction - + # Mock collection and document mock_hackathon_ref = MagicMock() mock_collection = MagicMock() mock_collection.document.return_value = mock_hackathon_ref mock_db_instance.collection.return_value = mock_collection - + json_data = { "title": "Test Hackathon", "description": "Test Description", @@ -113,20 +113,20 @@ def test_save_hackathon_clears_cache(self, mock_validate, mock_db, mock_clear_ca "image_url": "https://example.com/image.png", "event_id": "event123" } - + # Execute result = save_hackathon(json_data, "user123") - + # Assert assert result.text == "Saved Hackathon" mock_clear_cache.assert_called_once() - - @patch('api.messages.messages_service.doc_to_json') - @patch('api.messages.messages_service.get_single_hackathon_event') - @patch('api.messages.messages_service.get_single_hackathon_id') - @patch('api.messages.messages_service.get_hackathon_list') + + @patch('common.utils.firestore_helpers.doc_to_json') + @patch('services.hackathons_service.get_single_hackathon_event') + @patch('services.hackathons_service.get_single_hackathon_id') + @patch('services.hackathons_service.get_hackathon_list') def test_clear_cache_clears_all_caches( - self, + self, mock_get_hackathon_list, mock_get_single_hackathon_id, mock_get_single_hackathon_event, @@ -135,7 +135,7 @@ def test_clear_cache_clears_all_caches( """Test that clear_cache clears all hackathon-related caches.""" # Execute clear_cache() - + # Assert - verify that cache_clear was called on all cached functions mock_doc_to_json.cache_clear.assert_called_once() mock_get_single_hackathon_event.cache_clear.assert_called_once() diff --git a/api/messages/tests/test_hackathon_requests.py b/api/messages/tests/test_hackathon_requests.py new file mode 100644 index 0000000..36c3972 --- /dev/null +++ b/api/messages/tests/test_hackathon_requests.py @@ -0,0 +1,340 @@ +""" +Test cases for hackathon request admin service functions. + +Tests get_all_hackathon_requests and admin_update_hackathon_request +from hackathons_service. +""" +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime +from services.hackathons_service import ( + get_all_hackathon_requests, + admin_update_hackathon_request, + get_hackathon_request_by_id, + create_hackathon, + update_hackathon_request, +) + + +class TestGetAllHackathonRequests: + """Test cases for listing all hackathon requests.""" + + @patch('services.hackathons_service._get_db') + def test_returns_all_requests(self, mock_db): + """Test that all hackathon requests are returned with their IDs.""" + # Setup + mock_doc1 = MagicMock() + mock_doc1.id = "request-1" + mock_doc1.to_dict.return_value = { + "companyName": "Acme Corp", + "contactName": "Alice", + "status": "pending", + "created": "2025-06-01T10:00:00", + } + + mock_doc2 = MagicMock() + mock_doc2.id = "request-2" + mock_doc2.to_dict.return_value = { + "companyName": "Beta Inc", + "contactName": "Bob", + "status": "approved", + "created": "2025-07-01T10:00:00", + } + + mock_collection = MagicMock() + mock_collection.stream.return_value = [mock_doc1, mock_doc2] + mock_db.return_value.collection.return_value = mock_collection + + # Execute + result = get_all_hackathon_requests() + + # Assert + assert "requests" in result + assert len(result["requests"]) == 2 + mock_db.return_value.collection.assert_called_once_with('hackathon_requests') + + @patch('services.hackathons_service._get_db') + def test_requests_include_document_ids(self, mock_db): + """Test that each request includes its Firestore document ID.""" + mock_doc = MagicMock() + mock_doc.id = "abc-123" + mock_doc.to_dict.return_value = { + "companyName": "Test Co", + "created": "2025-01-01T00:00:00", + } + + mock_collection = MagicMock() + mock_collection.stream.return_value = [mock_doc] + mock_db.return_value.collection.return_value = mock_collection + + result = get_all_hackathon_requests() + + assert result["requests"][0]["id"] == "abc-123" + assert result["requests"][0]["companyName"] == "Test Co" + + @patch('services.hackathons_service._get_db') + def test_requests_sorted_newest_first(self, mock_db): + """Test that requests are sorted by created date descending.""" + mock_doc_old = MagicMock() + mock_doc_old.id = "old" + mock_doc_old.to_dict.return_value = { + "companyName": "Old Co", + "created": "2025-01-01T00:00:00", + } + + mock_doc_new = MagicMock() + mock_doc_new.id = "new" + mock_doc_new.to_dict.return_value = { + "companyName": "New Co", + "created": "2025-12-01T00:00:00", + } + + mock_collection = MagicMock() + # Return in wrong order to verify sorting + mock_collection.stream.return_value = [mock_doc_old, mock_doc_new] + mock_db.return_value.collection.return_value = mock_collection + + result = get_all_hackathon_requests() + + assert result["requests"][0]["id"] == "new" + assert result["requests"][1]["id"] == "old" + + @patch('services.hackathons_service._get_db') + def test_empty_collection_returns_empty_list(self, mock_db): + """Test that an empty collection returns an empty requests list.""" + mock_collection = MagicMock() + mock_collection.stream.return_value = [] + mock_db.return_value.collection.return_value = mock_collection + + result = get_all_hackathon_requests() + + assert result == {"requests": []} + + @patch('services.hackathons_service._get_db') + def test_handles_missing_created_field(self, mock_db): + """Test that requests without a created field are still returned.""" + mock_doc = MagicMock() + mock_doc.id = "no-date" + mock_doc.to_dict.return_value = { + "companyName": "No Date Co", + "status": "pending", + } + + mock_collection = MagicMock() + mock_collection.stream.return_value = [mock_doc] + mock_db.return_value.collection.return_value = mock_collection + + result = get_all_hackathon_requests() + + assert len(result["requests"]) == 1 + assert result["requests"][0]["companyName"] == "No Date Co" + + +class TestAdminUpdateHackathonRequest: + """Test cases for admin updating a hackathon request.""" + + @patch('services.hackathons_service.send_slack_audit') + @patch('services.hackathons_service._get_db') + def test_updates_status_successfully(self, mock_db, mock_slack): + """Test that an admin can update the status of a request.""" + # Setup + mock_doc_ref = MagicMock() + mock_snapshot = MagicMock() + mock_snapshot.exists = True + mock_doc_ref.get.return_value = mock_snapshot + + # After update, return updated doc + updated_dict = { + "companyName": "Test Co", + "status": "approved", + "adminNotes": "Looks good", + "updated": "2025-07-01T00:00:00", + } + # First get() for exists check, second get() after update + mock_snapshot_after = MagicMock() + mock_snapshot_after.to_dict.return_value = updated_dict + mock_doc_ref.get.side_effect = [mock_snapshot, mock_snapshot_after] + + mock_collection = MagicMock() + mock_collection.document.return_value = mock_doc_ref + mock_db.return_value.collection.return_value = mock_collection + + # Execute + result = admin_update_hackathon_request("req-123", { + "status": "approved", + "adminNotes": "Looks good", + }) + + # Assert + assert result is not None + assert result["id"] == "req-123" + mock_doc_ref.update.assert_called_once() + update_args = mock_doc_ref.update.call_args[0][0] + assert update_args["status"] == "approved" + assert update_args["adminNotes"] == "Looks good" + assert "updated" in update_args + + @patch('services.hackathons_service.send_slack_audit') + @patch('services.hackathons_service._get_db') + def test_returns_none_for_nonexistent_request(self, mock_db, mock_slack): + """Test that updating a nonexistent request returns None.""" + mock_doc_ref = MagicMock() + mock_snapshot = MagicMock() + mock_snapshot.exists = False + mock_doc_ref.get.return_value = mock_snapshot + + mock_collection = MagicMock() + mock_collection.document.return_value = mock_doc_ref + mock_db.return_value.collection.return_value = mock_collection + + result = admin_update_hackathon_request("nonexistent-id", { + "status": "approved", + }) + + assert result is None + mock_doc_ref.update.assert_not_called() + + @patch('services.hackathons_service.send_slack_audit') + @patch('services.hackathons_service._get_db') + def test_adds_updated_timestamp(self, mock_db, mock_slack): + """Test that the updated timestamp is added to the update payload.""" + mock_doc_ref = MagicMock() + mock_snapshot = MagicMock() + mock_snapshot.exists = True + mock_doc_ref.get.return_value = mock_snapshot + + mock_snapshot_after = MagicMock() + mock_snapshot_after.to_dict.return_value = {"status": "in-progress"} + mock_doc_ref.get.side_effect = [mock_snapshot, mock_snapshot_after] + + mock_collection = MagicMock() + mock_collection.document.return_value = mock_doc_ref + mock_db.return_value.collection.return_value = mock_collection + + admin_update_hackathon_request("req-456", {"status": "in-progress"}) + + update_args = mock_doc_ref.update.call_args[0][0] + assert "updated" in update_args + # Verify it's a valid ISO format timestamp + datetime.fromisoformat(update_args["updated"]) + + @patch('services.hackathons_service.send_slack_audit') + @patch('services.hackathons_service._get_db') + def test_sends_slack_audit(self, mock_db, mock_slack): + """Test that updating a request sends a Slack audit message.""" + mock_doc_ref = MagicMock() + mock_snapshot = MagicMock() + mock_snapshot.exists = True + mock_doc_ref.get.return_value = mock_snapshot + + mock_snapshot_after = MagicMock() + mock_snapshot_after.to_dict.return_value = {} + mock_doc_ref.get.side_effect = [mock_snapshot, mock_snapshot_after] + + mock_collection = MagicMock() + mock_collection.document.return_value = mock_doc_ref + mock_db.return_value.collection.return_value = mock_collection + + admin_update_hackathon_request("req-789", {"status": "rejected"}) + + mock_slack.assert_called_once() + call_kwargs = mock_slack.call_args[1] + assert call_kwargs["action"] == "admin_update_hackathon_request" + assert call_kwargs["message"] == "Admin updating" + assert call_kwargs["payload"]["status"] == "rejected" + + +class TestGetHackathonRequestById: + """Test cases for retrieving a single hackathon request.""" + + @patch('services.hackathons_service.send_slack_audit') + @patch('services.hackathons_service._get_db') + def test_returns_request_data(self, mock_db, mock_slack): + """Test that a request is returned by its document ID.""" + mock_doc = MagicMock() + mock_doc_data = MagicMock() + mock_doc_data.to_dict.return_value = { + "companyName": "Found Co", + "status": "pending", + } + mock_doc.get.return_value = mock_doc_data + + mock_collection = MagicMock() + mock_collection.document.return_value = mock_doc + mock_db.return_value.collection.return_value = mock_collection + + result = get_hackathon_request_by_id("doc-123") + + assert result["companyName"] == "Found Co" + mock_collection.document.assert_called_once_with("doc-123") + + +class TestCreateHackathon: + """Test cases for creating a new hackathon request.""" + + @patch('services.hackathons_service.send_slack') + @patch('services.hackathons_service.send_hackathon_request_email') + @patch('services.hackathons_service.send_slack_audit') + @patch('services.hackathons_service._get_db') + def test_creates_request_with_pending_status(self, mock_db, mock_slack_audit, mock_email, mock_slack): + """Test that a new request is created with pending status.""" + mock_doc = MagicMock() + mock_collection = MagicMock() + mock_collection.document.return_value = mock_doc + mock_db.return_value.collection.return_value = mock_collection + + payload = { + "companyName": "New Corp", + "contactName": "Charlie", + "contactEmail": "charlie@example.com", + } + + result = create_hackathon(payload) + + assert result["success"] is True + assert result["message"] == "Hackathon Request Created" + assert "id" in result + # Verify the data was saved with pending status + saved_data = mock_doc.set.call_args[0][0] + assert saved_data["status"] == "pending" + assert "created" in saved_data + + @patch('services.hackathons_service.send_slack') + @patch('services.hackathons_service.send_hackathon_request_email') + @patch('services.hackathons_service.send_slack_audit') + @patch('services.hackathons_service._get_db') + def test_sends_confirmation_email(self, mock_db, mock_slack_audit, mock_email, mock_slack): + """Test that a confirmation email is sent on creation.""" + mock_doc = MagicMock() + mock_collection = MagicMock() + mock_collection.document.return_value = mock_doc + mock_db.return_value.collection.return_value = mock_collection + + payload = { + "companyName": "Email Corp", + "contactName": "Diana", + "contactEmail": "diana@example.com", + } + + create_hackathon(payload) + + mock_email.assert_called_once() + call_args = mock_email.call_args[0] + assert call_args[0] == "Diana" + assert call_args[1] == "diana@example.com" + + @patch('services.hackathons_service.send_slack') + @patch('services.hackathons_service.send_slack_audit') + @patch('services.hackathons_service._get_db') + def test_skips_email_without_contact_info(self, mock_db, mock_slack_audit, mock_slack): + """Test that no email is sent if contact info is missing.""" + mock_doc = MagicMock() + mock_collection = MagicMock() + mock_collection.document.return_value = mock_doc + mock_db.return_value.collection.return_value = mock_collection + + payload = {"companyName": "No Contact Corp"} + + with patch('services.hackathons_service.send_hackathon_request_email') as mock_email: + create_hackathon(payload) + mock_email.assert_not_called() diff --git a/api/newsletters/newsletter_service.py b/api/newsletters/newsletter_service.py index 010c462..4bda365 100644 --- a/api/newsletters/newsletter_service.py +++ b/api/newsletters/newsletter_service.py @@ -1,7 +1,9 @@ import logging from ratelimit import limits -from api.messages.messages_service import (get_db, ONE_MINUTE) +from db.db import get_db + +ONE_MINUTE = 60 logger = logging.getLogger("myapp") diff --git a/api/teams/teams_service.py b/api/teams/teams_service.py index a1f79e8..ea4994a 100644 --- a/api/teams/teams_service.py +++ b/api/teams/teams_service.py @@ -1,26 +1,19 @@ import uuid import logging from datetime import datetime -from db.db import get_db -from api.messages.messages_service import ( - get_propel_user_details_by_id, - get_user_doc_reference, - get_problem_statement_from_id_old, - get_single_npo, - validate_github_username, - get_hackathon_by_event_id, - get_teams_list, - clear_cache, - create_github_repo, - create_slack_channel, - invite_user_to_channel, - send_slack, - send_slack_audit -) +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.nonprofits_service import get_single_npo +from common.utils.firestore_helpers import clear_all_caches as clear_cache from services.users_service import ( + get_propel_user_details_by_id, save_user, get_user_from_slack_id ) +from common.utils.github import create_github_repo, validate_github_username +from common.utils.slack import create_slack_channel, invite_user_to_channel, send_slack, send_slack_audit +from common.utils.firebase import get_hackathon_by_event_id from common.utils.oauth_providers import extract_slack_user_id, is_oauth_user_id from common.utils.slack import add_bot_to_channel diff --git a/api/volunteers/volunteers_views.py b/api/volunteers/volunteers_views.py index 163b44e..bc12792 100644 --- a/api/volunteers/volunteers_views.py +++ b/api/volunteers/volunteers_views.py @@ -17,6 +17,7 @@ send_volunteer_message, send_email_to_address, get_resend_email_statuses, + list_all_resend_emails, ) from common.auth import auth, auth_user @@ -595,6 +596,8 @@ def admin_send_email_to_address(): recipient_type = request_data.get('recipient_type', 'volunteer') name = request_data.get('name') volunteer_id = request_data.get('volunteer_id') + collection_name = request_data.get('collection_name') + document_id = request_data.get('document_id') if not email: return _error_response("Email address is required", 400) @@ -612,7 +615,9 @@ def admin_send_email_to_address(): admin_user=auth_user, recipient_type=recipient_type, name=name, - volunteer_id=volunteer_id + volunteer_id=volunteer_id, + collection_name=collection_name, + document_id=document_id ) if result['success']: @@ -656,3 +661,35 @@ def admin_get_resend_email_statuses(): logger.error("Error in admin_get_resend_email_statuses: %s", str(e)) logger.exception(e) return _error_response(f"Failed to fetch email statuses: {str(e)}") + + +@bp.route('/admin/emails/resend-list', methods=['POST']) +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_list_resend_emails(): + """Admin endpoint to list all sent emails from Resend, indexed by recipient.""" + try: + body = request.get_json(silent=True) or {} + emails_param = body.get('emails', []) + if isinstance(emails_param, str): + parsed = [e.strip() for e in emails_param.split(',') if e.strip()] + filter_emails = parsed if parsed else None + elif isinstance(emails_param, list): + parsed = [e.strip() for e in emails_param if isinstance(e, str) and e.strip()] + filter_emails = parsed if parsed else None + else: + filter_emails = None + + logger.info("Listing Resend emails, filter_count=%d", len(filter_emails) if filter_emails else 0) + + result = list_all_resend_emails(filter_emails=filter_emails) + + if result['success']: + return _success_response(result, "Resend email list fetched successfully") + + logger.error("Failed to list Resend emails. Error: %s", result.get('error', 'Unknown error')) + return _error_response(result.get('error', 'Unknown error'), 500) + + except Exception as e: + logger.error("Error in admin_list_resend_emails: %s", str(e)) + logger.exception(e) + return _error_response(f"Failed to list Resend emails: {str(e)}") diff --git a/common/utils/firestore_helpers.py b/common/utils/firestore_helpers.py new file mode 100644 index 0000000..352f565 --- /dev/null +++ b/common/utils/firestore_helpers.py @@ -0,0 +1,114 @@ +import time +from functools import wraps + +from cachetools import cached, TTLCache +from cachetools.keys import hashkey +from firebase_admin import firestore +from firebase_admin.firestore import DocumentReference, DocumentSnapshot + +from common.log import get_logger + +logger = get_logger("firestore_helpers") + +# Registry of caches to clear +_cache_registry = [] + + +def register_cache(cache_obj): + """Register a cache for bulk clearing via clear_all_caches().""" + _cache_registry.append(cache_obj) + + +def clear_all_caches(): + """Clear all registered caches and the doc_to_json cache.""" + doc_to_json.cache_clear() + for cache_obj in _cache_registry: + try: + cache_obj.cache_clear() + except Exception as e: + logger.warning(f"Failed to clear a registered cache: {e}") + + +def hash_key(docid, doc=None, depth=0): + return hashkey(docid) + + +def log_execution_time(func): + @wraps(func) + def wrapper(*args, **kwargs): + start_time = time.time() + result = func(*args, **kwargs) + end_time = time.time() + execution_time = end_time - start_time + logger.debug(f"{func.__name__} execution time: {execution_time:.4f} seconds") + return result + return wrapper + + +@cached(cache=TTLCache(maxsize=2000, ttl=3600), key=hash_key) +def doc_to_json(docid=None, doc=None, depth=0): + if not docid: + logger.debug("docid is NoneType") + return + if not doc: + logger.debug("doc is NoneType") + return + + # Check if type is DocumentSnapshot + if isinstance(doc, firestore.DocumentSnapshot): + logger.debug("doc is DocumentSnapshot") + d_json = doc.to_dict() + # Check if type is DocumentReference + elif isinstance(doc, firestore.DocumentReference): + logger.debug("doc is DocumentReference") + d = doc.get() + d_json = d.to_dict() + else: + return doc + + if d_json is None: + logger.warn(f"doc.to_dict() is NoneType | docid={docid} doc={doc}") + return + + # If any values in d_json is a list, add only the document id to the list for DocumentReference or DocumentSnapshot + for key, value in d_json.items(): + if isinstance(value, list): + for i, v in enumerate(value): + logger.debug(f"doc_to_json - i={i} v={v}") + if isinstance(v, firestore.DocumentReference): + value[i] = v.id + elif isinstance(v, firestore.DocumentSnapshot): + value[i] = v.id + else: + value[i] = v + d_json[key] = value + + d_json["id"] = docid + return d_json + + +def doc_to_json_recursive(doc=None): + logger.debug(f"doc_to_json_recursive start doc={doc}") + + if not doc: + logger.debug("doc is NoneType") + return + + docid = "" + # Check if type is DocumentSnapshot + if isinstance(doc, DocumentSnapshot): + logger.debug("doc is DocumentSnapshot") + d_json = doc_to_json(docid=doc.id, doc=doc) + docid = doc.id + # Check if type is DocumentReference + elif isinstance(doc, DocumentReference): + logger.debug("doc is DocumentReference") + d = doc.get() + docid = d.id + d_json = doc_to_json(docid=doc.id, doc=d) + else: + logger.debug(f"Not DocumentSnapshot or DocumentReference, skipping - returning: {doc}") + return doc + + d_json["id"] = docid + return d_json diff --git a/requirements.txt b/requirements.txt index 2b74c2f..2e6d459 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ et-xmlfile==1.1.0 openpyxl==3.1.2 pylint==3.2.5 pytest==8.2.2 -resend==2.3.0 +resend==2.22.0 readme-metrics[Flask]==3.1.0 redis>=6.1.0 tiktoken==0.9.0 diff --git a/services/email_service.py b/services/email_service.py new file mode 100644 index 0000000..8f42c30 --- /dev/null +++ b/services/email_service.py @@ -0,0 +1,263 @@ +import os +import random +from datetime import datetime + +import resend +import requests +from ratelimit import limits + +from common.log import get_logger, debug +from common.utils.slack import send_slack +from common.utils import safe_get_env_var + +logger = get_logger("email_service") + +ONE_MINUTE = 60 + +resend_api_key = os.getenv("RESEND_WELCOME_EMAIL_KEY") +if not resend_api_key: + logger.error("RESEND_WELCOME_EMAIL_KEY not set") +else: + resend.api_key = resend_api_key + +google_recaptcha_key = safe_get_env_var("GOOGLE_CAPTCHA_SECRET_KEY") + + +def add_utm(url, source="email", medium="welcome", campaign="newsletter_signup", content=None): + utm_string = f"utm_source={source}&utm_medium={medium}&utm_campaign={campaign}" + if content: + utm_string += f"&utm_content={content}" + return f"{url}?{utm_string}" + + +def send_nonprofit_welcome_email(organization_name, contact_name, email): + resend.api_key = os.getenv("RESEND_WELCOME_EMAIL_KEY") + + subject = "Welcome to Opportunity Hack: Tech Solutions for Your Nonprofit!" + + images = [ + "https://cdn.ohack.dev/ohack.dev/2023_hackathon_1.webp", + "https://cdn.ohack.dev/ohack.dev/2023_hackathon_2.webp", + "https://cdn.ohack.dev/ohack.dev/2023_hackathon_3.webp" + ] + chosen_image = random.choice(images) + image_number = images.index(chosen_image) + 1 + image_utm_content = f"nonprofit_header_image_{image_number}" + + html_content = f""" + + + + + + Welcome to Opportunity Hack + + + Opportunity Hack Event + +

    Welcome {organization_name} to Opportunity Hack!

    + +

    Dear {contact_name},

    + +

    We're excited to welcome {organization_name} to the Opportunity Hack community! We're here to connect your nonprofit with skilled tech volunteers to bring your ideas to life.

    + +

    What's Next?

    + + +

    Important Links:

    + + +

    Questions or need assistance? Reach out on our Slack channel or email us at support@ohack.org.

    + +

    We're excited to work with you to create tech solutions that amplify your impact!

    + +

    Best regards,
    The Opportunity Hack Team

    + + + + + + """ + + if organization_name is None or organization_name == "" or organization_name == "Unassigned" or organization_name.isspace(): + organization_name = "Nonprofit Partner" + + if contact_name is None or contact_name == "" or contact_name == "Unassigned" or contact_name.isspace(): + contact_name = "Nonprofit Friend" + + params = { + "from": "Opportunity Hack ", + "to": f"{contact_name} <{email}>", + "cc": "questions@ohack.org", + "reply_to": "questions@ohack.org", + "subject": subject, + "html": html_content, + } + logger.info(f"Sending nonprofit application email to {email}") + + email = resend.Emails.SendParams(params) + resend.Emails.send(email) + + logger.info(f"Sent nonprofit application email to {email}") + return True + + +def send_welcome_email(name, email): + resend.api_key = os.getenv("RESEND_WELCOME_EMAIL_KEY") + + subject = "Welcome to Opportunity Hack: Code for Good!" + + images = [ + "https://cdn.ohack.dev/ohack.dev/2023_hackathon_1.webp", + "https://cdn.ohack.dev/ohack.dev/2023_hackathon_2.webp", + "https://cdn.ohack.dev/ohack.dev/2023_hackathon_3.webp" + ] + chosen_image = random.choice(images) + image_number = images.index(chosen_image) + 1 + image_utm_content = f"header_image_{image_number}" + + + html_content = f""" + + + + + + Welcome to Opportunity Hack + + + Opportunity Hack Event + +

    Hey {name}!! Welcome to Opportunity Hack!

    + +

    We're thrilled you've joined our community of tech volunteers making a difference!

    + +

    At Opportunity Hack, we believe in harnessing the power of code for social good. Our mission is simple: connect skilled volunteers like you with nonprofits that need tech solutions.

    + +

    Ready to dive in?

    + + +

    Got questions? Reach out on our Slack channel.

    + +

    Together, we can code for change!

    + +

    The Opportunity Hack Team

    + + + + + + """ + + + if name is None or name == "" or name == "Unassigned" or name.isspace(): + name = "OHack Friend" + + + params = { + "from": "Opportunity Hack ", + "to": f"{name} <{email}>", + "cc": "questions@ohack.org", + "reply_to": "questions@ohack.org", + "subject": subject, + "html": html_content, + } + + email = resend.Emails.SendParams(params) + resend.Emails.send(email) + debug(logger, "Processing email", email=email) + return True + + +def send_welcome_emails(): + from db.db import get_db + logger.info("Sending welcome emails") + db = get_db() + + query = db.collection('leads').stream() + leads = [] + for lead in query: + lead_dict = lead.to_dict() + if "welcome_email_sent" not in lead_dict and "email" in lead_dict and lead_dict["email"] is not None and lead_dict["email"] != "": + leads.append(lead) + + send_email = False + + emails = set() + + for lead in leads: + lead_dict = lead.to_dict() + email = lead_dict["email"].lower() + if email in emails: + logger.info(f"Skipping duplicate email {email}") + continue + + logger.info(f"Sending welcome email to '{lead_dict['name']}' {email} for {lead.id}") + + if send_email: + success_send_email = send_welcome_email(lead_dict["name"], email) + if success_send_email: + logger.info(f"Sent welcome email to {email}") + lead.reference.update({ + "welcome_email_sent": datetime.now().isoformat() + }) + emails.add(email) + + +async def save_lead(json): + from db.db import get_db + token = json["token"] + + if "name" not in json or "email" not in json: + logger.error(f"Missing field name or email {json}") + return False + + if len(json["name"]) < 2 or len(json["email"]) < 3: + logger.error(f"Name or email too short name:{json['name']} email:{json['email']}") + return False + + recaptcha_response = requests.post( + f"https://www.google.com/recaptcha/api/siteverify?secret={google_recaptcha_key}&response={token}") + recaptcha_response_json = recaptcha_response.json() + logger.info(f"Recaptcha Response: {recaptcha_response_json}") + + if recaptcha_response_json["success"] == False: + return False + else: + logger.info("Recaptcha Success, saving...") + db = get_db() + collection = db.collection('leads') + del json["token"] + + json["timestamp"] = datetime.now().isoformat() + insert_res = collection.add(json) + logger.info(f"Lead saved for {json}") + + slack_message = f"New lead! Name:`{json['name']}` Email:`{json['email']}`" + send_slack(slack_message, "ohack-dev-leads") + + success_send_email = send_welcome_email( json["name"], json["email"] ) + if success_send_email: + logger.info(f"Sent welcome email to {json['email']}") + collection.document(insert_res[1].id).update({ + "welcome_email_sent": datetime.now().isoformat() + }) + return True + + +@limits(calls=30, period=ONE_MINUTE) +async def save_lead_async(json): + await save_lead(json) diff --git a/services/feedback_service.py b/services/feedback_service.py new file mode 100644 index 0000000..ee21f63 --- /dev/null +++ b/services/feedback_service.py @@ -0,0 +1,138 @@ +import os +import uuid +from datetime import datetime + +import resend +from cachetools import cached, TTLCache +from ratelimit import limits +from firebase_admin import firestore + +from common.log import get_logger +from common.utils.slack import send_slack_audit, send_slack +from db.db import get_db +from services.users_service import get_slack_user_from_propel_user_id, get_user_from_slack_id +from api.messages.message import Message + +logger = get_logger("feedback_service") + +ONE_MINUTE = 60 + +resend_api_key = os.getenv("RESEND_WELCOME_EMAIL_KEY") +if resend_api_key: + resend.api_key = resend_api_key + + +@limits(calls=50, period=ONE_MINUTE) +def save_feedback(propel_user_id, json): + db = get_db() + logger.info("Saving Feedback") + send_slack_audit(action="save_feedback", message="Saving", payload=json) + + slack_user = get_slack_user_from_propel_user_id(propel_user_id) + user_db_id = get_user_from_slack_id(slack_user["sub"]).id + feedback_giver_id = slack_user["sub"] + + doc_id = uuid.uuid1().hex + feedback_receiver_id = json.get("feedback_receiver_id") + relationship = json.get("relationship") + duration = json.get("duration") + confidence_level = json.get("confidence_level") + is_anonymous = json.get("is_anonymous", False) + feedback_data = json.get("feedback", {}) + + collection = db.collection('feedback') + + insert_res = collection.document(doc_id).set({ + "feedback_giver_slack_id": feedback_giver_id, + "feedback_giver_id": user_db_id, + "feedback_receiver_id": feedback_receiver_id, + "relationship": relationship, + "duration": duration, + "confidence_level": confidence_level, + "is_anonymous": is_anonymous, + "feedback": feedback_data, + "timestamp": datetime.now().isoformat() + }) + + logger.info(f"Insert Result: {insert_res}") + + notify_feedback_receiver(feedback_receiver_id) + + get_user_feedback.cache_clear() + + return Message("Feedback saved successfully") + + +def notify_feedback_receiver(feedback_receiver_id): + db = get_db() + user_doc = db.collection('users').document(feedback_receiver_id).get() + if user_doc.exists: + user_data = user_doc.to_dict() + user_email = user_data.get('email_address', '') + slack_user_id = user_data.get('user_id', '').split('-')[-1] + logger.info(f"User with ID {feedback_receiver_id} found") + logger.info(f"Sending notification to user {slack_user_id}") + + message = ( + f"Hello <@{slack_user_id}>! You've received new feedback. " + "Visit https://www.ohack.dev/myfeedback to view it." + ) + + send_slack(message=message, channel=slack_user_id) + logger.info(f"Notification sent to user {slack_user_id}") + + if user_email: + subject = "New Feedback Received" + html_content = f""" + + + + + + New Feedback Received + + +

    New Feedback Received

    +

    Hello,

    +

    You have received new feedback. Please visit My Feedback to view it.

    +

    Thank you for being a part of the Opportunity Hack community!

    +

    Best regards,
    The Opportunity Hack Team

    + + + """ + params = { + "from": "Opportunity Hack ", + "to": f"{user_data.get('name', 'User')} <{user_email}>", + "bcc": "greg@ohack.org", + "subject": subject, + "html": html_content, + } + email = resend.Emails.SendParams(params) + resend.Emails.send(email) + logger.info(f"Email notification sent to {user_email}") + else: + logger.warning(f"User with ID {feedback_receiver_id} not found") + + +@cached(cache=TTLCache(maxsize=100, ttl=600)) +@limits(calls=100, period=ONE_MINUTE) +def get_user_feedback(propel_user_id): + logger.info(f"Getting feedback for propel_user_id: {propel_user_id}") + db = get_db() + + slack_user = get_slack_user_from_propel_user_id(propel_user_id) + db_user_id = get_user_from_slack_id(slack_user["sub"]).id + + feedback_docs = db.collection('feedback').where("feedback_receiver_id", "==", db_user_id).order_by("timestamp", direction=firestore.Query.DESCENDING).stream() + + feedback_list = [] + for doc in feedback_docs: + feedback = doc.to_dict() + if feedback.get("is_anonymous", False): + if "feedback_giver_id" in feedback: + feedback.pop("feedback_giver_id") + if "feedback_giver_slack_id" in feedback: + feedback.pop("feedback_giver_slack_id") + feedback_list.append(feedback) + + return {"feedback": feedback_list} diff --git a/services/giveaway_service.py b/services/giveaway_service.py new file mode 100644 index 0000000..c6eeba7 --- /dev/null +++ b/services/giveaway_service.py @@ -0,0 +1,74 @@ +import uuid +from datetime import datetime + +from firebase_admin import firestore + +from common.log import get_logger +from common.utils.slack import send_slack_audit +from db.db import get_db +from services.users_service import get_slack_user_from_propel_user_id, get_user_from_slack_id +from api.messages.message import Message + +logger = get_logger("giveaway_service") + + +def save_giveaway(propel_user_id, json): + db = get_db() + logger.info("Submitting Giveaway") + send_slack_audit(action="submit_giveaway", message="Submitting", payload=json) + + slack_user = get_slack_user_from_propel_user_id(propel_user_id) + user_db_id = get_user_from_slack_id(slack_user["sub"]).id + giveaway_id = json.get("giveaway_id") + giveaway_data = json.get("giveaway_data", {}) + entries = json.get("entries", 0) + + collection = db.collection('giveaways') + + doc_id = uuid.uuid1().hex + insert_res = collection.document(doc_id).set({ + "user_id": user_db_id, + "giveaway_id": giveaway_id, + "entries": entries, + "giveaway_data": giveaway_data, + "timestamp": datetime.now().isoformat() + }) + + logger.info(f"Insert Result: {insert_res}") + + return Message("Giveaway submitted successfully") + + +def get_user_giveaway(propel_user_id): + logger.info(f"Getting giveaway for propel_user_id: {propel_user_id}") + db = get_db() + + slack_user = get_slack_user_from_propel_user_id(propel_user_id) + db_user_id = get_user_from_slack_id(slack_user["sub"]).id + + giveaway_docs = db.collection('giveaways').where("user_id", "==", db_user_id).stream() + + giveaway_list = [] + for doc in giveaway_docs: + giveaway = doc.to_dict() + giveaway_list.append(giveaway) + + return {"giveaways": giveaway_list} + + +def get_all_giveaways(): + logger.info("Getting all giveaways") + db = get_db() + docs = db.collection('giveaways').order_by("timestamp", direction=firestore.Query.DESCENDING).stream() + + giveaways = {} + for doc in docs: + giveaway = doc.to_dict() + user_id = giveaway["user_id"] + if user_id not in giveaways: + from api.messages.messages_service import get_user_by_id_old + user = get_user_by_id_old(user_id) + giveaway["user"] = user + giveaways[user_id] = giveaway + + return {"giveaways": list(giveaways.values())} diff --git a/services/hackathons_service.py b/services/hackathons_service.py new file mode 100644 index 0000000..a77976a --- /dev/null +++ b/services/hackathons_service.py @@ -0,0 +1,678 @@ +import uuid +import os +import random +from datetime import datetime, timedelta + +from cachetools import cached, TTLCache +from ratelimit import limits +from firebase_admin import firestore +from firebase_admin.firestore import DocumentReference, DocumentSnapshot +import resend + +from common.log import get_logger +from common.utils.slack import send_slack_audit, send_slack, async_send_slack, invite_user_to_channel +from common.utils.firebase import ( + get_hackathon_by_event_id, + get_volunteer_from_db_by_event, + get_volunteer_checked_in_from_db_by_event, +) +from common.utils.validators import validate_hackathon_data +from common.utils.firestore_helpers import ( + doc_to_json, + doc_to_json_recursive, + hash_key, + log_execution_time, + clear_all_caches, + register_cache, +) +from api.messages.message import Message +from services.users_service import get_propel_user_details_by_id + +logger = get_logger("hackathons_service") + +ONE_MINUTE = 60 +THIRTY_SECONDS = 30 + + +def _get_db(): + from db.db import get_db + return get_db() + + +def clear_cache(): + """Clear all hackathon-related caches.""" + clear_all_caches() + get_single_hackathon_event.cache_clear() + get_single_hackathon_id.cache_clear() + get_hackathon_list.cache_clear() + + +def add_nonprofit_to_hackathon(json): + hackathonId = json["hackathonId"] + nonprofitId = json["nonprofitId"] + + logger.info(f"Add Nonprofit to Hackathon Start hackathonId={hackathonId} nonprofitId={nonprofitId}") + + db = _get_db() + hackathon_doc = db.collection('hackathons').document(hackathonId) + nonprofit_doc = db.collection('nonprofits').document(nonprofitId) + hackathon_data = hackathon_doc.get() + if not hackathon_data.exists: + logger.warning(f"Add Nonprofit to Hackathon End (no results)") + return { + "message": "Hackathon not found" + } + nonprofit_data = nonprofit_doc.get() + if not nonprofit_data.exists: + logger.warning(f"Add Nonprofit to Hackathon End (no results)") + return { + "message": "Nonprofit not found" + } + hackathon_dict = hackathon_data.to_dict() + if "nonprofits" not in hackathon_dict: + hackathon_dict["nonprofits"] = [] + if nonprofit_doc in hackathon_dict["nonprofits"]: + logger.warning(f"Add Nonprofit to Hackathon End (no results)") + return { + "message": "Nonprofit already in hackathon" + } + hackathon_dict["nonprofits"].append(nonprofit_doc) + hackathon_doc.set(hackathon_dict, merge=True) + + clear_cache() + + return { + "message": "Nonprofit added to hackathon" + } + + +def remove_nonprofit_from_hackathon(json): + hackathonId = json["hackathonId"] + nonprofitId = json["nonprofitId"] + + logger.info(f"Remove Nonprofit from Hackathon Start hackathonId={hackathonId} nonprofitId={nonprofitId}") + + db = _get_db() + hackathon_doc = db.collection('hackathons').document(hackathonId) + nonprofit_doc = db.collection('nonprofits').document(nonprofitId) + + if not hackathon_doc: + logger.warning(f"Remove Nonprofit from Hackathon End (hackathon not found)") + return { + "message": "Hackathon not found" + } + + hackathon_data = hackathon_doc.get().to_dict() + + if "nonprofits" not in hackathon_data or not hackathon_data["nonprofits"]: + logger.warning(f"Remove Nonprofit from Hackathon End (no nonprofits in hackathon)") + return { + "message": "No nonprofits in hackathon" + } + + nonprofit_found = False + updated_nonprofits = [] + + for np in hackathon_data["nonprofits"]: + if np.id != nonprofitId: + updated_nonprofits.append(np) + else: + nonprofit_found = True + + if not nonprofit_found: + logger.warning(f"Remove Nonprofit from Hackathon End (nonprofit not found in hackathon)") + return { + "message": "Nonprofit not found in hackathon" + } + + hackathon_data["nonprofits"] = updated_nonprofits + hackathon_doc.set(hackathon_data, merge=True) + + clear_cache() + + logger.info(f"Remove Nonprofit from Hackathon End (nonprofit removed)") + return { + "message": "Nonprofit removed from hackathon" + } + + +@cached(cache=TTLCache(maxsize=100, ttl=20)) +@limits(calls=2000, period=ONE_MINUTE) +def get_single_hackathon_id(id): + logger.debug(f"get_single_hackathon_id start id={id}") + db = _get_db() + doc = db.collection('hackathons').document(id) + + if doc is None: + logger.warning("get_single_hackathon_id end (no results)") + return {} + else: + result = doc_to_json(docid=doc.id, doc=doc) + result["id"] = doc.id + + logger.info(f"get_single_hackathon_id end (with result):{result}") + return result + return {} + + +@cached(cache=TTLCache(maxsize=100, ttl=10)) +@limits(calls=2000, period=ONE_MINUTE) +def get_volunteer_by_event(event_id, volunteer_type, admin=False): + logger.debug(f"get {volunteer_type} start event_id={event_id}") + + if event_id is None: + logger.warning(f"get {volunteer_type} end (no results)") + return [] + + results = get_volunteer_from_db_by_event(event_id, volunteer_type, admin=admin) + + if results is None: + logger.warning(f"get {volunteer_type} end (no results)") + return [] + else: + logger.debug(f"get {volunteer_type} end (with result):{results}") + return results + + +@cached(cache=TTLCache(maxsize=100, ttl=5)) +def get_volunteer_checked_in_by_event(event_id, volunteer_type): + logger.debug(f"get {volunteer_type} start event_id={event_id}") + + if event_id is None: + logger.warning(f"get {volunteer_type} end (no results)") + return [] + + results = get_volunteer_checked_in_from_db_by_event(event_id, volunteer_type) + + if results is None: + logger.warning(f"get {volunteer_type} end (no results)") + return [] + else: + logger.debug(f"get {volunteer_type} end (with result):{results}") + return results + + +@cached(cache=TTLCache(maxsize=100, ttl=600)) +@limits(calls=2000, period=ONE_MINUTE) +def get_single_hackathon_event(hackathon_id): + logger.debug(f"get_single_hackathon_event start hackathon_id={hackathon_id}") + result = get_hackathon_by_event_id(hackathon_id) + + if result is None: + logger.warning("get_single_hackathon_event end (no results)") + return {} + else: + if "nonprofits" in result and result["nonprofits"]: + result["nonprofits"] = [doc_to_json(doc=npo, docid=npo.id) for npo in result["nonprofits"]] + else: + result["nonprofits"] = [] + if "teams" in result and result["teams"]: + result["teams"] = [doc_to_json(doc=team, docid=team.id) for team in result["teams"]] + else: + result["teams"] = [] + + logger.info(f"get_single_hackathon_event end (with result):{result}") + return result + return {} + + +@cached(cache=TTLCache(maxsize=100, ttl=3600), key=lambda is_current_only: str(is_current_only)) +@limits(calls=200, period=ONE_MINUTE) +@log_execution_time +def get_hackathon_list(is_current_only=None): + """ + Retrieve a list of hackathons based on specified criteria. + + Args: + is_current_only: Filter type - 'current', 'previous', or None for all hackathons + + Returns: + Dictionary containing list of hackathons with document references resolved + """ + logger.debug(f"Hackathon List - Getting {is_current_only or 'all'} hackathons") + db = _get_db() + + query = db.collection('hackathons') + today_str = datetime.now().strftime("%Y-%m-%d") + + if is_current_only == "current": + logger.debug(f"Querying current events (end_date >= {today_str})") + query = query.where(filter=firestore.FieldFilter("end_date", ">=", today_str)).order_by("end_date", direction=firestore.Query.ASCENDING) + + elif is_current_only == "previous": + target_date = datetime.now() + timedelta(days=-3*365) + target_date_str = target_date.strftime("%Y-%m-%d") + logger.debug(f"Querying previous events ({target_date_str} <= end_date <= {today_str})") + query = query.where("end_date", ">=", target_date_str).where("end_date", "<=", today_str) + query = query.order_by("end_date", direction=firestore.Query.DESCENDING).limit(50) + + else: + query = query.order_by("start_date") + + try: + logger.debug(f"Executing query: {query}") + docs = query.stream() + results = _process_hackathon_docs(docs) + logger.debug(f"Retrieved {len(results)} hackathon results") + return {"hackathons": results} + except Exception as e: + logger.error(f"Error retrieving hackathons: {str(e)}") + return {"hackathons": [], "error": str(e)} + + +def _process_hackathon_docs(docs): + """ + Process hackathon documents and resolve references. + """ + if not docs: + return [] + + results = [] + for doc in docs: + try: + d = doc_to_json(doc.id, doc) + + for key, value in d.items(): + if isinstance(value, list): + d[key] = [doc_to_json_recursive(item) for item in value] + elif isinstance(value, (DocumentReference, DocumentSnapshot)): + d[key] = doc_to_json_recursive(value) + + results.append(d) + except Exception as e: + logger.error(f"Error processing hackathon doc {doc.id}: {str(e)}") + + return results + + +@limits(calls=100, period=ONE_MINUTE) +def single_add_volunteer(event_id, json, volunteer_type, propel_id): + db = _get_db() + logger.info("Single Add Volunteer") + logger.info("JSON: " + str(json)) + send_slack_audit(action="single_add_volunteer", message="Adding", payload=json) + + admin_email, admin_user_id, admin_last_login, admin_profile_image, admin_name, admin_nickname = get_propel_user_details_by_id(propel_id) + + if volunteer_type not in ["mentor", "volunteer", "judge"]: + return Message( + "Error: Must be volunteer, mentor, judge" + ) + + json["volunteer_type"] = volunteer_type + json["event_id"] = event_id + name = json["name"] + + json["created_by"] = admin_name + json["created_timestamp"] = datetime.now().isoformat() + + logger.info(f"Checking to see if person {name} is already in DB for event: {event_id}") + doc = db.collection('volunteers').where("event_id", "==", event_id).where("name", "==", name).stream() + if len(list(doc)) > 0: + logger.warning("Volunteer already exists") + return Message("Volunteer already exists") + + logger.info(f"Checking to see if the event_id '{event_id}' provided exists") + doc = db.collection('hackathons').where("event_id", "==", event_id).stream() + if len(list(doc)) == 0: + logger.warning("No hackathon found") + return Message("No Hackathon Found") + + logger.info(f"Looks good! Adding to volunteers collection JSON: {json}") + doc = db.collection('volunteers').add(json) + + get_volunteer_by_event.cache_clear() + + return Message( + "Added Hackathon Volunteer" + ) + + +@limits(calls=50, period=ONE_MINUTE) +def update_hackathon_volunteers(event_id, volunteer_type, json, propel_id): + db = _get_db() + logger.info(f"update_hackathon_volunteers for event_id={event_id} propel_id={propel_id}") + logger.info("JSON: " + str(json)) + send_slack_audit(action="update_hackathon_volunteers", message="Updating", payload=json) + + if "id" not in json: + logger.error("Missing id field") + return Message("Missing id field") + + volunteer_id = json["id"] + + admin_email, admin_user_id, admin_last_login, admin_profile_image, admin_name, admin_nickname = get_propel_user_details_by_id(propel_id) + + doc_ref = db.collection("volunteers").document(volunteer_id) + doc = doc_ref.get() + doc_dict = doc.to_dict() + doc_volunteer_type = doc_dict.get("volunteer_type", "participant").lower() + + if doc_ref is None: + return Message("No volunteer for Hackathon Found") + + json["updated_by"] = admin_name + json["updated_timestamp"] = datetime.now().isoformat() + + doc_ref.update(json) + + slack_user_id = doc.get('slack_user_id') + + hackathon_welcome_message = f"🎉 Welcome <@{slack_user_id}> [{doc_volunteer_type}]." + + base_message = f"🎉 Welcome to Opportunity Hack {event_id}! You're checked in as a {doc_volunteer_type}.\n\n" + + if doc_volunteer_type == 'mentor': + role_guidance = """🧠 As a Mentor: +• Help teams with technical challenges and project direction +• Share your expertise either by staying with a specific team, looking at GitHub to find a team that matches your skills, or asking for who might need a mentor in #ask-a-mentor +• Guide teams through problem-solving without doing the work for them +• Connect with teams in their Slack channels or in-person""" + + elif doc_volunteer_type == 'judge': + role_guidance = """⚖️ As a Judge: +• Review team presentations and evaluate projects +• Focus on impact, technical implementation, and feasibility +• Provide constructive feedback during judging sessions +• Join the judges' briefing session for scoring criteria""" + + elif doc_volunteer_type == 'volunteer': + role_guidance = """🙋 As a Volunteer: +• Help with event logistics and participant support +• Assist with check-in, meals, and general questions +• Support the organizing team throughout the event +• Be a friendly face for participants who need help""" + + else: # hacker/participant + role_guidance = """💻 As a Hacker: +• Form or join a team to work on nonprofit challenges +• Collaborate with your team to build meaningful tech solutions +• Attend mentor sessions and utilize available resources +• Prepare for final presentations and judging""" + + slack_message_content = f"""{base_message}{role_guidance} + +📅 Important Links: +• Full schedule: https://www.ohack.dev/hack/{event_id}#countdown +• Slack channels: Watch #general for updates +• Need help? Ask in #help or find an organizer + +🚀 Ready to code for good? Let's build technology that makes a real difference for nonprofits and people in the world! +""" + + if json.get("checkedIn") is True and "checkedIn" not in doc_dict.keys() and doc_dict.get("checkedIn") != True: + logger.info(f"Volunteer {volunteer_id} just checked in, sending welcome message to {slack_user_id}") + invite_user_to_channel(slack_user_id, "hackathon-welcome") + + logger.info(f"Sending Slack message to volunteer {volunteer_id} in channel #{slack_user_id} and #hackathon-welcome") + async_send_slack( + channel="#hackathon-welcome", + message=hackathon_welcome_message + ) + + logger.info(f"Sending Slack DM to volunteer {volunteer_id} in channel #{slack_user_id}") + async_send_slack( + channel=slack_user_id, + message=slack_message_content + ) + else: + logger.info(f"Volunteer {volunteer_id} checked in again, no welcome message sent.") + + get_volunteer_by_event.cache_clear() + + return Message( + "Updated Hackathon Volunteers" + ) + + +from services.email_service import add_utm + + +def send_hackathon_request_email(contact_name, contact_email, request_id): + """ + Send a specialized confirmation email to someone who has submitted a hackathon request. + """ + images = [ + "https://cdn.ohack.dev/ohack.dev/2023_hackathon_1.webp", + "https://cdn.ohack.dev/ohack.dev/2023_hackathon_2.webp", + "https://cdn.ohack.dev/ohack.dev/2023_hackathon_3.webp" + ] + chosen_image = random.choice(images) + image_number = images.index(chosen_image) + 1 + image_utm_content = f"hackathon_request_image_{image_number}" + + base_url = os.getenv("FRONTEND_URL", "https://www.ohack.dev") + edit_link = f"{base_url}/hack/request/{request_id}" + + html_content = f""" + + + + + + Thank You for Your Hackathon Request + + + Opportunity Hack Event + +

    Thank You for Your Hackathon Request!

    + +

    Dear {contact_name},

    + +

    We're thrilled you're interested in hosting an Opportunity Hack event! Your request has been received and our team is reviewing it now.

    + +
    +

    Next Steps:

    +
      +
    1. A member of our team will reach out within 3-5 business days
    2. +
    3. We'll schedule a call to discuss your goals and requirements
    4. +
    5. Together, we'll create a customized hackathon plan for your community
    6. +
    +
    + +

    Need to make changes to your request?
    + You can edit your request here at any time.

    + +

    Why Host an Opportunity Hack?

    +
      +
    • Connect local nonprofits with skilled tech volunteers
    • +
    • Build lasting technology solutions for social good
    • +
    • Create meaningful community engagement opportunities
    • +
    • Develop technical skills while making a difference
    • +
    + +

    Have questions in the meantime? Feel free to reply to this email or reach out through our Slack community.

    + +

    Together, we can create positive change through technology!

    + +

    Warm regards,
    The Opportunity Hack Team

    + + + + + + """ + + if contact_name is None or contact_name == "" or contact_name == "Unassigned" or contact_name.isspace(): + contact_name = "Event Organizer" + + params = { + "from": "Opportunity Hack ", + "to": f"{contact_name} <{contact_email}>", + "cc": "questions@ohack.org", + "reply_to": "questions@ohack.org", + "subject": "Your Opportunity Hack Event Request - Next Steps", + "html": html_content, + } + + try: + email = resend.Emails.SendParams(params) + resend.Emails.send(email) + logger.info(f"Sent hackathon request confirmation email to {contact_email}") + return True + except Exception as e: + logger.error(f"Error sending hackathon request email via Resend: {str(e)}") + return False + + +def create_hackathon(json): + db = _get_db() + logger.debug("Hackathon Create") + send_slack_audit(action="create_hackathon", message="Creating", payload=json) + + doc_id = uuid.uuid1().hex + collection = db.collection('hackathon_requests') + json["created"] = datetime.now().isoformat() + json["status"] = "pending" + insert_res = collection.document(doc_id).set(json) + json["id"] = doc_id + + if "contactEmail" in json and "contactName" in json: + send_hackathon_request_email(json["contactName"], json["contactEmail"], doc_id) + + send_slack( + message=":rocket: New Hackathon Request :rocket: with json: " + str(json), channel="log-hackathon-requests", icon_emoji=":rocket:") + logger.debug(f"Insert Result: {insert_res}") + + return { + "message": "Hackathon Request Created", + "success": True, + "id": doc_id + } + + +def get_hackathon_request_by_id(doc_id): + db = _get_db() + logger.debug("Hackathon Request Get") + doc = db.collection('hackathon_requests').document(doc_id) + if doc: + doc_dict = doc.get().to_dict() + send_slack_audit(action="get_hackathon_request_by_id", message="Getting", payload=doc_dict) + return doc_dict + else: + return None + + +def update_hackathon_request(doc_id, json): + db = _get_db() + logger.debug("Hackathon Request Update") + doc = db.collection('hackathon_requests').document(doc_id) + if doc: + doc_dict = doc.get().to_dict() + send_slack_audit(action="update_hackathon_request", message="Updating", payload=doc_dict) + send_hackathon_request_email(json["contactName"], json["contactEmail"], doc_id) + doc_dict["updated"] = datetime.now().isoformat() + + doc.update(json) + return doc_dict + else: + return None + + +def get_all_hackathon_requests(): + db = _get_db() + logger.debug("Hackathon Requests List (Admin)") + collection = db.collection('hackathon_requests') + docs = collection.stream() + requests = [] + for doc in docs: + doc_dict = doc.to_dict() + doc_dict["id"] = doc.id + requests.append(doc_dict) + requests.sort(key=lambda x: x.get("created", ""), reverse=True) + return {"requests": requests} + + +def admin_update_hackathon_request(doc_id, json): + db = _get_db() + logger.debug("Hackathon Request Admin Update") + doc_ref = db.collection('hackathon_requests').document(doc_id) + doc_snapshot = doc_ref.get() + if not doc_snapshot.exists: + return None + + send_slack_audit(action="admin_update_hackathon_request", message="Admin updating", payload=json) + json["updated"] = datetime.now().isoformat() + doc_ref.update(json) + + updated_doc = doc_ref.get().to_dict() + updated_doc["id"] = doc_id + return updated_doc + + +@limits(calls=50, period=ONE_MINUTE) +def save_hackathon(json_data, propel_id): + db = _get_db() + logger.info("Hackathon Save/Update initiated") + logger.debug(json_data) + send_slack_audit(action="save_hackathon", message="Saving/Updating", payload=json_data) + + try: + validate_hackathon_data(json_data) + + doc_id = json_data.get("id") or uuid.uuid1().hex + is_update = "id" in json_data + + hackathon_data = { + "title": json_data["title"], + "description": json_data["description"], + "location": json_data["location"], + "start_date": json_data["start_date"], + "end_date": json_data["end_date"], + "type": json_data["type"], + "image_url": json_data["image_url"], + "event_id": json_data["event_id"], + "links": json_data.get("links", []), + "countdowns": json_data.get("countdowns", []), + "constraints": json_data.get("constraints", { + "max_people_per_team": 5, + "max_teams_per_problem": 10, + "min_people_per_team": 2, + }), + "donation_current": json_data.get("donation_current", { + "food": "0", + "prize": "0", + "swag": "0", + "thank_you": "", + }), + "donation_goals": json_data.get("donation_goals", { + "food": "0", + "prize": "0", + "swag": "0", + }), + "timezone": json_data.get("timezone", "America/Phoenix"), + "last_updated": firestore.SERVER_TIMESTAMP, + "last_updated_by": propel_id, + } + + if "nonprofits" in json_data: + 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"]] + + @firestore.transactional + def update_hackathon(transaction): + hackathon_ref = db.collection('hackathons').document(doc_id) + if is_update: + transaction.set(hackathon_ref, hackathon_data, merge=True) + else: + hackathon_data["created_at"] = firestore.SERVER_TIMESTAMP + hackathon_data["created_by"] = propel_id + transaction.set(hackathon_ref, hackathon_data) + + transaction = db.transaction() + update_hackathon(transaction) + + clear_cache() + + logger.info(f"Hackathon {'updated' if is_update else 'created'} successfully. ID: {doc_id}") + return Message( + "Saved Hackathon" + ) + + except ValueError as ve: + logger.error(f"Validation error: {str(ve)}") + return {"error": str(ve)}, 400 + except Exception as e: + logger.error(f"Error saving/updating hackathon: {str(e)}") + return {"error": "An unexpected error occurred"}, 500 diff --git a/services/news_service.py b/services/news_service.py new file mode 100644 index 0000000..d610f89 --- /dev/null +++ b/services/news_service.py @@ -0,0 +1,151 @@ +import os +from datetime import datetime + +import pytz +from cachetools import cached, TTLCache +from ratelimit import limits + +from common.log import get_logger +from common.utils.firebase import upsert_news, upsert_praise, get_user_by_user_id, get_recent_praises, get_praises_by_user_id +from common.utils.openai_api import generate_and_save_image_to_cdn +from common.utils.slack import get_user_info +from common.utils.firestore_helpers import doc_to_json +from db.db import get_db +from api.messages.message import Message +from firebase_admin import firestore + +logger = get_logger("news_service") + +CDN_SERVER = os.getenv("CDN_SERVER") +ONE_MINUTE = 60 + + +def save_news(json): + check_fields = ["title", "description", "slack_ts", "slack_permalink", "slack_channel"] + for field in check_fields: + if field not in json: + logger.error(f"Missing field {field} in {json}") + return Message("Missing field") + + cdn_dir = "ohack.dev/news" + news_image = generate_and_save_image_to_cdn(cdn_dir, json["title"]) + json["image"] = f"{CDN_SERVER}/{cdn_dir}/{news_image}" + json["last_updated"] = datetime.now().isoformat() + upsert_news(json) + + logger.info("Updated news successfully") + + get_news.cache_clear() + logger.info("Cleared cache for get_news") + + return Message("Saved News") + + +def save_praise(json): + check_fields = ["praise_receiver", "praise_channel", "praise_message"] + for field in check_fields: + if field not in json: + logger.error(f"Missing field {field} in {json}") + return Message("Missing field") + + logger.debug(f"Detected required fields, attempting to save praise") + json["timestamp"] = datetime.now(pytz.utc).astimezone().isoformat() + + try: + receiver_user = get_user_by_user_id(json["praise_receiver"]) + if receiver_user and "id" in receiver_user: + json["praise_receiver_ohack_id"] = receiver_user["id"] + logger.debug(f"Added praise_receiver_ohack_id: {receiver_user['id']}") + else: + logger.warning(f"Could not find ohack.dev user for praise_receiver: {json['praise_receiver']}") + json["praise_receiver_ohack_id"] = None + + sender_user = get_user_by_user_id(json["praise_sender"]) + if sender_user and "id" in sender_user: + json["praise_sender_ohack_id"] = sender_user["id"] + logger.debug(f"Added praise_sender_ohack_id: {sender_user['id']}") + else: + logger.warning(f"Could not find ohack.dev user for praise_sender: {json['praise_sender']}") + json["praise_sender_ohack_id"] = None + + except Exception as e: + logger.error(f"Error getting ohack.dev user IDs: {str(e)}") + json["praise_receiver_ohack_id"] = None + json["praise_sender_ohack_id"] = None + + logger.info(f"Attempting to save the praise with the json object {json}") + upsert_praise(json) + + logger.info("Updated praise successfully") + + get_praises_about_user.cache_clear() + logger.info("Cleared cache for get_praises_by_user_id") + + get_all_praises.cache_clear() + logger.info("Cleared cache for get_all_praises") + + return Message("Saved praise") + + +@cached(cache=TTLCache(maxsize=100, ttl=600)) +def get_all_praises(): + results = get_recent_praises() + + slack_ids = set() + for r in results: + slack_ids.add(r["praise_receiver"]) + slack_ids.add(r["praise_sender"]) + + logger.info(f"SlackIDS: {slack_ids}") + slack_user_info = get_user_info(slack_ids) + logger.info(f"Slack User Info; {slack_user_info}") + + for r in results: + r['praise_receiver_details'] = slack_user_info[r['praise_receiver']] + r['praise_sender_details'] = slack_user_info[r['praise_sender']] + + logger.info(f"Here are the 20 most recently written praises: {results}") + return Message(results) + + +@cached(cache=TTLCache(maxsize=100, ttl=600)) +def get_praises_about_user(user_id): + results = get_praises_by_user_id(user_id) + + slack_ids = set() + for r in results: + slack_ids.add(r["praise_receiver"]) + slack_ids.add(r["praise_sender"]) + logger.info(f"Slack IDs: {slack_ids}") + slack_user_info = get_user_info(slack_ids) + logger.info(f"Slack User Info: {slack_user_info}") + for r in results: + r['praise_receiver_details'] = slack_user_info[r['praise_receiver']] + r['praise_sender_details'] = slack_user_info[r['praise_sender']] + + logger.info(f"Here are all praises related to {user_id}: {results}") + return Message(results) + + +@cached(cache=TTLCache(maxsize=100, ttl=32600), key=lambda news_limit, news_id: f"{news_limit}-{news_id}") +def get_news(news_limit=3, news_id=None): + logger.debug("Get News") + db = get_db() + if news_id is not None: + logger.info(f"Getting single news item for news_id={news_id}") + collection = db.collection('news') + doc = collection.document(news_id).get() + if doc is None: + return Message({}) + else: + return Message(doc.to_dict()) + else: + collection = db.collection('news') + docs = collection.order_by("slack_ts", direction=firestore.Query.DESCENDING).limit(news_limit).stream() + results = [] + for doc in docs: + doc_json = doc.to_dict() + doc_json["id"] = doc.id + results.append(doc_json) + logger.debug(f"Get News Result: {results}") + return Message(results) diff --git a/services/nonprofits_service.py b/services/nonprofits_service.py index 279cb56..66eed18 100644 --- a/services/nonprofits_service.py +++ b/services/nonprofits_service.py @@ -1,5 +1,6 @@ from datetime import datetime import os +import uuid from ratelimit import limits import requests from db.db import delete_nonprofit, fetch_npo, fetch_npos, insert_nonprofit, update_nonprofit @@ -7,20 +8,37 @@ import pytz from cachetools import cached, LRUCache, TTLCache from cachetools.keys import hashkey +from firebase_admin import firestore from common.log import get_logger, info, debug, warning, error, exception +from common.utils.slack import send_slack_audit, send_slack +from common.utils.validators import validate_email, validate_url +from common.exceptions import InvalidInputError +from common.utils.firestore_helpers import doc_to_json, doc_to_json_recursive +from api.messages.message import Message logger = get_logger("nonprofits_service") -#TODO consts file? ONE_MINUTE = 1*60 + +def _get_db(): + from db.db import get_db + return get_db() + + +def _clear_cache(): + from services.hackathons_service import clear_cache + clear_cache() + + +# ==================== Model-based functions (existing) ==================== + @limits(calls=20, period=ONE_MINUTE) def get_npos(): debug(logger, "Get NPOs start") - + npos = fetch_npos() - - # log result + debug(logger, "Found NPO results", count=len(npos)) return npos @@ -32,14 +50,11 @@ def get_npo(id): def save_npo(d): debug(logger, "Save NPO", nonprofit=d) - n = Nonprofit() # Don't use ProblemStatement.deserialize here. We don't have an id yet. + n = Nonprofit() n.update(d) n = insert_nonprofit(n) - # send_slack_audit(action="save_npo", - # message="Saving", payload=d) - return n @limits(calls=50, period=ONE_MINUTE) @@ -54,19 +69,372 @@ def update_npo(d): if n is not None: n.update(d) - # send_slack_audit(action="update_npo", - # message="Update", payload=d) - return update_nonprofit(n) else: return None - -def delete_npo(id): + +def delete_npo(id): n: Nonprofit | None = fetch_npo(id) if n is not None: n = delete_nonprofit(id) return n - + + +# ==================== Raw-Firestore functions (from messages_service) ==================== + +@limits(calls=1000, period=ONE_MINUTE) +def get_single_npo(npo_id): + logger.debug(f"get_npo start npo_id={npo_id}") + db = _get_db() + doc = db.collection('nonprofits').document(npo_id) + + if doc is None: + logger.warning("get_npo end (no results)") + return {} + else: + result = doc_to_json(docid=doc.id, doc=doc) + + logger.info(f"get_npo end (with result):{result}") + return { + "nonprofits": result + } + return {} + + +@limits(calls=40, period=ONE_MINUTE) +def get_npos_by_hackathon_id(id): + logger.debug(f"get_npos_by_hackathon_id start id={id}") + db = _get_db() + doc = db.collection('hackathons').document(id) + + try: + doc_dict = doc.get().to_dict() + if doc_dict is None: + logger.warning("get_npos_by_hackathon_id end (no results)") + return { + "nonprofits": [] + } + + npos = [] + if "nonprofits" in doc_dict and doc_dict["nonprofits"]: + npo_refs = doc_dict["nonprofits"] + logger.info(f"get_npos_by_hackathon_id found {len(npo_refs)} nonprofit references") + + for npo_ref in npo_refs: + try: + npo_doc = npo_ref.get() + if npo_doc.exists: + npo = doc_to_json(docid=npo_doc.id, doc=npo_doc) + npos.append(npo) + except Exception as e: + logger.error(f"Error processing nonprofit reference: {e}") + continue + + return { + "nonprofits": npos + } + except Exception as e: + logger.error(f"Error in get_npos_by_hackathon_id: {e}") + return { + "nonprofits": [] + } + + +@limits(calls=40, period=ONE_MINUTE) +def get_npo_by_hackathon_id(id): + logger.debug(f"get_npo_by_hackathon_id start id={id}") + db = _get_db() + doc = db.collection('hackathons').document(id) + + if doc is None: + logger.warning("get_npo_by_hackathon_id end (no results)") + return {} + else: + result = doc_to_json(docid=doc.id, doc=doc) + + logger.info(f"get_npo_by_hackathon_id end (with result):{result}") + return result + return {} + + +@limits(calls=20, period=ONE_MINUTE) +def get_npo_list(word_length=30): + logger.debug("NPO List Start") + db = _get_db() + docs = db.collection('nonprofits').order_by( "rank" ).stream() + if docs is None: + return {[]} + else: + results = [] + for doc in docs: + logger.debug(f"Processing doc {doc.id} {doc}") + results.append(doc_to_json_recursive(doc=doc)) + + logger.debug(f"Found {len(results)} results {results}") + return { "nonprofits": results } + + +@limits(calls=100, period=ONE_MINUTE) +def save_npo_legacy(json): + """Legacy raw-Firestore save_npo from messages_service.""" + send_slack_audit(action="save_npo", message="Saving", payload=json) + db = _get_db() + logger.info("NPO Save - Starting") + + try: + required_fields = ['name', 'description', 'website', 'slack_channel'] + for field in required_fields: + if field not in json or not json[field].strip(): + raise InvalidInputError(f"Missing or empty required field: {field}") + + name = json['name'].strip() + description = json['description'].strip() + website = json['website'].strip() + slack_channel = json['slack_channel'].strip() + contact_people = json.get('contact_people', []) + contact_email = json.get('contact_email', []) + problem_statements = json.get('problem_statements', []) + image = json.get('image', '').strip() + rank = int(json.get('rank', 0)) + + contact_email = [email.strip() for email in contact_email if validate_email(email.strip())] + + if not validate_url(website): + raise InvalidInputError("Invalid website URL") + + problem_statement_refs = [ + db.collection("problem_statements").document(ps) + for ps in problem_statements + if ps.strip() + ] + + npo_data = { + "name": name, + "description": description, + "website": website, + "slack_channel": slack_channel, + "contact_people": contact_people, + "contact_email": contact_email, + "problem_statements": problem_statement_refs, + "image": image, + "rank": rank, + "created_at": firestore.SERVER_TIMESTAMP, + "updated_at": firestore.SERVER_TIMESTAMP + } + + @firestore.transactional + def save_npo_transaction(transaction): + existing_npo = db.collection('nonprofits').where("name", "==", name).limit(1).get() + if len(existing_npo) > 0: + raise InvalidInputError(f"Nonprofit with name '{name}' already exists") + + new_doc_ref = db.collection('nonprofits').document() + + transaction.set(new_doc_ref, npo_data) + + return new_doc_ref + + transaction = db.transaction() + new_npo_ref = save_npo_transaction(transaction) + + logger.info(f"NPO Save - Successfully saved nonprofit: {new_npo_ref.id}") + send_slack_audit(action="save_npo", message="Saved successfully", payload={"id": new_npo_ref.id}) + + _clear_cache() + + return Message(f"Saved NPO with ID: {new_npo_ref.id}") + + except InvalidInputError as e: + logger.error(f"NPO Save - Invalid input: {str(e)}") + return Message(f"Failed to save NPO: {str(e)}", status="error") + except Exception as e: + logger.exception("NPO Save - Unexpected error occurred") + return Message("An unexpected error occurred while saving the NPO", status="error") + + +@limits(calls=100, period=ONE_MINUTE) +def remove_npo_legacy(json): + """Legacy raw-Firestore remove_npo from messages_service.""" + logger.debug("Start NPO Delete") + doc_id = json["id"] + db = _get_db() + doc = db.collection('nonprofits').document(doc_id) + if doc: + send_slack_audit(action="remove_npo", message="Removing", payload=doc.get().to_dict()) + doc.delete() + + logger.debug("End NPO Delete") + return Message( + "Delete NPO" + ) + + +@limits(calls=20, period=ONE_MINUTE) +def update_npo_legacy(json): + """Legacy raw-Firestore update_npo from messages_service.""" + db = _get_db() + + logger.debug("NPO Edit") + send_slack_audit(action="update_npo", message="Updating", payload=json) + + doc_id = json["id"] + temp_problem_statements = json["problem_statements"] + + doc = db.collection('nonprofits').document(doc_id) + if doc: + doc_dict = doc.get().to_dict() + send_slack_audit(action="update_npo", message="Updating", payload=doc_dict) + + name = json.get("name", None) + contact_email = json.get("contact_email", None) + contact_people = json.get("contact_people", None) + slack_channel = json.get("slack_channel", None) + website = json.get("website", None) + description = json.get("description", None) + image = json.get("image", None) + rank = json.get("rank", None) + + if isinstance(contact_email, str): + contact_email = [email.strip() for email in contact_email.split(',')] + if isinstance(contact_people, str): + contact_people = [person.strip() for person in contact_people.split(',')] + + problem_statements = [] + for ps in temp_problem_statements: + problem_statements.append(db.collection("problem_statements").document(ps)) + + update_data = { + "contact_email": contact_email, + "contact_people": contact_people, + "name": name, + "slack_channel": slack_channel, + "website": website, + "description": description, + "problem_statements": problem_statements, + "image": image, + "rank": rank + } + + update_data = {k: v for k, v in update_data.items() if v is not None} + logger.debug(f"Update data: {update_data}") + + doc.update(update_data) + + logger.debug("NPO Edit - Update successful") + send_slack_audit(action="update_npo", message="Update successful", payload=update_data) + + _clear_cache() + + return Message("Updated NPO") + else: + logger.error(f"NPO Edit - Document with id {doc_id} not found") + return Message("NPO not found", status="error") + + +@limits(calls=100, period=ONE_MINUTE) +def update_npo_application(application_id, json, propel_id): + send_slack_audit(action="update_npo_application", message="Updating", payload=json) + db = _get_db() + logger.info("NPO Application Update") + doc = db.collection('project_applications').document(application_id) + if doc: + doc_dict = doc.get().to_dict() + send_slack_audit(action="update_npo_application", + message="Updating", payload=doc_dict) + doc.update(json) + + logger.info(f"Clearing cache for application_id={application_id}") + + _clear_cache() + + return Message( + "Updated NPO Application" + ) + + +@limits(calls=100, period=ONE_MINUTE) +def get_npo_applications(): + logger.info("get_npo_applications Start") + db = _get_db() + + @firestore.transactional + def get_latest_docs(transaction): + docs = db.collection('project_applications').get(transaction=transaction) + return [doc_to_json(docid=doc.id, doc=doc) for doc in docs] + + transaction = db.transaction() + results = get_latest_docs(transaction) + + if not results: + return {"applications": []} + + logger.info(results) + logger.info("get_npo_applications End") + + return {"applications": results} + + +@limits(calls=100, period=ONE_MINUTE) +def save_npo_application(json): + from services.email_service import send_nonprofit_welcome_email, google_recaptcha_key + + send_slack_audit(action="save_npo_application", message="Saving", payload=json) + db = _get_db() + logger.debug("NPO Application Save") + + token = json["token"] + recaptcha_response = requests.post( + f"https://www.google.com/recaptcha/api/siteverify?secret={google_recaptcha_key}&response={token}") + recaptcha_response_json = recaptcha_response.json() + logger.info(f"Recaptcha Response: {recaptcha_response_json}") + + if recaptcha_response_json["success"] == False: + return Message( + "Recaptcha failed" + ) + + doc_id = uuid.uuid1().hex + + name = json["name"] + email = json["email"] + organization = json["organization"] + idea = json["idea"] + isNonProfit = json["isNonProfit"] + + collection = db.collection('project_applications') + + insert_res = collection.document(doc_id).set({ + "name": name, + "email": email, + "organization": organization, + "idea": idea, + "isNonProfit": isNonProfit, + "timestamp": datetime.now().isoformat() + }) + + logger.info(f"Insert Result: {insert_res}") + + logger.info(f"Sending welcome email to {name} {email}") + + send_nonprofit_welcome_email(organization, name, email) + + logger.info(f"Sending slack message to nonprofit-form-submissions") + + slack_message = f''' +:rocket: New NPO Application :rocket: +Name: `{name}` +Email: `{email}` +Organization: `{organization}` +Idea: `{idea}` +Is Nonprofit: `{isNonProfit}` +''' + send_slack(channel="nonprofit-form-submissions", message=slack_message, icon_emoji=":rocket:") + + logger.info(f"Sent slack message to nonprofit-form-submissions") + + return Message( + "Saved NPO Application" + ) diff --git a/services/onboarding_service.py b/services/onboarding_service.py new file mode 100644 index 0000000..e4a2be1 --- /dev/null +++ b/services/onboarding_service.py @@ -0,0 +1,91 @@ +from datetime import datetime + +import pytz +from ratelimit import limits + +from common.log import get_logger, debug, error +from db.db import get_db +from api.messages.message import Message + +logger = get_logger("onboarding_service") + +ONE_MINUTE = 60 + + +@limits(calls=50, period=ONE_MINUTE) +def save_onboarding_feedback(json_data): + """ + Save or update onboarding feedback to Firestore. + + Handles the data format and implements logic to either: + 1. Create new feedback if no existing feedback is found + 2. Update existing feedback if found based on contact info or client info + """ + db = get_db() + logger.info("Processing onboarding feedback submission") + debug(logger, "Onboarding feedback data", data=json_data) + + try: + contact_info = json_data.get("contactForFollowup", {}) + client_info = json_data.get("clientInfo", {}) + + has_contact_info = ( + contact_info.get("name") and + contact_info.get("email") and + contact_info.get("name").strip() and + contact_info.get("email").strip() + ) + + existing_feedback = None + + if has_contact_info: + logger.info("Searching for existing feedback by contact info") + existing_docs = db.collection('onboarding_feedbacks').where( + "contactForFollowup.name", "==", contact_info["name"] + ).where( + "contactForFollowup.email", "==", contact_info["email"] + ).limit(1).stream() + + for doc in existing_docs: + existing_feedback = doc + logger.info(f"Found existing feedback by contact info: {doc.id}") + break + else: + logger.info("Searching for existing feedback by client info") + existing_docs = db.collection('onboarding_feedbacks').where( + "clientInfo.userAgent", "==", client_info.get("userAgent", "") + ).where( + "clientInfo.ipAddress", "==", client_info.get("ipAddress", "") + ).limit(1).stream() + + for doc in existing_docs: + existing_feedback = doc + logger.info(f"Found existing feedback by client info: {doc.id}") + break + + feedback_data = { + "overallRating": json_data.get("overallRating"), + "usefulTopics": json_data.get("usefulTopics", []), + "missingTopics": json_data.get("missingTopics", ""), + "easeOfUnderstanding": json_data.get("easeOfUnderstanding", ""), + "improvements": json_data.get("improvements", ""), + "additionalFeedback": json_data.get("additionalFeedback", ""), + "contactForFollowup": contact_info, + "clientInfo": client_info, + "timestamp": datetime.now(pytz.utc) + } + + if existing_feedback: + logger.info(f"Updating existing feedback: {existing_feedback.id}") + existing_feedback.reference.update(feedback_data) + message = "Onboarding feedback updated successfully" + else: + logger.info("Creating new onboarding feedback") + db.collection('onboarding_feedbacks').add(feedback_data) + message = "Onboarding feedback submitted successfully" + + return Message(message) + + except Exception as e: + error(logger, f"Error saving onboarding feedback: {str(e)}", exc_info=True) + return Message("Failed to save onboarding feedback", status="error") diff --git a/services/teams_service.py b/services/teams_service.py new file mode 100644 index 0000000..3c85bb0 --- /dev/null +++ b/services/teams_service.py @@ -0,0 +1,479 @@ +import uuid +from datetime import datetime + +from cachetools import cached, TTLCache +from ratelimit import limits +from firebase_admin import firestore + +from common.log import get_logger +from common.utils.firestore_helpers import doc_to_json, log_execution_time +from common.utils.slack import send_slack_audit, send_slack, create_slack_channel, invite_user_to_channel +from common.utils.github import create_github_repo, validate_github_username, get_all_repos +from common.utils.firebase import get_hackathon_by_event_id +from common.utils.oauth_providers import extract_slack_user_id +from db.db import get_db, get_user_doc_reference +from services.users_service import ( + get_propel_user_details_by_id, + get_slack_user_from_propel_user_id, + get_user_from_slack_id, +) +from services.nonprofits_service import get_single_npo +from api.messages.message import Message + +logger = get_logger("teams_service") + +ONE_MINUTE = 60 +THIRTY_SECONDS = 30 + + +def _clear_cache(): + from services.hackathons_service import clear_cache + clear_cache() + + +@limits(calls=2000, period=THIRTY_SECONDS) +def get_teams_list(id=None): + logger.debug(f"Teams List Start team_id={id}") + db = get_db() + if id is not None: + logger.debug(f"Teams List team_id={id} | Start") + doc = db.collection('teams').document(id).get() + if doc is None: + return {} + else: + logger.info(f"Teams List team_id={id} | End (with result):{doc_to_json(docid=doc.id, doc=doc)}") + logger.debug(f"Teams List team_id={id} | End") + return doc_to_json(docid=doc.id, doc=doc) + else: + logger.debug("Teams List | Start") + docs = db.collection('teams').stream() + if docs is None: + logger.debug("Teams List | End (no results)") + return {[]} + else: + results = [] + for doc in docs: + results.append(doc_to_json(docid=doc.id, doc=doc)) + + logger.debug(f"Found {len(results)} results {results}") + return { "teams": results } + + +@limits(calls=2000, period=THIRTY_SECONDS) +@cached(cache=TTLCache(maxsize=100, ttl=600), key=lambda id: id) +@log_execution_time +def get_team(id): + if id is None: + logger.warning("get_team called with None id") + return {"team": {}} + + logger.debug(f"Fetching team with id={id}") + + db = get_db() + doc_ref = db.collection('teams').document(id) + + try: + doc = doc_ref.get() + if not doc.exists: + logger.info(f"Team with id={id} not found") + return {} + + team_data = doc_to_json(docid=doc.id, doc=doc) + logger.info(f"Successfully retrieved team with id={id}") + return { + "team" : team_data + } + + except Exception as e: + logger.error(f"Error retrieving team with id={id}: {str(e)}") + return {} + + finally: + logger.debug(f"get_team operation completed for id={id}") + + +def get_teams_by_event_id(event_id): + """Get teams for a specific hackathon event (for admin judging assignment)""" + logger.debug(f"Getting teams for event_id={event_id}") + db = get_db() + + try: + docs = db.collection('teams').where('hackathon_event_id', '==', event_id).stream() + + results = [] + for doc in docs: + team_data = doc_to_json(docid=doc.id, doc=doc) + + formatted_team = { + "id": team_data.get("id"), + "name": team_data.get("name", ""), + "members": team_data.get("members", []), + "problem_statement": { + "title": team_data.get("problem_statement", {}).get("title", ""), + "nonprofit": team_data.get("problem_statement", {}).get("nonprofit", "") + } + } + results.append(formatted_team) + + logger.debug(f"Found {len(results)} teams for event {event_id}") + return {"teams": results} + + except Exception as e: + logger.error(f"Error fetching teams for event {event_id}: {str(e)}") + return {"teams": [], "error": "Failed to fetch teams"} + + +def get_teams_batch(json): + if "team_ids" not in json: + logger.error("get_teams_batch called without team_ids in json") + return [] + team_ids = json["team_ids"] + logger.debug(f"get_teams_batch start team_ids={team_ids}") + db = get_db() + if not team_ids: + logger.warning("get_teams_batch end (no team_ids provided)") + return [] + try: + docs = db.collection('teams').where( + '__name__', 'in', [db.collection('teams').document(team_id) for team_id in team_ids]).stream() + + results = [] + for doc in docs: + team_data = doc_to_json(docid=doc.id, doc=doc) + results.append(team_data) + + logger.debug(f"get_teams_batch end (with {len(results)} results)") + return results + except Exception as e: + logger.error(f"Error in get_teams_batch: {str(e)}") + import traceback + traceback.print_exc() + return [] + + +def save_team(propel_user_id, json): + send_slack_audit(action="save_team", message="Saving", payload=json) + + email, user_id, last_login, profile_image, name, nickname = get_propel_user_details_by_id(propel_user_id) + slack_user_id = user_id + + root_slack_user_id = extract_slack_user_id(slack_user_id) + user = get_user_doc_reference(root_slack_user_id) + + db = get_db() + logger.debug("Team Save") + + logger.debug(json) + doc_id = uuid.uuid1().hex + + team_name = json["name"] + + + + slack_channel = json["slackChannel"] + + hackathon_event_id = json["eventId"] + problem_statement_id = json["problemStatementId"] if "problemStatementId" in json else None + nonprofit_id = json["nonprofitId"] if "nonprofitId" in json else None + + github_username = json["githubUsername"] + if validate_github_username(github_username) == False: + return { + "message": "Error: Invalid GitHub Username - don't give us your email, just your username without the @ symbol." + } + + + nonprofit = None + nonprofit_name = "" + if nonprofit_id is not None: + logger.info(f"Nonprofit ID provided {nonprofit_id}") + nonprofit = get_single_npo(nonprofit_id)["nonprofits"] + nonprofit_name = nonprofit["name"] + logger.info(f"Nonprofit {nonprofit}") + if "problem_statements" in nonprofit and len(nonprofit["problem_statements"]) > 0: + problem_statement_id = nonprofit["problem_statements"][0] + logger.info(f"Problem Statement ID {problem_statement_id}") + else: + return { + "message": "Error: Nonprofit does not have any problem statements" + } + + + problem_statement = None + if problem_statement_id is not None: + problem_statement = get_problem_statement_from_id_old(problem_statement_id) + logger.info(f"Problem Statement {problem_statement}") + + if nonprofit is None and problem_statement is None: + return "Error: Please provide either a Nonprofit or a Problem Statement" + + + team_slack_channel = slack_channel + raw_problem_statement_title = problem_statement.get().to_dict()["title"] + + problem_statement_title = raw_problem_statement_title.replace(" ", "").replace("-", "") + logger.info(f"Problem Statement Title: {problem_statement_title}") + + nonprofit_title = nonprofit_name.replace(" ", "").replace("-", "") + nonprofit_title = nonprofit_title[:20] + logger.info(f"Nonprofit Title: {nonprofit_title}") + + repository_name = f"{team_name}-{nonprofit_title}-{problem_statement_title}" + logger.info(f"Repository Name: {repository_name}") + + repository_name = repository_name[:100] + logger.info(f"Truncated Repository Name: {repository_name}") + + slack_name_of_creator = name + + nonprofit_url = f"https://ohack.dev/nonprofit/{nonprofit_id}" + project_url = f"https://ohack.dev/project/{problem_statement_id}" + try: + logger.info(f"Creating github repo {repository_name} for {json}") + repo = create_github_repo(repository_name, hackathon_event_id, slack_name_of_creator, team_name, team_slack_channel, problem_statement_id, raw_problem_statement_title, github_username, nonprofit_name, nonprofit_id) + except ValueError as e: + return { + "message": f"Error: {e}" + } + logger.info(f"Created github repo {repo} for {json}") + + logger.info(f"Creating slack channel {slack_channel}") + create_slack_channel(slack_channel) + + logger.info(f"Inviting user {slack_user_id} to slack channel {slack_channel}") + invite_user_to_channel(slack_user_id, slack_channel) + + slack_admins = ["UC31XTRT5", "UCQKX6LPR", "U035023T81Z", "UC31XTRT5", "UC2JW3T3K", "UPD90QV17", "U05PYC0LMHR"] + for admin in slack_admins: + logger.info(f"Inviting admin {admin} to slack channel {slack_channel}") + invite_user_to_channel(admin, slack_channel) + + slack_message = f''' +:rocket: Team *{team_name}* is ready for launch! :tada: + +*Channel:* #{team_slack_channel} +*Nonprofit:* <{nonprofit_url}|{nonprofit_name}> +*Project:* <{project_url}|{raw_problem_statement_title}> +*Created by:* <@{root_slack_user_id}> (add your other team members here) + +:github_parrot: *GitHub Repository:* {repo['full_url']} +All code goes here! Remember, we're building for the public good (MIT license). + +:question: *Need help?* +Join <#C01E5CGDQ74> or <#C07KYG3CECX> for questions and updates. + +:clipboard: *Next Steps:* +1. Add team to GitHub repo: +2. Create DevPost project: +3. Submit to +4. Study your nonprofit slides and software requirements doc and chat with mentors +5. Code, collaborate, and create! +6. Share your progress on the socials: `#ohack2024` @opportunityhack +7. +8. Post-hack: Update LinkedIn with your amazing experience! +9. Update for a chance to win prizes! +10. Follow the schedule at + + +Let's make a difference! :muscle: :heart: +''' + send_slack(slack_message, slack_channel) + send_slack(slack_message, "log-team-creation") + + repo_name = repo["repo_name"] + full_github_repo_url = repo["full_url"] + + my_date = datetime.now() + collection = db.collection('teams') + insert_res = collection.document(doc_id).set({ + "team_number" : -1, + "users": [user], + "problem_statements": [problem_statement], + "name": team_name, + "slack_channel": slack_channel, + "created": my_date.isoformat(), + "active": "True", + "github_links": [ + { + "link": full_github_repo_url, + "name": repo_name + } + ] + }) + + logger.debug(f"Insert Result: {insert_res}") + + new_team_doc = db.collection('teams').document(doc_id) + user_doc = user.get() + user_dict = user_doc.to_dict() + user_teams = user_dict["teams"] + user_teams.append(new_team_doc) + user.set({ + "teams": user_teams + }, merge=True) + + hackathon_db_id = get_hackathon_by_event_id(hackathon_event_id)["id"] + event_collection = db.collection("hackathons").document(hackathon_db_id) + event_collection_dict = event_collection.get().to_dict() + + new_teams = [] + for t in event_collection_dict["teams"]: + new_teams.append(t) + new_teams.append(new_team_doc) + + event_collection.set({ + "teams" : new_teams + }, merge=True) + + logger.info(f"Clearing cache for event_id={hackathon_db_id} problem_statement_id={problem_statement_id} user_doc.id={user_doc.id} doc_id={doc_id}") + _clear_cache() + + team = get_teams_list(doc_id) + + + return { + "message" : f"Saved Team and GitHub repo created. See your Slack channel --> #{slack_channel} for more details.", + "success" : True, + "team": team, + "user": { + "name" : user_dict["name"], + "profile_image": user_dict["profile_image"], + } + } + + +def get_github_repos(event_id): + logger.info(f"Get Github Repos for event_id={event_id}") + hackathon = get_hackathon_by_event_id(event_id) + if hackathon is None: + logger.warning(f"Get Github Repos End (no results)") + return {} + else: + org_name = hackathon["github_org"] + return get_all_repos(org_name) + + +def join_team(propel_user_id, json): + logger.info(f"Join Team UserId: {propel_user_id} Json: {json}") + team_id = json["teamId"] + + db = get_db() + + slack_user = get_slack_user_from_propel_user_id(propel_user_id) + userid = get_user_from_slack_id(slack_user["sub"]).id + + team_ref = db.collection('teams').document(team_id) + user_ref = db.collection('users').document(userid) + + @firestore.transactional + def update_team_and_user(transaction): + team_doc = team_ref.get(transaction=transaction) + user_doc = user_ref.get(transaction=transaction) + + if not team_doc.exists: + raise ValueError("Team not found") + if not user_doc.exists: + raise ValueError("User not found") + + team_data = team_doc.to_dict() + user_data = user_doc.to_dict() + + team_users = team_data.get("users", []) + user_teams = user_data.get("teams", []) + + if user_ref in team_users: + logger.warning(f"User {userid} is already in team {team_id}") + return False, None + + new_team_users = list(set(team_users + [user_ref])) + new_user_teams = list(set(user_teams + [team_ref])) + + transaction.update(team_ref, {"users": new_team_users}) + transaction.update(user_ref, {"teams": new_user_teams}) + + logger.debug(f"User {userid} added to team {team_id}") + return True, team_data.get("slack_channel") + + try: + transaction = db.transaction() + success, team_slack_channel = update_team_and_user(transaction) + if success: + send_slack_audit(action="join_team", message="Added", payload=json) + message = "Joined Team" + if team_slack_channel: + invite_user_to_channel(userid, team_slack_channel) + else: + message = "User was already in the team" + except Exception as e: + logger.error(f"Error in join_team: {str(e)}") + return Message(f"Error: {str(e)}") + + _clear_cache() + + logger.debug("Join Team End") + return Message(message) + + +def unjoin_team(propel_user_id, json): + logger.info(f"Unjoin for UserId: {propel_user_id} Json: {json}") + team_id = json["teamId"] + + db = get_db() + + slack_user = get_slack_user_from_propel_user_id(propel_user_id) + userid = get_user_from_slack_id(slack_user["sub"]).id + + team_ref = db.collection('teams').document(team_id) + user_ref = db.collection('users').document(userid) + + @firestore.transactional + def update_team_and_user(transaction): + team_doc = team_ref.get(transaction=transaction) + user_doc = user_ref.get(transaction=transaction) + + if not team_doc.exists: + raise ValueError("Team not found") + if not user_doc.exists: + raise ValueError("User not found") + + team_data = team_doc.to_dict() + user_data = user_doc.to_dict() + + user_list = team_data.get("users", []) + user_teams = user_data.get("teams", []) + + if user_ref not in user_list: + logger.warning(f"User {userid} not found in team {team_id}") + return False + + new_user_list = [u for u in user_list if u.id != userid] + new_user_teams = [t for t in user_teams if t.id != team_id] + + transaction.update(team_ref, {"users": new_user_list}) + transaction.update(user_ref, {"teams": new_user_teams}) + + logger.debug(f"User {userid} removed from team {team_id}") + return True + + try: + transaction = db.transaction() + success = update_team_and_user(transaction) + if success: + send_slack_audit(action="unjoin_team", message="Removed", payload=json) + message = "Removed from Team" + else: + message = "User was not in the team" + except Exception as e: + logger.error(f"Error in unjoin_team: {str(e)}") + return Message(f"Error: {str(e)}") + + _clear_cache() + + logger.debug("Unjoin Team End") + return Message(message) + + +def get_problem_statement_from_id_old(problem_id): + """Lazy import to avoid circular dependency with messages_service.""" + from api.messages.messages_service import get_problem_statement_from_id_old as _get_ps + return _get_ps(problem_id) diff --git a/services/volunteers_service.py b/services/volunteers_service.py index a8089fc..6ea4eb4 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -8,7 +8,7 @@ from google.cloud import firestore from common.utils.slack import get_slack_user_by_email, send_slack from common.log import get_logger, info, debug, warning, error, exception -from common.utils.redis_cache import redis_cached, delete_cached, clear_pattern +from common.utils.redis_cache import redis_cached, delete_cached, clear_pattern, get_cached, set_cached from common.utils.oauth_providers import SLACK_PREFIX, normalize_slack_user_id, is_oauth_user_id, is_slack_user_id, extract_slack_user_id import os import requests @@ -1791,6 +1791,10 @@ def send_volunteer_message( # Determine success based on whether at least one message was sent success = delivery_status['slack_sent'] or delivery_status['email_sent'] + # Invalidate Resend email list cache so next fetch picks up the new email + if delivery_status['email_sent']: + delete_cached("resend:all_emails_index") + result = { 'success': success, 'volunteer_id': volunteer_id, @@ -1831,7 +1835,9 @@ def send_email_to_address( admin_user: Any = None, recipient_type: str = 'volunteer', name: Optional[str] = None, - volunteer_id: Optional[str] = None + volunteer_id: Optional[str] = None, + collection_name: Optional[str] = None, + document_id: Optional[str] = None ) -> Dict[str, Any]: """ Send an email to a specific email address using the same template as send_volunteer_message. @@ -1906,6 +1912,36 @@ def send_email_to_address( volunteer_id=volunteer_id, resend_email_id=resend_email_id, exc_info=tracking_error) + # Track email on any Firestore collection/document (generic tracking) + if collection_name and document_id and email_success: + try: + from db.db import get_db + db = get_db() + email_subject = f"{subject} - Message from Opportunity Hack Team" + + sent_email_record = { + 'resend_id': resend_email_id, + 'subject': email_subject, + 'timestamp': _get_current_timestamp(), + 'sent_by': admin_full_name, + 'recipient_type': recipient_type, + } + + doc_ref = db.collection(collection_name).document(document_id) + doc_ref.update({ + 'sent_emails': firestore.ArrayUnion([sent_email_record]), + 'last_email_timestamp': _get_current_timestamp(), + }) + + info(logger, "Updated document with sent_emails tracking", + collection=collection_name, document_id=document_id, + resend_email_id=resend_email_id) + + except Exception as tracking_error: + error(logger, "Failed to update document with sent_emails tracking", + collection=collection_name, document_id=document_id, + resend_email_id=resend_email_id, exc_info=tracking_error) + # Enhanced Slack audit message from common.utils.slack import send_slack_audit message_preview = message[:100] + "..." if len(message) > 100 else message @@ -1925,6 +1961,10 @@ def send_email_to_address( } ) + # Invalidate Resend email list cache so next fetch picks up the new email + if email_success: + delete_cached("resend:all_emails_index") + result = { 'success': email_success, 'recipient_email': email, @@ -1989,3 +2029,147 @@ def get_resend_email_statuses(email_ids: list) -> Dict[str, Any]: except Exception as e: error(logger, "Error fetching Resend email statuses", exc_info=e) return {'success': False, 'error': str(e)} + + +def list_all_resend_emails(filter_emails=None): + """ + Fetch all sent emails from Resend's List Emails API, cached in Redis. + Returns an index of {recipient_email: [{id, subject, created_at, last_event}, ...]}. + + Args: + filter_emails: Optional list of email addresses to filter results for. + + Returns: + Dict with 'success', 'emails_by_recipient', and 'total_fetched'. + """ + try: + resend.api_key = os.environ.get('RESEND_EMAIL_STATUS_KEY') + if not resend.api_key: + return {'success': False, 'error': 'Resend API key not configured'} + + cache_key = "resend:all_emails_index" + cached = get_cached(cache_key) + if cached is not None: + info(logger, "Using cached Resend email index", + total_emails=cached.get('total_fetched', 0)) + index = cached['emails_by_recipient'] + if filter_emails: + filter_set = {e.lower() for e in filter_emails} + index = {k: v for k, v in index.items() if k in filter_set} + return { + 'success': True, + 'emails_by_recipient': index, + 'total_fetched': cached['total_fetched'], + 'truncated': cached.get('truncated', False), + 'from_cache': True + } + + # Paginate through resend.Emails.list() (requires resend >= 2.5) + # Safety cap of 100 pages (~10,000 emails); for-else detects if we hit it. + all_emails = [] + max_pages = 100 + params = {"limit": 100} + page_error_occurred = False + truncated = False + + for page in range(max_pages): + try: + response = resend.Emails.list(params) + email_list = response.get('data', []) if isinstance(response, dict) else getattr(response, 'data', []) + all_emails.extend(email_list) + info(logger, "Fetched Resend emails page", + page=page, count=len(email_list), total_so_far=len(all_emails)) + + has_more = response.get('has_more', False) if isinstance(response, dict) else getattr(response, 'has_more', False) + if not has_more or len(email_list) == 0: + break + + # Use last email ID as cursor for next page + last_item = email_list[-1] + last_id = last_item.get('id', '') if isinstance(last_item, dict) else getattr(last_item, 'id', '') + if not last_id: + break + params = {"limit": 100, "after": last_id} + + except Exception as page_error: + warning(logger, "Error fetching Resend emails page", + page=page, exc_info=page_error) + page_error_occurred = True + break + else: + # Loop exhausted max_pages without a natural break — results are truncated. + truncated = True + warning(logger, "Resend email pagination hit safety cap", + max_pages=max_pages, total_so_far=len(all_emails)) + + # If a page fetch failed, return an error rather than caching partial data. + if page_error_occurred: + return { + 'success': False, + 'error': 'Failed to fetch all email pages from Resend', + 'emails_by_recipient': {}, + 'total_fetched': len(all_emails) + } + + # Build index by recipient email + index = {} + for email_data in all_emails: + if isinstance(email_data, dict): + recipients = email_data.get('to', []) + email_id = email_data.get('id', '') + subject = email_data.get('subject', '') + created_at = email_data.get('created_at', '') + last_event = email_data.get('last_event', '') + else: + recipients = getattr(email_data, 'to', []) + email_id = getattr(email_data, 'id', '') + subject = getattr(email_data, 'subject', '') + created_at = getattr(email_data, 'created_at', '') + last_event = getattr(email_data, 'last_event', '') + + if isinstance(recipients, str): + recipients = [recipients] + + entry = { + 'id': email_id, + 'subject': subject, + 'created_at': created_at, + 'last_event': last_event, + } + + for recipient in recipients: + key = recipient.lower().strip() + if key not in index: + index[key] = [] + index[key].append(entry) + + total_fetched = len(all_emails) + + info(logger, "Built Resend email index", + total_fetched=total_fetched, unique_recipients=len(index), + truncated=truncated) + + # Cache the full index with 300s TTL, including empty results so we + # don't hammer the Resend API on every request when there are no emails. + set_cached(cache_key, { + 'emails_by_recipient': index, + 'total_fetched': total_fetched, + 'truncated': truncated + }, ttl=300) + + # Filter if requested + if filter_emails: + filter_set = {e.lower() for e in filter_emails} + index = {k: v for k, v in index.items() if k in filter_set} + + return { + 'success': True, + 'emails_by_recipient': index, + 'total_fetched': total_fetched, + 'truncated': truncated, + 'from_cache': False + } + + except Exception as e: + error(logger, "Error listing Resend emails", exc_info=e) + return {'success': False, 'error': str(e)}