From 4a2b1e1c2cdc38d206d1c42c68b73119555c9442 Mon Sep 17 00:00:00 2001 From: Greg V Date: Sun, 22 Feb 2026 10:46:07 -0700 Subject: [PATCH 01/14] Add Resend List Emails API for recipient-based email status lookup Adds a new endpoint GET /api/admin/emails/resend-list that fetches all sent emails from Resend's List Emails API, indexes them by recipient email address, and caches the result in Redis (300s TTL). This removes the dependency on stored resend_id values for showing delivery status. Also invalidates the cache after sending new emails. Co-Authored-By: Claude Opus 4.6 --- api/volunteers/volunteers_views.py | 25 ++++++ services/volunteers_service.py | 136 ++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/api/volunteers/volunteers_views.py b/api/volunteers/volunteers_views.py index 163b44e..a2a8cfe 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 @@ -656,3 +657,27 @@ 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=['GET']) +@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: + emails_param = request.args.get('emails', '') + filter_emails = [e.strip() for e in emails_param.split(',') if e.strip()] if emails_param else 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/services/volunteers_service.py b/services/volunteers_service.py index a8089fc..5a7b734 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, @@ -1925,6 +1929,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 +1997,129 @@ 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'], + 'from_cache': True + } + + # Paginate through Resend Emails.list() + all_emails = [] + max_pages = 10 + + if not hasattr(resend.Emails, 'list'): + error(logger, "resend.Emails.list is not available in this SDK version") + return {'success': False, 'error': 'resend.Emails.list not available'} + + for page in range(max_pages): + try: + # Resend SDK list() returns a ListEmailsResponse + # The response has a 'data' attribute with the email list + response = resend.Emails.list() + email_list = [] + if isinstance(response, dict): + email_list = response.get('data', []) + elif hasattr(response, 'data'): + email_list = response.data if isinstance(response.data, list) else [] + else: + email_list = list(response) if response else [] + + all_emails.extend(email_list) + info(logger, "Fetched Resend emails page", + page=page, count=len(email_list), total_so_far=len(all_emails)) + + # Resend list API returns all emails in one call (no pagination params) + # so we only need one iteration + break + + except Exception as page_error: + warning(logger, "Error fetching Resend emails page", + page=page, exc_info=page_error) + break + + # 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) + + # Cache the full index with 300s TTL + set_cached(cache_key, { + 'emails_by_recipient': index, + 'total_fetched': total_fetched + }, ttl=300) + + info(logger, "Built Resend email index", + total_fetched=total_fetched, unique_recipients=len(index)) + + # 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, + 'from_cache': False + } + + except Exception as e: + error(logger, "Error listing Resend emails", exc_info=e) + return {'success': False, 'error': str(e)} From de319b59a9c9442c2e89a115d65fb90d624290e6 Mon Sep 17 00:00:00 2001 From: Greg V Date: Mon, 23 Feb 2026 08:11:17 -0700 Subject: [PATCH 02/14] Upgrade resend SDK to 2.22.0 and use Emails.list() natively The installed resend 2.3.0 didn't have Emails.list(). Upgraded to 2.22.0 which supports list() with pagination (limit, after cursor, has_more). Co-Authored-By: Claude Opus 4.6 --- requirements.txt | 2 +- services/volunteers_service.py | 33 ++++++++++++++------------------- 2 files changed, 15 insertions(+), 20 deletions(-) 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/volunteers_service.py b/services/volunteers_service.py index 5a7b734..32bfae8 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -2031,34 +2031,29 @@ def list_all_resend_emails(filter_emails=None): 'from_cache': True } - # Paginate through Resend Emails.list() + # Paginate through resend.Emails.list() (requires resend >= 2.5) all_emails = [] max_pages = 10 - - if not hasattr(resend.Emails, 'list'): - error(logger, "resend.Emails.list is not available in this SDK version") - return {'success': False, 'error': 'resend.Emails.list not available'} + params = {"limit": 100} for page in range(max_pages): try: - # Resend SDK list() returns a ListEmailsResponse - # The response has a 'data' attribute with the email list - response = resend.Emails.list() - email_list = [] - if isinstance(response, dict): - email_list = response.get('data', []) - elif hasattr(response, 'data'): - email_list = response.data if isinstance(response.data, list) else [] - else: - email_list = list(response) if response else [] - + 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)) - # Resend list API returns all emails in one call (no pagination params) - # so we only need one iteration - break + 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", From 7464ee6866b428ffe8e377f35bbfdde4b4967d8f Mon Sep 17 00:00:00 2001 From: Greg V Date: Tue, 24 Feb 2026 09:16:32 -0700 Subject: [PATCH 03/14] Skip caching empty Resend email results Prevents stale empty results from being served when a previous fetch failed (e.g., due to old SDK version). Co-Authored-By: Claude Opus 4.6 --- services/volunteers_service.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/services/volunteers_service.py b/services/volunteers_service.py index 32bfae8..ed5e3d0 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -2095,10 +2095,12 @@ def list_all_resend_emails(filter_emails=None): total_fetched = len(all_emails) # Cache the full index with 300s TTL - set_cached(cache_key, { - 'emails_by_recipient': index, - 'total_fetched': total_fetched - }, ttl=300) + # Only cache if we actually fetched emails (avoid caching errors/empty results) + if total_fetched > 0: + set_cached(cache_key, { + 'emails_by_recipient': index, + 'total_fetched': total_fetched + }, ttl=300) info(logger, "Built Resend email index", total_fetched=total_fetched, unique_recipients=len(index)) From dc73cfdde907c805e03c4e6b3364df0e96261c59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 05:03:59 +0000 Subject: [PATCH 04/14] Initial plan From 5cd313a49f2c745c36165599b5ea06619783fcb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 05:05:37 +0000 Subject: [PATCH 05/14] Initial plan From 3f38d5ec4dbafdd5501a0c1b1493ea5f6a5106e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 05:07:01 +0000 Subject: [PATCH 06/14] Convert resend-list endpoint from GET to POST with JSON body to avoid URL length limits Co-authored-by: gregv <6913307+gregv@users.noreply.github.com> --- api/volunteers/volunteers_views.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/api/volunteers/volunteers_views.py b/api/volunteers/volunteers_views.py index a2a8cfe..8c8fc8d 100644 --- a/api/volunteers/volunteers_views.py +++ b/api/volunteers/volunteers_views.py @@ -659,13 +659,21 @@ def admin_get_resend_email_statuses(): return _error_response(f"Failed to fetch email statuses: {str(e)}") -@bp.route('/admin/emails/resend-list', methods=['GET']) +@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: - emails_param = request.args.get('emails', '') - filter_emails = [e.strip() for e in emails_param.split(',') if e.strip()] if emails_param else None + 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) From 39e1f698ab62d76dd62054973eb101dcbe94469e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 05:07:34 +0000 Subject: [PATCH 07/14] Add .github/copilot-instructions.md with repository guidance for GitHub Copilot Co-authored-by: gregv <6913307+gregv@users.noreply.github.com> --- .github/copilot-instructions.md | 108 ++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 .github/copilot-instructions.md 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 From 619b6617df6da2a4139548206ae3afc612162694 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 05:08:12 +0000 Subject: [PATCH 08/14] Initial plan From eb496d6fcabbe6fd05549ea7e6d1b316f495e7cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 05:11:56 +0000 Subject: [PATCH 09/14] Fix list_all_resend_emails: pagination cap, error handling, empty-result caching Co-authored-by: gregv <6913307+gregv@users.noreply.github.com> --- services/volunteers_service.py | 41 +++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/services/volunteers_service.py b/services/volunteers_service.py index ed5e3d0..cd1a923 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -2028,13 +2028,17 @@ def list_all_resend_emails(filter_emails=None): '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 = 10 + max_pages = 100 params = {"limit": 100} + page_error_occurred = False + truncated = False for page in range(max_pages): try: @@ -2058,7 +2062,22 @@ def list_all_resend_emails(filter_emails=None): 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 = {} @@ -2094,16 +2113,17 @@ def list_all_resend_emails(filter_emails=None): total_fetched = len(all_emails) - # Cache the full index with 300s TTL - # Only cache if we actually fetched emails (avoid caching errors/empty results) - if total_fetched > 0: - set_cached(cache_key, { - 'emails_by_recipient': index, - 'total_fetched': total_fetched - }, ttl=300) - info(logger, "Built Resend email index", - total_fetched=total_fetched, unique_recipients=len(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: @@ -2114,6 +2134,7 @@ def list_all_resend_emails(filter_emails=None): 'success': True, 'emails_by_recipient': index, 'total_fetched': total_fetched, + 'truncated': truncated, 'from_cache': False } From 5bf9df5d2b3f2ad7486bcef68ea39787b0ad3462 Mon Sep 17 00:00:00 2001 From: Greg V Date: Thu, 5 Mar 2026 21:50:35 -0800 Subject: [PATCH 10/14] Add admin endpoints for hackathon request management Add protected GET/PATCH endpoints at /api/messages/admin/hackathon-requests for listing and updating hackathon requests with volunteer.admin permission. Includes service functions and tests (13 tests). Co-Authored-By: Claude Opus 4.6 --- api/messages/messages_service.py | 32 ++ api/messages/messages_views.py | 19 + api/messages/tests/test_hackathon_requests.py | 340 ++++++++++++++++++ 3 files changed, 391 insertions(+) create mode 100644 api/messages/tests/test_hackathon_requests.py diff --git a/api/messages/messages_service.py b/api/messages/messages_service.py index c1f8496..7639be3 100644 --- a/api/messages/messages_service.py +++ b/api/messages/messages_service.py @@ -1642,6 +1642,38 @@ def update_hackathon_request(doc_id, json): 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) + # Sort by created date descending (newest first) + 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): diff --git a/api/messages/messages_views.py b/api/messages/messages_views.py index 3cddeb0..8ed35ec 100644 --- a/api/messages/messages_views.py +++ b/api/messages/messages_views.py @@ -669,6 +669,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 api.messages.messages_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 api.messages.messages_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_hackathon_requests.py b/api/messages/tests/test_hackathon_requests.py new file mode 100644 index 0000000..96b0066 --- /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 messages_service. +""" +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime +from api.messages.messages_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('api.messages.messages_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('api.messages.messages_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('api.messages.messages_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('api.messages.messages_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('api.messages.messages_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('api.messages.messages_service.send_slack_audit') + @patch('api.messages.messages_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('api.messages.messages_service.send_slack_audit') + @patch('api.messages.messages_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('api.messages.messages_service.send_slack_audit') + @patch('api.messages.messages_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('api.messages.messages_service.send_slack_audit') + @patch('api.messages.messages_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('api.messages.messages_service.send_slack_audit') + @patch('api.messages.messages_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('api.messages.messages_service.send_slack') + @patch('api.messages.messages_service.send_hackathon_request_email') + @patch('api.messages.messages_service.send_slack_audit') + @patch('api.messages.messages_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('api.messages.messages_service.send_slack') + @patch('api.messages.messages_service.send_hackathon_request_email') + @patch('api.messages.messages_service.send_slack_audit') + @patch('api.messages.messages_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('api.messages.messages_service.send_slack') + @patch('api.messages.messages_service.send_slack_audit') + @patch('api.messages.messages_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('api.messages.messages_service.send_hackathon_request_email') as mock_email: + create_hackathon(payload) + mock_email.assert_not_called() From dfd1f5f57beaf145786bd861015ba2d7f383b0ca Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 6 Mar 2026 08:21:00 -0700 Subject: [PATCH 11/14] Add admin contact submissions endpoints and generic email tracking Extend send_email_to_address with collection_name/document_id params to track sent emails on any Firestore collection, not just volunteers. Add admin endpoints for listing and updating contact form submissions. Co-Authored-By: Claude Opus 4.6 --- api/contact/contact_service.py | 49 ++++++++++++++++++++++++++++++ api/contact/contact_views.py | 45 +++++++++++++++++++++++++-- api/volunteers/volunteers_views.py | 6 +++- services/volunteers_service.py | 34 ++++++++++++++++++++- 4 files changed, 130 insertions(+), 4 deletions(-) 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/volunteers/volunteers_views.py b/api/volunteers/volunteers_views.py index 8c8fc8d..bc12792 100644 --- a/api/volunteers/volunteers_views.py +++ b/api/volunteers/volunteers_views.py @@ -596,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) @@ -613,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']: diff --git a/services/volunteers_service.py b/services/volunteers_service.py index cd1a923..6ea4eb4 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -1835,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. @@ -1910,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 From b045688cac1d9d85d996bb473e4093b7acfd4ffb Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 6 Mar 2026 08:57:20 -0700 Subject: [PATCH 12/14] Refactor: break up messages_service.py monolith into domain modules Extract ~1200 lines from the 3155-line messages_service.py god module: - Create common/utils/firestore_helpers.py with shared Firestore utilities (doc_to_json, doc_to_json_recursive, hash_key, log_execution_time, cache mgmt) - Create services/hackathons_service.py with 17 hackathon functions (~600 lines) - Expand services/nonprofits_service.py with 10 raw-Firestore nonprofit functions - Redirect 9 re-exported imports in teams_service.py to their real sources - Redirect newsletter_service.py imports to db.db No API routes, URLs, or behavior changes. All 17 existing tests pass. Co-Authored-By: Claude Opus 4.6 --- api/judging/judging_service.py | 2 +- api/messages/messages_service.py | 1201 +---------------- api/messages/messages_views.py | 55 +- api/messages/tests/test_cache_invalidation.py | 70 +- api/messages/tests/test_hackathon_requests.py | 58 +- api/newsletters/newsletter_service.py | 4 +- api/teams/teams_service.py | 19 +- common/utils/firestore_helpers.py | 114 ++ services/hackathons_service.py | 682 ++++++++++ services/nonprofits_service.py | 396 +++++- 10 files changed, 1291 insertions(+), 1310 deletions(-) create mode 100644 common/utils/firestore_helpers.py create mode 100644 services/hackathons_service.py diff --git a/api/judging/judging_service.py b/api/judging/judging_service.py index 5f53a63..93ea115 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 services.hackathons_service import get_single_hackathon_event from api.messages.messages_service import ( - get_single_hackathon_event, get_team, get_teams_batch ) diff --git a/api/messages/messages_service.py b/api/messages/messages_service.py index 7639be3..4572c8a 100644 --- a/api/messages/messages_service.py +++ b/api/messages/messages_service.py @@ -71,98 +71,13 @@ def get_admin_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 +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, +) # Global variable to store singleton instance @@ -185,290 +100,6 @@ def get_db(): 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): @@ -600,83 +231,6 @@ def get_teams_batch(json): -@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) @@ -1029,746 +583,9 @@ def update_team_and_user(transaction): 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") - 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() - - -@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.debug("End NPO Delete") - return Message( - "Delete NPO" - ) - - -@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() - - 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"] - - # 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() - - return Message( - "Updated Hackathon Volunteers" - ) - -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 - - -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) - # Sort by created date descending (newest first) - 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 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 - - - + 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 diff --git a/api/messages/messages_views.py b/api/messages/messages_views.py index 8ed35ec..0080d25 100644 --- a/api/messages/messages_views.py +++ b/api/messages/messages_views.py @@ -17,27 +17,9 @@ 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_single_npo, - get_npo_by_hackathon_id, - get_npos_by_hackathon_id, - get_single_hackathon_event, - single_add_volunteer, - get_single_hackathon_id, - add_nonprofit_to_hackathon, - remove_nonprofit_from_hackathon, - save_hackathon, - create_hackathon, - get_hackathon_request_by_id, - update_hackathon_request, - update_hackathon_volunteers, get_teams_list, get_team, get_teams_batch, @@ -45,28 +27,49 @@ 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, ) +from services.nonprofits_service import ( + get_single_npo, + get_npo_list, + get_npo_by_hackathon_id, + get_npos_by_hackathon_id, + 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, + remove_nonprofit_from_hackathon, + save_hackathon, + create_hackathon, + get_hackathon_request_by_id, + update_hackathon_request, + update_hackathon_volunteers, + get_hackathon_list, + get_volunteer_by_event, +) logger = get_logger("messages_views") @@ -674,7 +677,7 @@ def admin_get_all_giveaways(): @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 api.messages.messages_service import get_all_hackathon_requests + from services.hackathons_service import get_all_hackathon_requests return get_all_hackathon_requests() @bp.route("/admin/hackathon-requests/", methods=["PATCH"]) @@ -682,7 +685,7 @@ def admin_get_all_hackathon_requests(): @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 api.messages.messages_service import admin_update_hackathon_request + 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 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 index 96b0066..36c3972 100644 --- a/api/messages/tests/test_hackathon_requests.py +++ b/api/messages/tests/test_hackathon_requests.py @@ -2,12 +2,12 @@ Test cases for hackathon request admin service functions. Tests get_all_hackathon_requests and admin_update_hackathon_request -from messages_service. +from hackathons_service. """ import pytest from unittest.mock import patch, MagicMock from datetime import datetime -from api.messages.messages_service import ( +from services.hackathons_service import ( get_all_hackathon_requests, admin_update_hackathon_request, get_hackathon_request_by_id, @@ -19,7 +19,7 @@ class TestGetAllHackathonRequests: """Test cases for listing all hackathon requests.""" - @patch('api.messages.messages_service.get_db') + @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 @@ -53,7 +53,7 @@ def test_returns_all_requests(self, mock_db): assert len(result["requests"]) == 2 mock_db.return_value.collection.assert_called_once_with('hackathon_requests') - @patch('api.messages.messages_service.get_db') + @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() @@ -72,7 +72,7 @@ def test_requests_include_document_ids(self, mock_db): assert result["requests"][0]["id"] == "abc-123" assert result["requests"][0]["companyName"] == "Test Co" - @patch('api.messages.messages_service.get_db') + @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() @@ -99,7 +99,7 @@ def test_requests_sorted_newest_first(self, mock_db): assert result["requests"][0]["id"] == "new" assert result["requests"][1]["id"] == "old" - @patch('api.messages.messages_service.get_db') + @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() @@ -110,7 +110,7 @@ def test_empty_collection_returns_empty_list(self, mock_db): assert result == {"requests": []} - @patch('api.messages.messages_service.get_db') + @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() @@ -133,8 +133,8 @@ def test_handles_missing_created_field(self, mock_db): class TestAdminUpdateHackathonRequest: """Test cases for admin updating a hackathon request.""" - @patch('api.messages.messages_service.send_slack_audit') - @patch('api.messages.messages_service.get_db') + @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 @@ -174,8 +174,8 @@ def test_updates_status_successfully(self, mock_db, mock_slack): assert update_args["adminNotes"] == "Looks good" assert "updated" in update_args - @patch('api.messages.messages_service.send_slack_audit') - @patch('api.messages.messages_service.get_db') + @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() @@ -194,8 +194,8 @@ def test_returns_none_for_nonexistent_request(self, mock_db, mock_slack): assert result is None mock_doc_ref.update.assert_not_called() - @patch('api.messages.messages_service.send_slack_audit') - @patch('api.messages.messages_service.get_db') + @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() @@ -218,8 +218,8 @@ def test_adds_updated_timestamp(self, mock_db, mock_slack): # Verify it's a valid ISO format timestamp datetime.fromisoformat(update_args["updated"]) - @patch('api.messages.messages_service.send_slack_audit') - @patch('api.messages.messages_service.get_db') + @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() @@ -247,8 +247,8 @@ def test_sends_slack_audit(self, mock_db, mock_slack): class TestGetHackathonRequestById: """Test cases for retrieving a single hackathon request.""" - @patch('api.messages.messages_service.send_slack_audit') - @patch('api.messages.messages_service.get_db') + @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() @@ -272,10 +272,10 @@ def test_returns_request_data(self, mock_db, mock_slack): class TestCreateHackathon: """Test cases for creating a new hackathon request.""" - @patch('api.messages.messages_service.send_slack') - @patch('api.messages.messages_service.send_hackathon_request_email') - @patch('api.messages.messages_service.send_slack_audit') - @patch('api.messages.messages_service.get_db') + @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() @@ -299,10 +299,10 @@ def test_creates_request_with_pending_status(self, mock_db, mock_slack_audit, mo assert saved_data["status"] == "pending" assert "created" in saved_data - @patch('api.messages.messages_service.send_slack') - @patch('api.messages.messages_service.send_hackathon_request_email') - @patch('api.messages.messages_service.send_slack_audit') - @patch('api.messages.messages_service.get_db') + @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() @@ -323,9 +323,9 @@ def test_sends_confirmation_email(self, mock_db, mock_slack_audit, mock_email, m assert call_args[0] == "Diana" assert call_args[1] == "diana@example.com" - @patch('api.messages.messages_service.send_slack') - @patch('api.messages.messages_service.send_slack_audit') - @patch('api.messages.messages_service.get_db') + @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() @@ -335,6 +335,6 @@ def test_skips_email_without_contact_info(self, mock_db, mock_slack_audit, mock_ payload = {"companyName": "No Contact Corp"} - with patch('api.messages.messages_service.send_hackathon_request_email') as mock_email: + 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..e7a23e3 100644 --- a/api/teams/teams_service.py +++ b/api/teams/teams_service.py @@ -1,26 +1,21 @@ import uuid import logging from datetime import datetime -from db.db import get_db +from db.db import get_db, get_user_doc_reference 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 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/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/services/hackathons_service.py b/services/hackathons_service.py new file mode 100644 index 0000000..5872dc7 --- /dev/null +++ b/services/hackathons_service.py @@ -0,0 +1,682 @@ +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 api.messages.messages_service 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" + ) + + +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_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/nonprofits_service.py b/services/nonprofits_service.py index 279cb56..c08a40e 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 api.messages.messages_service 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 api.messages.messages_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" + ) From a5a430da54688e657bca45caae1751b0368cb3e1 Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 6 Mar 2026 10:35:53 -0700 Subject: [PATCH 13/14] Extract teams and email services from messages_service monolith - Extract 8 team functions into services/teams_service.py (~480 lines) - Extract 6 email functions into services/email_service.py (~260 lines) - Move get_db() import to db.db (eliminate local duplicate) - Deduplicate add_utm() helper (was copied in hackathons_service) - Update imports in messages_views, judging_service, teams_service - Remove unused imports from messages_service (github, slack, oauth) - messages_service.py reduced from ~1970 to ~1200 lines Co-Authored-By: Claude Opus 4.6 --- api/judging/judging_service.py | 2 +- api/messages/messages_service.py | 778 +------------------------------ api/messages/messages_views.py | 20 +- api/teams/teams_service.py | 6 +- services/email_service.py | 263 +++++++++++ services/hackathons_service.py | 8 +- services/nonprofits_service.py | 4 +- services/teams_service.py | 479 +++++++++++++++++++ 8 files changed, 763 insertions(+), 797 deletions(-) create mode 100644 services/email_service.py create mode 100644 services/teams_service.py diff --git a/api/judging/judging_service.py b/api/judging/judging_service.py index 93ea115..2570673 100644 --- a/api/judging/judging_service.py +++ b/api/judging/judging_service.py @@ -28,7 +28,7 @@ from model.judge_score import JudgeScore from model.judge_panel import JudgePanel from services.hackathons_service import get_single_hackathon_event -from api.messages.messages_service import ( +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 4572c8a..301d7f0 100644 --- a/api/messages/messages_service.py +++ b/api/messages/messages_service.py @@ -1,9 +1,7 @@ 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.slack import send_slack_audit, send_slack, async_send_slack, invite_user_to_channel, get_user_info +from common.utils.firebase import upsert_news, upsert_praise, get_github_contributions_for_user, 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 api.messages.message import Message from google.cloud.exceptions import NotFound @@ -19,8 +17,6 @@ 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 @@ -31,9 +27,8 @@ from datetime import datetime, timedelta import os -from db.db import fetch_user_by_user_id, get_user_doc_reference +from db.db import fetch_user_by_user_id, get_db import resend -import random logger = get_logger("messages_service") @@ -44,8 +39,6 @@ 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 @@ -80,508 +73,8 @@ def get_admin_message(): ) -# 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 - - - -@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 [] - - - -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) def clear_cache(): from services.hackathons_service import clear_cache as _hackathon_clear_cache @@ -715,271 +208,6 @@ def get_praises_about_user(user_id): # -------------------- 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") diff --git a/api/messages/messages_views.py b/api/messages/messages_views.py index 0080d25..525ab07 100644 --- a/api/messages/messages_views.py +++ b/api/messages/messages_views.py @@ -20,15 +20,7 @@ save_helping_status_old, save_profile_metadata_old, save_problem_statement_old, - get_teams_list, - get_team, - get_teams_batch, - get_teams_by_event_id, - save_team, - unjoin_team, - join_team, save_news, - save_lead_async, get_news, get_all_profiles, get_github_profile, @@ -37,12 +29,22 @@ save_praise, save_feedback, get_user_feedback, - get_github_repos, get_user_giveaway, save_giveaway, get_all_giveaways, 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, diff --git a/api/teams/teams_service.py b/api/teams/teams_service.py index e7a23e3..ea4994a 100644 --- a/api/teams/teams_service.py +++ b/api/teams/teams_service.py @@ -2,10 +2,8 @@ import logging from datetime import datetime from db.db import get_db, get_user_doc_reference -from api.messages.messages_service import ( - get_problem_statement_from_id_old, - get_teams_list, -) +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 ( 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/hackathons_service.py b/services/hackathons_service.py index 5872dc7..a77976a 100644 --- a/services/hackathons_service.py +++ b/services/hackathons_service.py @@ -35,7 +35,7 @@ def _get_db(): - from api.messages.messages_service import get_db + from db.db import get_db return get_db() @@ -425,11 +425,7 @@ def update_hackathon_volunteers(event_id, volunteer_type, json, propel_id): ) -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}" +from services.email_service import add_utm def send_hackathon_request_email(contact_name, contact_email, request_id): diff --git a/services/nonprofits_service.py b/services/nonprofits_service.py index c08a40e..66eed18 100644 --- a/services/nonprofits_service.py +++ b/services/nonprofits_service.py @@ -22,7 +22,7 @@ def _get_db(): - from api.messages.messages_service import get_db + from db.db import get_db return get_db() @@ -379,7 +379,7 @@ def get_latest_docs(transaction): @limits(calls=100, period=ONE_MINUTE) def save_npo_application(json): - from api.messages.messages_service import send_nonprofit_welcome_email, google_recaptcha_key + 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() 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) From 16ba89e769e758d213d88fb9f6b6356829ff4bc6 Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 6 Mar 2026 13:07:26 -0700 Subject: [PATCH 14/14] Extract news/praise, feedback, giveaway, and onboarding services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract news/praise functions into services/news_service.py (~150 lines) - Extract feedback functions into services/feedback_service.py (~140 lines) - Extract giveaway functions into services/giveaway_service.py (~75 lines) - Extract onboarding feedback into services/onboarding_service.py (~90 lines) - Clean up unused imports from messages_service.py - messages_service.py reduced from ~1200 to ~760 lines (3155 → 760 total, 76% reduction) Co-Authored-By: Claude Opus 4.6 --- api/messages/messages_service.py | 452 +------------------------------ api/messages/messages_views.py | 15 +- services/feedback_service.py | 138 ++++++++++ services/giveaway_service.py | 74 +++++ services/news_service.py | 151 +++++++++++ services/onboarding_service.py | 91 +++++++ 6 files changed, 466 insertions(+), 455 deletions(-) create mode 100644 services/feedback_service.py create mode 100644 services/giveaway_service.py create mode 100644 services/news_service.py create mode 100644 services/onboarding_service.py diff --git a/api/messages/messages_service.py b/api/messages/messages_service.py index 301d7f0..ce7a63c 100644 --- a/api/messages/messages_service.py +++ b/api/messages/messages_service.py @@ -1,47 +1,29 @@ from common.utils import safe_get_env_var -from common.utils.slack import send_slack_audit, send_slack, async_send_slack, invite_user_to_channel, get_user_info -from common.utils.firebase import upsert_news, upsert_praise, get_github_contributions_for_user, 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 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 -from common.utils.validators import validate_email, validate_url, validate_hackathon_data -from common.exceptions import InvalidInputError -from cachetools import cached, LRUCache, TTLCache -from cachetools.keys import hashkey +from cachetools import cached, TTLCache from ratelimit import limits -from datetime import datetime, timedelta import os from db.db import fetch_user_by_user_id, get_db -import resend 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 - -CDN_SERVER = os.getenv("CDN_SERVER") ONE_MINUTE = 1*60 -THIRTY_SECONDS = 30 def get_public_message(): logger.debug("~ Public ~") return Message( @@ -92,145 +74,6 @@ def clear_cache(): 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 --------------------------- # - -@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): @@ -694,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. @@ -1096,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 525ab07..2537f86 100644 --- a/api/messages/messages_views.py +++ b/api/messages/messages_views.py @@ -20,20 +20,19 @@ save_helping_status_old, save_profile_metadata_old, save_problem_statement_old, - save_news, - get_news, get_all_profiles, get_github_profile, +) +from services.news_service import ( + save_news, + get_news, get_all_praises, get_praises_about_user, save_praise, - save_feedback, - get_user_feedback, - get_user_giveaway, - save_giveaway, - get_all_giveaways, - save_onboarding_feedback, ) +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, 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/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/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")