diff --git a/api/messages/messages_service.py b/api/messages/messages_service.py index 3b96abd..c1f8496 100644 --- a/api/messages/messages_service.py +++ b/api/messages/messages_service.py @@ -226,6 +226,8 @@ def add_nonprofit_to_hackathon(json): # 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" @@ -280,6 +282,9 @@ def remove_nonprofit_from_hackathon(json): 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" @@ -1233,6 +1238,7 @@ 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) @@ -1680,6 +1686,7 @@ def save_hackathon(json_data, propel_id): "prize": "0", "swag": "0", }), + "timezone": json_data.get("timezone", "America/Phoenix"), "last_updated": firestore.SERVER_TIMESTAMP, "last_updated_by": propel_id, } @@ -1707,11 +1714,8 @@ def update_hackathon(transaction): transaction = db.transaction() update_hackathon(transaction) - # Clear cache for get_single_hackathon_event - get_single_hackathon_event.cache_clear() - - # Clear cache for get_hackathon_list - doc_to_json.cache_clear() + # Clear all hackathon-related caches + clear_cache() logger.info(f"Hackathon {'updated' if is_update else 'created'} successfully. ID: {doc_id}") diff --git a/api/messages/tests/__init__.py b/api/messages/tests/__init__.py new file mode 100644 index 0000000..31d41f8 --- /dev/null +++ b/api/messages/tests/__init__.py @@ -0,0 +1 @@ +# Tests for messages service diff --git a/api/messages/tests/test_cache_invalidation.py b/api/messages/tests/test_cache_invalidation.py new file mode 100644 index 0000000..d7dd251 --- /dev/null +++ b/api/messages/tests/test_cache_invalidation.py @@ -0,0 +1,143 @@ +""" +Test cases for cache invalidation in messages_service. + +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 ( + add_nonprofit_to_hackathon, + remove_nonprofit_from_hackathon, + save_hackathon, + clear_cache +) + + +class TestCacheInvalidation: + """Test cases for cache invalidation in hackathon operations.""" + + @patch('api.messages.messages_service.clear_cache') + @patch('api.messages.messages_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 + mock_hackathon_doc = MagicMock() + mock_hackathon_data = MagicMock() + 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') + 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') + 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", + "location": "Test Location", + "start_date": "2024-01-01", + "end_date": "2024-01-02", + "type": "virtual", + "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') + def test_clear_cache_clears_all_caches( + self, + mock_get_hackathon_list, + mock_get_single_hackathon_id, + mock_get_single_hackathon_event, + mock_doc_to_json + ): + """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() + mock_get_single_hackathon_id.cache_clear.assert_called_once() + mock_get_hackathon_list.cache_clear.assert_called_once() diff --git a/api/volunteers/volunteers_views.py b/api/volunteers/volunteers_views.py index 1cbb499..163b44e 100644 --- a/api/volunteers/volunteers_views.py +++ b/api/volunteers/volunteers_views.py @@ -16,6 +16,7 @@ mentor_checkout, send_volunteer_message, send_email_to_address, + get_resend_email_statuses, ) from common.auth import auth, auth_user @@ -593,6 +594,7 @@ def admin_send_email_to_address(): subject = request_data.get('subject', 'Message from Opportunity Hack Team') recipient_type = request_data.get('recipient_type', 'volunteer') name = request_data.get('name') + volunteer_id = request_data.get('volunteer_id') if not email: return _error_response("Email address is required", 400) @@ -609,7 +611,8 @@ def admin_send_email_to_address(): admin_user_id=auth_user.user_id, admin_user=auth_user, recipient_type=recipient_type, - name=name + name=name, + volunteer_id=volunteer_id ) if result['success']: @@ -622,3 +625,34 @@ def admin_send_email_to_address(): except Exception as e: logger.error("Error in admin_send_email_to_address: %s", str(e)) return _error_response(f"Failed to send email: {str(e)}") + + +@bp.route('/admin/emails/resend-status', methods=['POST']) +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_get_resend_email_statuses(): + """Admin endpoint to fetch delivery status for Resend email IDs.""" + try: + request_data = _process_request() + email_ids = request_data.get('email_ids', []) + + logger.info("Fetching Resend email statuses for %d email IDs", len(email_ids) if isinstance(email_ids, list) else 0) + logger.debug("Email IDs requested: %s", email_ids) + + if not email_ids or not isinstance(email_ids, list): + logger.warning("Invalid or missing email_ids in request: %s", email_ids) + return _error_response("email_ids array is required", 400) + + result = get_resend_email_statuses(email_ids) + + if result['success']: + logger.info("Successfully fetched statuses for %d email IDs", len(email_ids)) + logger.debug("Email status results: %s", result) + return _success_response(result, "Email statuses fetched successfully") + + logger.error("Failed to fetch email statuses. 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_get_resend_email_statuses: %s", str(e)) + logger.exception(e) + return _error_response(f"Failed to fetch email statuses: {str(e)}") diff --git a/common/utils/validators.py b/common/utils/validators.py index c1917eb..b5ab767 100644 --- a/common/utils/validators.py +++ b/common/utils/validators.py @@ -2,6 +2,7 @@ from urllib.parse import urlparse import logging from datetime import datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError logger = logging.getLogger(__name__) @@ -96,12 +97,34 @@ def validate_hackathon_data(data): except ValueError as e: raise ValueError(f"Invalid date format: {str(e)}") + # Validate timezone if provided + timezone = data.get("timezone") + if timezone: + try: + ZoneInfo(timezone) + except (ZoneInfoNotFoundError, KeyError): + raise ValueError(f"Invalid timezone: {timezone}") + # Validate constraints constraints = data.get("constraints", {}) if not all(isinstance(constraints.get(k), int) for k in ["max_people_per_team", "max_teams_per_problem", "min_people_per_team"]): raise ValueError("Constraints must be integers") - # Add more specific validations as needed + # Validate hacker_required_questions if present + hacker_required_questions = constraints.get("hacker_required_questions", {}) + if hacker_required_questions: + questions = hacker_required_questions.get("questions", []) + if not isinstance(questions, list): + raise ValueError("hacker_required_questions.questions must be a list") + for i, q in enumerate(questions): + if not isinstance(q, dict): + raise ValueError(f"Question {i} must be an object") + if not isinstance(q.get("question"), str) or not q.get("question"): + raise ValueError(f"Question {i} must have a non-empty 'question' string") + if not isinstance(q.get("required_answer"), bool): + raise ValueError(f"Question {i} must have a boolean 'required_answer'") + if not isinstance(q.get("error"), str) or not q.get("error"): + raise ValueError(f"Question {i} must have a non-empty 'error' string") if __name__ == "__main__": # Simple tests diff --git a/services/problem_statements_service.py b/services/problem_statements_service.py index a9a46d7..ee5c2f2 100644 --- a/services/problem_statements_service.py +++ b/services/problem_statements_service.py @@ -105,7 +105,12 @@ def update_problem_statement_fields(d): if problem_statement is not None: problem_statement.update(d) problem_statement.id = d['id'] - return update_problem_statement(problem_statement) + result = update_problem_statement(problem_statement) + + # Clear cache after updating problem statement + get_problem_statement.cache_clear() + + return result else: return None diff --git a/services/volunteers_service.py b/services/volunteers_service.py index 6d27193..a8089fc 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -720,7 +720,32 @@ def create_or_update_volunteer( if not verify_recaptcha(recaptcha_token): warning(logger, "reCAPTCHA verification failed", email=email) return {"error": "reCAPTCHA verification failed"} - + + # Validate required question answers for hacker applications + if volunteer_data.get('volunteer_type') == 'hacker': + submitted_answers = volunteer_data.get('requiredQuestionAnswers', []) + if submitted_answers: + try: + db_temp = get_db() + hackathon_docs = db_temp.collection('hackathons').where('event_id', '==', event_id).limit(1).stream() + hackathon_data = None + for doc in hackathon_docs: + hackathon_data = doc.to_dict() + break + + if hackathon_data: + questions = hackathon_data.get('constraints', {}).get('hacker_required_questions', {}).get('questions', []) + if questions: + if len(submitted_answers) != len(questions): + warning(logger, "Required question answer count mismatch", email=email, event_id=event_id) + return {"error": "Required question answers do not match the expected number of questions"} + for i, q in enumerate(questions): + if i >= len(submitted_answers) or submitted_answers[i] != q.get('required_answer'): + warning(logger, "Required question answer incorrect", email=email, event_id=event_id, question_index=i) + return {"error": q.get('error', 'You do not meet the eligibility requirements for this event.')} + except Exception as e: + exception(logger, "Error validating required questions", exc_info=e, email=email, event_id=event_id) + db = get_db() volunteer_type = volunteer_data.get('volunteer_type') @@ -1576,14 +1601,15 @@ def _send_email_to_user( params["attachments"] = qr_code_attachments email_result = resend.Emails.send(params) + resend_email_id = email_result.get('id') if isinstance(email_result, dict) else getattr(email_result, 'id', None) info(logger, "Email sent to user", - volunteer_id=volunteer_id, email=email, result=email_result, recipient_type=recipient_type) - return True, None + volunteer_id=volunteer_id, email=email, result=email_result, resend_email_id=resend_email_id, recipient_type=recipient_type) + return True, None, resend_email_id except Exception as email_error: error(logger, "Failed to send email to user", volunteer_id=volunteer_id, exc_info=email_error) - return False, str(email_error) + return False, str(email_error), None def send_volunteer_message( @@ -1693,7 +1719,7 @@ def send_volunteer_message( delivery_status['slack_error'] = slack_error # Send email message - email_success, email_error = _send_email_to_user( + email_success, email_error, resend_email_id = _send_email_to_user( email=email, name=name, subject=subject, @@ -1706,30 +1732,31 @@ def send_volunteer_message( delivery_status['email_sent'] = email_success delivery_status['email_error'] = email_error - # Update volunteer document with message tracking + # Update volunteer document with message tracking (sent_emails with Resend ID) try: - admin_full_name = f"{admin_user.first_name} {admin_user.last_name}" if admin_user else "Admin" + admin_full_name = f"Admin ({admin_user.user_id})" if admin_user else "Admin" email_subject = f"{subject} - Message from Opportunity Hack Team" - message_record = { + sent_email_record = { + 'resend_id': resend_email_id, 'subject': email_subject, 'timestamp': _get_current_timestamp(), 'sent_by': admin_full_name, 'recipient_type': recipient_type, - 'delivery_status': delivery_status } - # Add the message to the volunteer's messages_sent array + # Add the record to the volunteer's sent_emails array volunteer_ref = db.collection('volunteers').document(volunteer_id) volunteer_ref.update({ - 'messages_sent': firestore.ArrayUnion([message_record]), + 'sent_emails': firestore.ArrayUnion([sent_email_record]), 'last_message_timestamp': _get_current_timestamp(), 'updated_timestamp': _get_current_timestamp() }) user_id = volunteer.get('user_id', '') - info(logger, "Updated volunteer document with message tracking", - volunteer_id=volunteer_id, message_timestamp=message_record['timestamp']) + info(logger, "Updated volunteer document with sent_emails tracking", + volunteer_id=volunteer_id, resend_email_id=resend_email_id, + message_timestamp=sent_email_record['timestamp']) _clear_volunteer_caches( user_id=user_id, @@ -1739,8 +1766,9 @@ def send_volunteer_message( ) except Exception as tracking_error: - error(logger, "Failed to update volunteer document with message tracking", - volunteer_id=volunteer_id, exc_info=tracking_error) + error(logger, "Failed to update volunteer document with sent_emails tracking", + volunteer_id=volunteer_id, resend_email_id=resend_email_id, + exc_info=tracking_error) # Enhanced Slack audit message with recipient context from common.utils.slack import send_slack_audit @@ -1802,7 +1830,8 @@ def send_email_to_address( admin_user_id: str, admin_user: Any = None, recipient_type: str = 'volunteer', - name: Optional[str] = None + name: Optional[str] = None, + volunteer_id: Optional[str] = None ) -> Dict[str, Any]: """ Send an email to a specific email address using the same template as send_volunteer_message. @@ -1815,6 +1844,7 @@ def send_email_to_address( admin_user: The admin user object recipient_type: Type of recipient (mentor, sponsor, judge, volunteer, hacker, etc.) name: Optional recipient name (defaults to 'Volunteer') + volunteer_id: Optional volunteer document ID for tracking sent emails Returns: Dict containing delivery status @@ -1833,7 +1863,7 @@ def send_email_to_address( _, message_for_email, qr_code_attachments = _process_qr_code_in_message(message) # Send email message - email_success, email_error = _send_email_to_user( + email_success, email_error, resend_email_id = _send_email_to_user( email=email, name=recipient_name, subject=subject, @@ -1841,13 +1871,44 @@ def send_email_to_address( recipient_type=recipient_type, volunteer_type=recipient_type, qr_code_attachments=qr_code_attachments, - volunteer_id=None + volunteer_id=volunteer_id ) + admin_full_name = f"Admin ({admin_user.user_id})" if admin_user else "Admin" + + # Save sent_emails record to volunteer document if volunteer_id is provided + if volunteer_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, + } + + volunteer_ref = db.collection('volunteers').document(volunteer_id) + volunteer_ref.update({ + 'sent_emails': firestore.ArrayUnion([sent_email_record]), + 'last_message_timestamp': _get_current_timestamp(), + 'updated_timestamp': _get_current_timestamp() + }) + + info(logger, "Updated volunteer document with sent_emails tracking (email-only path)", + volunteer_id=volunteer_id, resend_email_id=resend_email_id) + + except Exception as tracking_error: + error(logger, "Failed to update volunteer document with sent_emails tracking (email-only path)", + volunteer_id=volunteer_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 - admin_full_name = f"{admin_user.first_name} {admin_user.last_name}" if admin_user else "Admin" send_slack_audit( action="admin_send_email_to_address", @@ -1858,7 +1919,9 @@ def send_email_to_address( "recipient_type": recipient_type, "message_preview": message_preview, "email_sent": email_success, - "email_error": email_error + "email_error": email_error, + "resend_email_id": resend_email_id, + "volunteer_id": volunteer_id } ) @@ -1868,7 +1931,8 @@ def send_email_to_address( 'recipient_name': recipient_name, 'recipient_type': recipient_type, 'email_sent': email_success, - 'email_error': email_error + 'email_error': email_error, + 'resend_email_id': resend_email_id } if not email_success: @@ -1883,3 +1947,45 @@ def send_email_to_address( 'error': f"Failed to send email: {str(e)}", 'recipient_email': email } + + +def get_resend_email_statuses(email_ids: list) -> Dict[str, Any]: + """ + Fetch delivery status for a list of Resend email IDs. + + Args: + email_ids: List of Resend email IDs to look up + + Returns: + Dict with 'statuses' mapping each email ID to its Resend status + """ + 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'} + + statuses = {} + for eid in email_ids[:50]: # Cap at 50 to avoid excessive API calls + try: + email_data = resend.Emails.get(eid) + statuses[eid] = { + 'id': eid, + 'to': getattr(email_data, 'to', []) if not isinstance(email_data, dict) else email_data.get('to', []), + 'subject': getattr(email_data, 'subject', '') if not isinstance(email_data, dict) else email_data.get('subject', ''), + 'created_at': getattr(email_data, 'created_at', '') if not isinstance(email_data, dict) else email_data.get('created_at', ''), + 'last_event': getattr(email_data, 'last_event', '') if not isinstance(email_data, dict) else email_data.get('last_event', ''), + } + except Exception as fetch_error: + warning(logger, "Failed to fetch Resend email status", + resend_email_id=eid, exc_info=fetch_error) + statuses[eid] = { + 'id': eid, + 'last_event': 'unknown', + 'error': str(fetch_error) + } + + return {'success': True, 'statuses': statuses} + + except Exception as e: + error(logger, "Error fetching Resend email statuses", exc_info=e) + return {'success': False, 'error': str(e)}