diff --git a/backend/constants/common_constants.py b/backend/constants/common_constants.py index 074f6d0e..6a9ca9e9 100644 --- a/backend/constants/common_constants.py +++ b/backend/constants/common_constants.py @@ -11,6 +11,17 @@ class EntryStatus(str, Enum): DELETED = 'DELETED' +class LogLevel(str, Enum): + CRITICAL = 'CRITICAL' + FATAL = 'FATAL' + ERROR = 'ERROR' + WARNING = 'WARNING' + WARN = 'WARN' + INFO = 'INFO' + DEBUG = 'DEBUG' + NOTSET = 'NOTSET' + + class CommonConstants: # DB Constants CLS = 'cls' diff --git a/backend/controller/payment_controller.py b/backend/controller/payment_controller.py index 240416b1..9e3d1571 100644 --- a/backend/controller/payment_controller.py +++ b/backend/controller/payment_controller.py @@ -1,4 +1,4 @@ -from aws.cognito_settings import AccessUser, is_admin_user +from dependencies.verify_service_access import verify_service_access from fastapi import APIRouter, Body, Depends, Path, Query from fastapi.responses import JSONResponse from model.common import Message @@ -11,6 +11,7 @@ @payment_router.post( '', response_model=PaymentTransactionOut, + dependencies=[Depends(verify_service_access)], responses={ 400: {'model': Message, 'description': 'Bad request'}, 500: {'model': Message, 'description': 'Internal server error'}, @@ -20,6 +21,7 @@ @payment_router.post( '/', response_model=PaymentTransactionOut, + dependencies=[Depends(verify_service_access)], response_model_exclude_none=True, response_model_exclude_unset=True, include_in_schema=False, @@ -43,19 +45,17 @@ def create_payment_transaction( @payment_router.get( '/pending', response_model=list[PaymentTransactionOut], + dependencies=[Depends(verify_service_access)], responses={ 404: {'model': Message, 'description': 'Bad request'}, 500: {'model': Message, 'description': 'Internal server error'}, }, summary='Get pending payment transactions', ) -def get_pending_payment_transactions( - current_user: AccessUser = Depends(is_admin_user), -): +def get_pending_payment_transactions(): """ Get Payment Transaction with pending Status """ - _ = current_user payment_uc = PaymentUsecase() return payment_uc.query_pending_payment_transactions() @@ -63,6 +63,7 @@ def get_pending_payment_transactions( @payment_router.put( '/{paymentTransactionId}', response_model=PaymentTransactionOut, + dependencies=[Depends(verify_service_access)], responses={ 400: {'model': Message, 'description': 'Bad request'}, 500: {'model': Message, 'description': 'Internal server error'}, @@ -74,12 +75,10 @@ def update_payment_transaction( ..., description='The ID of the payment transaction', alias='paymentTransactionId' ), payment_transaction: PaymentTransactionIn = Body(..., description='The payment transaction data'), - current_user: AccessUser = Depends(is_admin_user), ): """ Update payment transaction """ - _ = current_user payment_uc = PaymentUsecase() return payment_uc.update_payment_transaction(payment_transaction_id, payment_transaction) diff --git a/backend/dependencies/verify_service_access.py b/backend/dependencies/verify_service_access.py new file mode 100644 index 00000000..a363aa98 --- /dev/null +++ b/backend/dependencies/verify_service_access.py @@ -0,0 +1,40 @@ +import hmac +import os +from typing import Optional + +from fastapi import HTTPException, Security, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from utils.logger import logger + +bearer_scheme = HTTPBearer(auto_error=False) + + +def verify_service_access( + auth: Optional[HTTPAuthorizationCredentials] = Security(bearer_scheme), +) -> None: + """Validate service-to-service access token using FastAPI's HTTPBearer. + + Compares the Bearer token in the 'Authorization' header against + the 'SECRET_TOKEN' environment variable. + + :param auth: Optional HTTPAuthorizationCredentials extracted by FastAPI's HTTPBearer. + :type auth: Optional[HTTPAuthorizationCredentials] + + :raises HTTPException: 500 if SECRET_TOKEN is not configured on the server. + :raises HTTPException: 401 if Authorization Bearer token is missing or invalid. + """ + raw_secret = os.environ.get('SECRET_TOKEN') + secret_token = raw_secret.strip() if raw_secret else None + if not secret_token: + logger.error('SECRET_TOKEN environment variable is not configured') + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Server configuration error: SECRET_TOKEN is not configured', + ) + + if not auth or not auth.credentials or not hmac.compare_digest(auth.credentials.strip(), secret_token): + logger.warning('Invalid or missing Authorization header') + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Invalid or missing Authorization token', + ) \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 18491051..ddf9d64f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,7 @@ STAGE = os.environ.get('STAGE') root_path = f'/{STAGE}' if STAGE else '/' +CORS_ORIGIN = '*.durianpy.org' if STAGE == 'prod' else '*' app = FastAPI( root_path=root_path, @@ -39,7 +40,7 @@ def welcome(): mangum_handler = Mangum(app, lifespan='off') -@cors_headers +@cors_headers(origin=CORS_ORIGIN) @lambdawarmer.warmer def handler(event, context): return mangum_handler(event, context) diff --git a/backend/resources/api.yml b/backend/resources/api.yml index 4ddde1c4..4c1e765b 100644 --- a/backend/resources/api.yml +++ b/backend/resources/api.yml @@ -8,11 +8,31 @@ app: - http: path: / method: get - cors: true + cors: + origin: ${self:custom.corsOriginValue} + headers: + - Content-Type + - X-Amz-Date + - Authorization + - X-Api-Key + - X-Amz-Security-Token + - X-Amz-User-Agent + - X-Amzn-Trace-Id + allowCredentials: true - http: path: /{proxy+} method: any - cors: true + cors: + origin: ${self:custom.corsOriginValue} + headers: + - Content-Type + - X-Amz-Date + - Authorization + - X-Api-Key + - X-Amz-Security-Token + - X-Amz-User-Agent + - X-Amzn-Trace-Id + allowCredentials: true - http: path: /docs method: get diff --git a/backend/resources/api_gateway.yml b/backend/resources/api_gateway.yml index f477c62e..095a0345 100644 --- a/backend/resources/api_gateway.yml +++ b/backend/resources/api_gateway.yml @@ -1,10 +1,47 @@ Resources: - GatewayResponse: - Type: 'AWS::ApiGateway::GatewayResponse' - Properties: - ResponseParameters: - gatewayresponse.header.WWW-Authenticate: "'Basic'" - ResponseType: UNAUTHORIZED - RestApiId: - Ref: 'ApiGatewayRestApi' - StatusCode: '401' + GatewayResponse: + Type: 'AWS::ApiGateway::GatewayResponse' + Properties: + ResponseParameters: + gatewayresponse.header.WWW-Authenticate: "'Basic'" + gatewayresponse.header.Access-Control-Allow-Origin: "'${self:custom.corsOriginValue}'" + gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent,X-Amzn-Trace-Id'" + gatewayresponse.header.Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + ResponseType: UNAUTHORIZED + RestApiId: + Ref: 'ApiGatewayRestApi' + StatusCode: '401' + + GatewayResponseDefault4XX: + Type: 'AWS::ApiGateway::GatewayResponse' + Properties: + ResponseParameters: + gatewayresponse.header.Access-Control-Allow-Origin: "'${self:custom.corsOriginValue}'" + gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent,X-Amzn-Trace-Id'" + gatewayresponse.header.Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + ResponseType: DEFAULT_4XX + RestApiId: + Ref: 'ApiGatewayRestApi' + + GatewayResponseDefault5XX: + Type: 'AWS::ApiGateway::GatewayResponse' + Properties: + ResponseParameters: + gatewayresponse.header.Access-Control-Allow-Origin: "'${self:custom.corsOriginValue}'" + gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent,X-Amzn-Trace-Id'" + gatewayresponse.header.Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + ResponseType: DEFAULT_5XX + RestApiId: + Ref: 'ApiGatewayRestApi' + + GatewayResponseThrottled: + Type: 'AWS::ApiGateway::GatewayResponse' + Properties: + ResponseParameters: + gatewayresponse.header.Access-Control-Allow-Origin: "'${self:custom.corsOriginValue}'" + gatewayresponse.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent,X-Amzn-Trace-Id'" + gatewayresponse.header.Access-Control-Allow-Methods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + ResponseType: THROTTLED + RestApiId: + Ref: 'ApiGatewayRestApi' + StatusCode: '429' diff --git a/backend/resources/s3.yml b/backend/resources/s3.yml index bd605442..05b7c137 100644 --- a/backend/resources/s3.yml +++ b/backend/resources/s3.yml @@ -9,7 +9,7 @@ Resources: CorsRules: - AllowedHeaders: ["*"] AllowedMethods: [GET, PUT, HEAD] - AllowedOrigins: ["*"] + AllowedOrigins: ${self:custom.s3CorsAllowedOrigins.${self:custom.stage}, self:custom.s3CorsAllowedOrigins.default} Id: ${self:custom.bucket}-name MaxAge: "3600" PublicAccessBlockConfiguration: diff --git a/backend/serverless.yaml b/backend/serverless.yaml index 35a33b7f..edcecca7 100644 --- a/backend/serverless.yaml +++ b/backend/serverless.yaml @@ -6,6 +6,19 @@ custom: projectName: ${self:custom.organization}-events serviceName: events stage: ${opt:stage, self:provider.stage} + corsOrigin: + prod: '*.durianpy.org' + default: '*' + corsOriginValue: ${self:custom.corsOrigin.${self:custom.stage}, self:custom.corsOrigin.default} + s3CorsAllowedOrigins: + prod: + - '*.durianpy.org' + - 'https://*.durianpy.org' + - 'https://durianpy.org' + default: + - '*' + apiGatewayRateLimit: 30 + apiGatewayBurstLimit: 30 pitr: prod: true pitrEnabled: ${self:custom.pitr.${self:custom.stage}, false} @@ -52,6 +65,10 @@ provider: Action: execute-api:Invoke Principal: "*" Resource: execute-api:/*/*/* + usagePlan: + throttle: + rateLimit: ${self:custom.apiGatewayRateLimit} + burstLimit: ${self:custom.apiGatewayBurstLimit} environment: REGION: ${self:provider.region} STAGE: ${self:custom.stage} @@ -65,6 +82,7 @@ provider: PAYMENT_QUEUE: ${self:custom.paymentQueue} CERTIFICATE_QUEUE: ${self:custom.certificateQueue} S3_BUCKET: ${self:custom.bucket} + SECRET_TOKEN: ${ssm:/techtix/events-api-secret-token-${self:custom.stage}} # KONFHUB_API_KEY: ${self:custom.konfHubApiKey} USER_POOL_ID: !ImportValue UserPoolId-${self:custom.stage} USER_POOL_CLIENT_ID: !ImportValue AppClientId-${self:custom.stage} diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..904ec376 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests package for TechTix backend.""" + diff --git a/backend/tests/test_logger.py b/backend/tests/test_logger.py new file mode 100644 index 00000000..88f6572d --- /dev/null +++ b/backend/tests/test_logger.py @@ -0,0 +1,536 @@ +"""Unit tests for the logger utility module.""" + +import asyncio +import logging +import os +import unittest +from concurrent.futures import ThreadPoolExecutor +from typing import Any +from unittest.mock import MagicMock, patch + +from constants.common_constants import LogLevel +from utils.logger import ( + Logger, + Settings, + _ISOFormatter, + log_execution, + logger, + mask_email, + mask_string, +) + + +class CustomDomainException(Exception): + """Custom domain exception for testing error mapping.""" + + +class ZeroArgDomainException(Exception): + """Custom domain exception that takes no arguments.""" + + def __init__(self) -> None: + super().__init__('Zero arg domain error') + + +class TestISOFormatter(unittest.TestCase): + """Tests for _ISOFormatter.""" + + def test_format_time(self) -> None: + """Test formatting of timestamp into ISO 8601 format.""" + formatter = _ISOFormatter('%(asctime)s [%(levelname)s] %(message)s') + record = logging.LogRecord( + name='test', + level=logging.INFO, + pathname='test.py', + lineno=1, + msg='Hello World', + args=(), + exc_info=None, + ) + formatted_time = formatter.formatTime(record) + self.assertIsInstance(formatted_time, str) + self.assertIn('T', formatted_time) + formatted_record = formatter.format(record) + self.assertIn(formatted_time, formatted_record) + self.assertIn('[INFO] Hello World', formatted_record) + + def test_format_with_user_context(self) -> None: + """Test that formatter tags [userid=] when user context is available.""" + formatter = _ISOFormatter('%(asctime)s [%(levelname)s] %(message)s') + record = logging.LogRecord( + name='test', + level=logging.DEBUG, + pathname='test.py', + lineno=1, + msg='Debugging details', + args=(), + exc_info=None, + ) + with patch.dict(os.environ, {'CURRENT_USER': 'test-sub-123'}): + formatted_record = formatter.format(record) + self.assertIn('[userid=test-sub-123] Debugging details', formatted_record) + + # Test with record attribute user_id + record2 = logging.LogRecord( + name='test', + level=logging.ERROR, + pathname='test.py', + lineno=1, + msg='Something failed', + args=(), + exc_info=None, + ) + record2.user_id = 'explicit-uid-456' + with patch.dict(os.environ, {}, clear=True): + formatted_record2 = formatter.format(record2) + self.assertIn('[userid=explicit-uid-456] Something failed', formatted_record2) + + +class TestLogger(unittest.TestCase): + """Tests for Logger singleton class.""" + + def tearDown(self) -> None: + """Reset the logger singleton after each test.""" + Logger._reset() + + def test_singleton_identity(self) -> None: + """Test that multiple instantiations return the exact same instance.""" + inst1 = Logger() + inst2 = Logger() + self.assertIs(inst1, inst2) + + def test_singleton_thread_safety(self) -> None: + """Test that concurrent instantiation across threads produces a single instance.""" + with ThreadPoolExecutor(max_workers=8) as executor: + futures = [executor.submit(Logger) for _ in range(20)] + instances = [f.result() for f in futures] + + first = instances[0] + for inst in instances[1:]: + self.assertIs(first, inst) + + def test_get_logger_factory(self) -> None: + """Test get_logger class method.""" + inst = Logger.get_logger('custom_app', LogLevel.INFO) + self.assertIsInstance(inst, Logger) + inst2 = Logger.get_logger() + self.assertIs(inst, inst2) + + def test_set_level(self) -> None: + """Test setLevel with LogLevel enum, string, and integer.""" + logger_instance = Logger() + logger_instance.setLevel(LogLevel.DEBUG) + self.assertEqual(logger_instance.level, logging.DEBUG) + + logger_instance.setLevel('WARNING') + self.assertEqual(logger_instance.level, logging.WARNING) + + logger_instance.setLevel(logging.ERROR) + self.assertEqual(logger_instance.level, logging.ERROR) + + def test_delegation_to_inner_logger(self) -> None: + """Test method and attribute delegation to logging.Logger.""" + logger_instance = Logger() + with patch.object(logger_instance._Logger__logger, 'info') as mock_info: + logger_instance.info('Test info message') + mock_info.assert_called_once_with('Test info message') + + with patch.object(logger_instance._Logger__logger, 'error') as mock_error: + logger_instance.error('Test error message') + mock_error.assert_called_once_with('Test error message') + + with patch.object(logger_instance._Logger__logger, 'warning') as mock_warn: + logger_instance.warning('Test warn message') + mock_warn.assert_called_once_with('Test warn message') + + with patch.object(logger_instance._Logger__logger, 'debug') as mock_debug: + logger_instance.debug('Test debug message') + mock_debug.assert_called_once_with('Test debug message') + + def test_user_tagging_across_log_levels_with_env_user(self) -> None: + """Test that debug, info, warning, error, critical, and exception include [userid=] from CURRENT_USER.""" + logger_instance = Logger() + with patch.dict(os.environ, {'CURRENT_USER': 'test-auth-user'}): + with patch.object(logger_instance._Logger__logger, 'debug') as mock_debug: + logger_instance.debug('Checking cache hit') + mock_debug.assert_called_once_with('[userid=test-auth-user] Checking cache hit') + + with patch.object(logger_instance._Logger__logger, 'error') as mock_error: + logger_instance.error('Database query failed') + mock_error.assert_called_once_with('[userid=test-auth-user] Database query failed') + + with patch.object(logger_instance._Logger__logger, 'info') as mock_info: + logger_instance.info('User initiated checkout') + mock_info.assert_called_once_with('[userid=test-auth-user] User initiated checkout') + + with patch.object(logger_instance._Logger__logger, 'warning') as mock_warn: + logger_instance.warning('Rate limit approaching') + mock_warn.assert_called_once_with('[userid=test-auth-user] Rate limit approaching') + + with patch.object(logger_instance._Logger__logger, 'critical') as mock_crit: + logger_instance.critical('Fatal crash') + mock_crit.assert_called_once_with('[userid=test-auth-user] Fatal crash') + + with patch.object(logger_instance._Logger__logger, 'exception') as mock_exc: + logger_instance.exception('Unhandled exception') + mock_exc.assert_called_once_with('[userid=test-auth-user] Unhandled exception') + + def test_user_tagging_across_log_levels_with_explicit_user_id(self) -> None: + """Test that passing explicit user_id attaches [userid=] even if CURRENT_USER differs.""" + logger_instance = Logger() + with patch.dict(os.environ, {'CURRENT_USER': 'env-user'}): + with patch.object(logger_instance._Logger__logger, 'debug') as mock_debug: + logger_instance.debug('Debug task', user_id='explicit-uid') + mock_debug.assert_called_once_with('[userid=explicit-uid] Debug task') + + with patch.object(logger_instance._Logger__logger, 'error') as mock_error: + logger_instance.error('Payment gateway error', user_id='explicit-uid') + mock_error.assert_called_once_with('[userid=explicit-uid] Payment gateway error') + + def test_user_tagging_avoids_duplicate_tags(self) -> None: + """Test that messages already containing [userid=] are not double-tagged.""" + logger_instance = Logger() + with patch.dict(os.environ, {'CURRENT_USER': 'env-user'}): + with patch.object(logger_instance._Logger__logger, 'debug') as mock_debug: + logger_instance.debug('[userid=existing-user] Already tagged message') + mock_debug.assert_called_once_with('[userid=existing-user] Already tagged message') + + with patch.object(logger_instance._Logger__logger, 'error') as mock_error: + logger_instance.error('[userid=existing-user] Already tagged error') + mock_error.assert_called_once_with('[userid=existing-user] Already tagged error') + + def test_getattr_raises_for_invalid_attribute(self) -> None: + """Test that accessing invalid attributes raises AttributeError.""" + logger_instance = Logger() + with self.assertRaises(AttributeError): + _ = logger_instance.non_existent_attribute_xyz + + def test_aws_lambda_environment(self) -> None: + """Test logger configuration when AWS_EXECUTION_ENV is set.""" + with patch.dict(os.environ, {'AWS_EXECUTION_ENV': 'AWS_Lambda_python3.11'}): + Logger._reset() + # Remove any existing handlers from previous test on root/custom loggers + logger_instance = Logger(name='lambda_test_logger') + self.assertFalse(logger_instance.propagate) + self.assertTrue(len(logger_instance.handlers) > 0) + record = logging.LogRecord( + name='lambda_test_logger', + level=logging.INFO, + pathname='test.py', + lineno=1, + msg='Lambda execution', + args=(), + exc_info=None, + ) + formatted = logger_instance.handlers[0].formatter.format(record) + self.assertEqual(formatted, '[INFO] Lambda execution') + + def test_static_methods_available_on_class(self) -> None: + """Test static methods on Logger class.""" + masked = Logger.mask_string('sensitive123') + self.assertEqual(masked, mask_string('sensitive123')) + + @Logger.log_execution + def sample() -> str: + return 'done' + + self.assertEqual(sample(), 'done') + + +class SampleService: + """Sample class to test class name extraction in log_execution.""" + + @log_execution + def instance_method(self, val: int) -> int: + return val * 2 + + @log_execution(CustomDomainException) + def method_with_error(self) -> None: + raise ValueError('inner value error') + + @classmethod + @log_execution + def class_method(cls, name: str) -> str: + return f'Hello, {name}' + + +class TestLogExecution(unittest.TestCase): + """Tests for log_execution decorator.""" + + def tearDown(self) -> None: + """Reset singleton.""" + Logger._reset() + + def test_sync_function_without_parentheses(self) -> None: + """Test decorating sync function without parentheses.""" + @log_execution + def calculate(a: int, b: int) -> int: + return a + b + + with patch.object(logger._Logger__logger, 'info') as mock_info: + result = calculate(2, 3) + self.assertEqual(result, 5) + self.assertTrue(mock_info.called) + self.assertIn('calculate', mock_info.call_args_list[0][0][0]) + + def test_sync_function_with_parentheses(self) -> None: + """Test decorating sync function with empty parentheses.""" + @log_execution() + def greet(name: str) -> str: + return f'Hello {name}' + + result = greet('Python') + self.assertEqual(result, 'Hello Python') + + def test_class_name_extraction(self) -> None: + """Test class name extraction for instance and class methods.""" + service = SampleService() + with patch.object(logger._Logger__logger, 'info') as mock_info: + result = service.instance_method(5) + self.assertEqual(result, 10) + log_msg = mock_info.call_args_list[0][0][0] + self.assertIn('SampleService', log_msg) + self.assertIn('instance_method', log_msg) + + with patch.object(logger._Logger__logger, 'info') as mock_info: + cls_result = SampleService.class_method('DurianPy') + self.assertEqual(cls_result, 'Hello, DurianPy') + log_msg = mock_info.call_args_list[0][0][0] + self.assertIn('SampleService', log_msg) + self.assertIn('class_method', log_msg) + + def test_async_function(self) -> None: + """Test decorating async coroutine function.""" + @log_execution + async def async_fetch(item_id: str) -> str: + await asyncio.sleep(0.01) + return f'item-{item_id}' + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + with patch.object(logger._Logger__logger, 'info') as mock_info: + result = loop.run_until_complete(async_fetch('123')) + self.assertEqual(result, 'item-123') + self.assertTrue(mock_info.called) + finally: + loop.close() + + def test_exception_logging_and_re_raise(self) -> None: + """Test that exceptions are logged and re-raised when no domain_exception is provided.""" + @log_execution + def failing_func() -> None: + raise KeyError('missing key') + + with patch.object(logger._Logger__logger, 'error') as mock_error: + with self.assertRaises(KeyError): + failing_func() + self.assertTrue(mock_error.called) + error_msg = mock_error.call_args_list[0][0][0] + self.assertIn('failing_func', error_msg) + self.assertIn('missing key', error_msg) + + def test_domain_exception_mapping(self) -> None: + """Test mapping an exception to a domain exception.""" + service = SampleService() + with patch.object(logger._Logger__logger, 'error') as mock_error: + with self.assertRaises(CustomDomainException) as ctx: + service.method_with_error() + self.assertIn('inner value error', str(ctx.exception)) + self.assertTrue(mock_error.called) + + def test_domain_exception_already_domain_exception(self) -> None: + """Test that if raised exception is already domain exception, it is not re-wrapped.""" + @log_execution(CustomDomainException) + def raise_domain() -> None: + raise CustomDomainException('already domain') + + with self.assertRaises(CustomDomainException) as ctx: + raise_domain() + self.assertEqual(str(ctx.exception), 'already domain') + + def test_domain_exception_zero_arg(self) -> None: + """Test mapping to domain exception with no-arg constructor.""" + @log_execution(ZeroArgDomainException) + def fail_with_zero_arg() -> None: + raise RuntimeError('runtime error') + + with self.assertRaises(ZeroArgDomainException) as ctx: + fail_with_zero_arg() + self.assertIn('Zero arg domain error', str(ctx.exception)) + + def test_async_exception_mapping(self) -> None: + """Test exception mapping in async function.""" + @log_execution(CustomDomainException) + async def async_fail() -> None: + await asyncio.sleep(0.01) + raise ValueError('async failure') + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + with self.assertRaises(CustomDomainException): + loop.run_until_complete(async_fail()) + finally: + loop.close() + + def test_user_tagging_from_env(self) -> None: + """Test that CURRENT_USER env var is included in execution logs.""" + service = SampleService() + with patch.dict(os.environ, {'CURRENT_USER': 'sub-uuid-123'}): + with patch.object(logger._Logger__logger, 'info') as mock_info: + result = service.instance_method(4) + self.assertEqual(result, 8) + self.assertTrue(mock_info.called) + log_msg = mock_info.call_args_list[0][0][0] + self.assertEqual(log_msg, '[SampleService] [userid=sub-uuid-123] Executing instance_method') + + def test_user_tagging_from_current_user_object(self) -> None: + """Test user extraction from current_user argument with sub attribute.""" + @log_execution + def endpoint_fn(data: str, current_user: Any = None) -> str: + return f'{data}-processed' + + mock_user = MagicMock() + mock_user.sub = 'cognito-sub-789' + + with patch.object(logger._Logger__logger, 'info') as mock_info: + result = endpoint_fn('item', current_user=mock_user) + self.assertEqual(result, 'item-processed') + self.assertTrue(mock_info.called) + log_msg = mock_info.call_args_list[0][0][0] + self.assertIn('[userid=cognito-sub-789] Executing endpoint_fn', log_msg) + + def test_user_tagging_from_user_id_kwarg(self) -> None: + """Test user extraction from user_id keyword argument.""" + @log_execution + def fn_with_user_id(user_id: str, action: str) -> str: + return action + + with patch.object(logger._Logger__logger, 'info') as mock_info: + result = fn_with_user_id(user_id='user-xyz-456', action='read') + self.assertEqual(result, 'read') + self.assertTrue(mock_info.called) + log_msg = mock_info.call_args_list[0][0][0] + self.assertIn('[userid=user-xyz-456] Executing fn_with_user_id', log_msg) + + def test_exception_logging_with_user_tag(self) -> None: + """Test that exception logs include the user tag when authenticated.""" + service = SampleService() + with patch.dict(os.environ, {'CURRENT_USER': 'sub-error-user'}): + with patch.object(logger._Logger__logger, 'error') as mock_error: + with self.assertRaises(CustomDomainException): + service.method_with_error() + self.assertTrue(mock_error.called) + err_msg = mock_error.call_args_list[0][0][0] + self.assertIn('[SampleService] [userid=sub-error-user] Exception in method_with_error', err_msg) + + +class TestMaskString(unittest.TestCase): + """Tests for mask_string utility function.""" + + def test_empty_and_none(self) -> None: + """Test None and empty strings return empty string.""" + self.assertEqual(mask_string(None), '') + self.assertEqual(mask_string(''), '') + + def test_length_less_than_or_equal_to_visible(self) -> None: + """Test strings shorter than or equal to prefix + suffix are completely masked.""" + self.assertEqual(mask_string('a', 2, 2), '*') + self.assertEqual(mask_string('ab', 2, 2), '**') + self.assertEqual(mask_string('abc', 2, 2), '***') + self.assertEqual(mask_string('abcd', 2, 2), '****') + + def test_standard_masking(self) -> None: + """Test normal masking with default prefix=2, suffix=2.""" + self.assertEqual(mask_string('password123'), 'pa*******23') + + def test_custom_prefix_suffix(self) -> None: + """Test custom prefix, suffix, and mask char.""" + self.assertEqual(mask_string('1234567890', 3, 2, '#'), '123#####90') + + def test_zero_prefix(self) -> None: + """Test zero prefix.""" + self.assertEqual(mask_string('secrettoken', visible_prefix=0, visible_suffix=3), '********ken') + + def test_zero_suffix(self) -> None: + """Test zero suffix.""" + self.assertEqual(mask_string('secrettoken', visible_prefix=3, visible_suffix=0), 'sec********') + + +class TestMaskEmail(unittest.TestCase): + """Tests for mask_email utility function.""" + + def test_empty_and_none(self) -> None: + """Test None, empty, and whitespace return empty string.""" + self.assertEqual(mask_email(None), '') + self.assertEqual(mask_email(''), '') + + def test_standard_email(self) -> None: + """Test masking standard email address.""" + self.assertEqual(mask_email('john.doe@example.com'), 'joh**doe@example.com') + self.assertEqual(mask_email('aspactores@durianpy.org'), 'aspa**ores@durianpy.org') + + + def test_short_local_parts(self) -> None: + """Test masking short local part emails.""" + self.assertEqual(mask_email('a@test.com'), '*@test.com') + self.assertEqual(mask_email('ab@test.com'), '**@test.com') + self.assertEqual(mask_email('abc@test.com'), 'a*c@test.com') + + def test_fallback_when_no_at_sign(self) -> None: + """Test fallback to mask_string when no @ sign is present.""" + self.assertEqual(mask_email('invalidemailaddress'), mask_string('invalidemailaddress')) + + +class TestInfoLogging(unittest.TestCase): + """Tests for Logger.info business event logging.""" + + def test_unauthenticated_info(self) -> None: + """Test logging info without user.""" + logger_instance = Logger() + with patch.dict(os.environ, {}, clear=True): + with patch.object(logger_instance._Logger__logger, 'info') as mock_info: + logger_instance.info('User registration created') + mock_info.assert_called_once_with('User registration created') + + def test_info_with_explicit_user_id(self) -> None: + """Test info logging with explicitly passed user_id.""" + logger_instance = Logger() + with patch.object(logger_instance._Logger__logger, 'info') as mock_info: + logger_instance.info('Payment successful: ₱1500', user_id='usr-paid-001') + mock_info.assert_called_once_with('[userid=usr-paid-001] Payment successful: ₱1500') + + def test_info_with_env_current_user(self) -> None: + """Test info logging falling back to CURRENT_USER env var.""" + logger_instance = Logger() + with patch.dict(os.environ, {'CURRENT_USER': 'cognito-sub-12345'}): + with patch.object(logger_instance._Logger__logger, 'info') as mock_info: + logger_instance.info('Event status changed to OPEN') + mock_info.assert_called_once_with('[userid=cognito-sub-12345] Event status changed to OPEN') + + +class TestSettings(unittest.TestCase): + """Tests for Settings class and LogLevel enum.""" + + def test_log_level_values(self) -> None: + """Test LogLevel enum definitions.""" + self.assertEqual(LogLevel.INFO.value, 'INFO') + self.assertEqual(LogLevel.DEBUG.value, 'DEBUG') + self.assertEqual(LogLevel.ERROR.value, 'ERROR') + self.assertEqual(LogLevel.WARNING.value, 'WARNING') + + def test_settings_defaults(self) -> None: + """Test Settings default values.""" + settings = Settings() + self.assertEqual(settings.APP_NAME, 'techtix-events-service') + self.assertIsNotNone(settings.LOG_LEVEL) + + def test_settings_overrides(self) -> None: + """Test Settings with explicit overrides.""" + settings = Settings(app_name='custom-app', log_level=LogLevel.INFO) + self.assertEqual(settings.APP_NAME, 'custom-app') + self.assertEqual(settings.LOG_LEVEL, LogLevel.INFO) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/usecase/certificate_usecase.py b/backend/usecase/certificate_usecase.py index 34d81439..4ec5ef5c 100644 --- a/backend/usecase/certificate_usecase.py +++ b/backend/usecase/certificate_usecase.py @@ -12,7 +12,7 @@ from repository.registrations_repository import RegistrationsRepository from starlette.responses import JSONResponse from usecase.file_s3_usecase import FileS3Usecase -from utils.logger import logger +from utils.logger import log_execution, logger, mask_email class CertificateUsecase: @@ -23,6 +23,7 @@ def __init__(self): self.__sqs_client = boto3_client('sqs', region_name=os.getenv('REGION', 'ap-southeast-1')) self.__sqs_url = os.getenv('CERTIFICATE_QUEUE') + @log_execution def generate_certificates(self, event_id: str, registration_id: str = None) -> Tuple[HTTPStatus, str]: """Generate certificates for an event @@ -56,8 +57,9 @@ def generate_certificates(self, event_id: str, registration_id: str = None) -> T ) message_id = response.get('MessageId') - message = f'Queue message success: {message_id}' - logger.info(message) + logger.info( + f'Certificate generation queued for event_id={event_id}, registration_id={registration_id}' + ) except Exception as e: message = f'Failed to send email: {str(e)}' @@ -67,6 +69,7 @@ def generate_certificates(self, event_id: str, registration_id: str = None) -> T else: return HTTPStatus.OK, message + @log_execution def claim_certificate(self, event_id: str, certificate_in: CertificateIn) -> Union[JSONResponse, CertificateOut]: """Claim a certificate @@ -80,6 +83,7 @@ def claim_certificate(self, event_id: str, certificate_in: CertificateIn) -> Uni :rtype: Union[JSONResponse, CertificateOut] """ + masked_email = mask_email(certificate_in.email) status, event, message = self.__events_repository.query_events(event_id) if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) @@ -134,6 +138,11 @@ def claim_certificate(self, event_id: str, certificate_in: CertificateIn) -> Uni # ) # img_download_url = pdf_download_url_response.downloadLink if pdf_download_url_response else None + logger.info( + f'Certificate claimed for event_id={event_id}, email={masked_email}, ' + f'registration_id={registration.registrationId}, is_first_claim={is_first_claim}' + ) + return CertificateOut( isFirstClaim=is_first_claim, # certificateTemplate=img_download_url, diff --git a/backend/usecase/discount_usecase.py b/backend/usecase/discount_usecase.py index e91ea423..7e73aaf9 100644 --- a/backend/usecase/discount_usecase.py +++ b/backend/usecase/discount_usecase.py @@ -13,6 +13,7 @@ from repository.events_repository import EventsRepository from repository.registrations_repository import RegistrationsRepository from starlette.responses import JSONResponse +from utils.logger import log_execution, logger from utils.utils import Utils @@ -22,6 +23,7 @@ def __init__(self): self.__events_repository = EventsRepository() self.__registrations_repository = RegistrationsRepository() + @log_execution def get_discount(self, event_id: str, entry_id: str) -> DiscountOut: """Get a discount. @@ -60,6 +62,7 @@ def get_discount(self, event_id: str, entry_id: str) -> DiscountOut: return discount_out + @log_execution def get_discount_list(self, event_id: str) -> List[DiscountOrganization]: """Get a list of discounts. @@ -105,6 +108,7 @@ def get_discount_list(self, event_id: str) -> List[DiscountOrganization]: for organization_id, discount_out_list in discount_map.items() ] + @log_execution def claim_discount(self, event_id: str, entry_id: str, registration_id: str): """Claim a discount. @@ -186,9 +190,13 @@ def claim_discount(self, event_id: str, entry_id: str, registration_id: str): if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info( + f'Discount claimed: code={entry_id}, event_id={event_id}, registration_id={registration_id}' + ) discount_data = self.__convert_data_entry_to_dict(discount) return DiscountOut(**discount_data) + @log_execution def create_discounts(self, discount_in: DiscountIn) -> Union[JSONResponse, List[DiscountOut]]: """Create discounts. @@ -245,6 +253,10 @@ def create_discounts(self, discount_in: DiscountIn) -> Union[JSONResponse, List[ discount_out = DiscountOut(**discount_data) discount_list.append(discount_out) + logger.info( + f'Discounts created: count={len(discount_list)}, organization={discount_in.organizationName}, ' + f'event_id={discount_in.eventId}, is_reusable={discount_in.isReusable}' + ) return discount_list def __generate_discount_code(self, length=8): diff --git a/backend/usecase/email_usecase.py b/backend/usecase/email_usecase.py index ef1a8156..218da80d 100644 --- a/backend/usecase/email_usecase.py +++ b/backend/usecase/email_usecase.py @@ -13,7 +13,7 @@ from model.preregistrations.preregistrations_constants import AcceptanceStatus from model.registrations.registration import Registration from repository.preregistrations_repository import PreRegistrationsRepository -from utils.logger import logger +from utils.logger import log_execution, logger, mask_email class EmailUsecase: @@ -44,7 +44,7 @@ def __send_email_handler(self, email_in_list: List[EmailIn], event: Event) -> Tu # Check if event has konfhub and exclude it from the Email Service if self.__event_email and event.konfhubId and event.konfhubApiKey: - logger.info(f'Skipping sending email to {self.__event_email} because it is a special email') + logger.info(f'Skipping sending email to {mask_email(self.__event_email)} because it is a special email') return timestamp = datetime.now(timezone.utc).isoformat(timespec='seconds') @@ -59,9 +59,9 @@ def __send_email_handler(self, email_in_list: List[EmailIn], event: Event) -> Tu MessageGroupId=f'durianpy-event-{event_id}', ) message_id = response.get('MessageId') - message = f'Queue message success: {message_id}' - logger.info(message) + logger.info(f'Queued {len(payload)} email(s) for event {event_id} (MessageId: {message_id})') + @log_execution def send_batch_email(self, email_in_list: List[EmailIn], event: Event) -> Tuple[HTTPStatus, str]: """Send an email to the queue @@ -87,6 +87,7 @@ def send_batch_email(self, email_in_list: List[EmailIn], event: Event) -> Tuple[ else: return HTTPStatus.OK, message + @log_execution def send_email(self, email_in: EmailIn, event: Event) -> Tuple[HTTPStatus, str]: """Send an email to the queue @@ -102,6 +103,7 @@ def send_email(self, email_in: EmailIn, event: Event) -> Tuple[HTTPStatus, str]: """ return self.send_batch_email(email_in_list=[email_in], event=event) + @log_execution def send_event_creation_email(self, event: Event) -> Tuple[HTTPStatus, str]: """Send an email to the queue. If the preregistration is accepted, send an acceptance email. If the preregistration is rejected, send a rejection email. @@ -112,6 +114,9 @@ def send_event_creation_email(self, event: Event) -> Tuple[HTTPStatus, str]: :rtype: Tuple[HTTPStatus, str] """ + logger.info( + f'Sending event creation email for event {event.name} (event_id={event.eventId}) to {mask_email(event.email)}' + ) subject = f'Event {event.name} has been created' body = [f'Event {event.name} has been created. Please check the event page for more details.'] salutation = 'Dear DurianPy ,' @@ -137,6 +142,7 @@ def send_event_creation_email(self, event: Event) -> Tuple[HTTPStatus, str]: ) return self.send_email(email_in=email_in, event=event) + @log_execution def send_registration_creation_email(self, registration: Registration, event: Event) -> Tuple[HTTPStatus, str]: """Send an email to the queue. @@ -177,9 +183,14 @@ def send_registration_creation_email(self, registration: Registration, event: Ev eventId=event.eventId, isDurianPy=is_durianpy, ) - logger.info(f'Sending registration confirmation email to {registration.email}') + masked_email = mask_email(registration.email) + logger.info( + f'Sending registration confirmation email to {masked_email} for event {event.name} ' + f'(registration_id={registration.registrationId})' + ) return self.send_email(email_in=email_in, event=event) + @log_execution def send_accept_reject_status_email( self, preregistrations: List[PreRegistration], event: Event ) -> Tuple[HTTPStatus, str]: @@ -198,15 +209,16 @@ def send_accept_reject_status_email( if preregistration.acceptanceEmailSent: continue + masked_email = mask_email(preregistration.email) should_send_acceptance = ( preregistration.acceptanceStatus and preregistration.acceptanceStatus == AcceptanceStatus.ACCEPTED.value ) if should_send_acceptance: email = self.send_preregistration_acceptance_email(preregistration=preregistration, event=event) - logger.info(f'Acceptance email sent to {preregistration.email} for event {event.eventId}') + logger.info(f'Acceptance email queued for {masked_email} for event {event.eventId}') else: email = self.send_preregistration_rejection_email(preregistration=preregistration, event=event) - logger.info(f'Rejection email sent to {preregistration.email} for event {event.eventId}') + logger.info(f'Rejection email queued for {masked_email} for event {event.eventId}') emails.append(email) @@ -216,6 +228,7 @@ def send_accept_reject_status_email( return self.send_batch_email(email_in_list=emails, event=event) + @log_execution def send_preregistration_creation_email( self, preregistration: PreRegistration, event: Event ) -> Tuple[HTTPStatus, str]: @@ -257,9 +270,14 @@ def send_preregistration_creation_email( eventId=event.eventId, isDurianPy=is_durianpy, ) - logger.info(f'Sending pre-registration email to {preregistration.email}') + masked_email = mask_email(preregistration.email) + logger.info( + f'Sending pre-registration email to {masked_email} for event {event.name} ' + f'(preregistration_id={preregistration.preregistrationId})' + ) return self.send_email(email_in=email_in, event=event) + @log_execution def send_preregistration_acceptance_email(self, preregistration: PreRegistration, event: Event) -> EmailIn: """Send an acceptance email to the queue. @@ -297,9 +315,11 @@ def send_preregistration_acceptance_email(self, preregistration: PreRegistration eventId=event.eventId, isDurianPy=is_durianpy, ) - logger.info(f'Sending pre-registration acceptance email to {preregistration.email}') + masked_email = mask_email(preregistration.email) + logger.info(f'Sending pre-registration acceptance email to {masked_email}') return email_in + @log_execution def send_preregistration_rejection_email(self, preregistration: PreRegistration, event: Event) -> EmailIn: """Send a rejection email to the queue. @@ -340,9 +360,11 @@ def send_preregistration_rejection_email(self, preregistration: PreRegistration, eventId=event.eventId, isDurianPy=is_durianpy, ) - logger.info(f'Sending pre-registration rejection email to {preregistration.email}') + masked_email = mask_email(preregistration.email) + logger.info(f'Sending pre-registration rejection email to {masked_email}') return email_in + @log_execution def send_event_completion_email( self, event: Event, @@ -361,6 +383,9 @@ def send_event_completion_email( :type participants: list """ + logger.info( + f'Sending event completion email for event {event.name} to {len(participants)} participant(s)' + ) self.__event_email = event.email event_name = event.name event_id = event.eventId diff --git a/backend/usecase/evaluation_usecase.py b/backend/usecase/evaluation_usecase.py index 92b02afc..25314ef1 100644 --- a/backend/usecase/evaluation_usecase.py +++ b/backend/usecase/evaluation_usecase.py @@ -12,6 +12,7 @@ from repository.events_repository import EventsRepository from repository.registrations_repository import RegistrationsRepository from starlette.responses import JSONResponse +from utils.logger import log_execution, logger class EvaluationUsecase: @@ -20,6 +21,7 @@ def __init__(self): self.__registrations_repository = RegistrationsRepository() self.__events_repository = EventsRepository() + @log_execution def create_evaluation(self, evaluation_list_in: EvaluationListIn) -> Union[JSONResponse, List[EvaluationOut]]: """Create evaluations for a registration @@ -59,8 +61,14 @@ def create_evaluation(self, evaluation_list_in: EvaluationListIn) -> Union[JSONR registration_in=RegistrationPatch(certificateClaimed=True), ) + logger.info( + f'Evaluation submitted for event_id={event_id}, registration_id={registration_id}, ' + f'questions_count={len(evaluation_list)}' + ) + return [EvaluationOut(**self.__convert_data_entry_to_dict(evaluation)) for evaluation in evaluation_list] + @log_execution def update_evaluation( self, event_id: str, @@ -110,9 +118,13 @@ def update_evaluation( if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info( + f'Evaluation updated for event_id={event_id}, registration_id={registration_id}, question={question}' + ) evaluation_data = self.__convert_data_entry_to_dict(update_evaluation) return EvaluationOut(**evaluation_data) + @log_execution def get_evaluation(self, event_id: str, registration_id: str, question: str) -> Union[JSONResponse, EvaluationOut]: """Get an evaluation @@ -142,6 +154,7 @@ def get_evaluation(self, event_id: str, registration_id: str, question: str) -> evaluations_data = self.__convert_data_entry_to_dict(evaluation) return EvaluationOut(**evaluations_data) + @log_execution def get_evaluations( self, event_id: str = None, registration_id: str = None, question: str = None ) -> Union[JSONResponse, List[EvaluationListOut]]: @@ -195,6 +208,7 @@ def get_evaluations( return evaluations_return + @log_execution def get_evaluations_by_question(self, event_id: str, question: str) -> Union[JSONResponse, List[EvaluationOut]]: """Get evaluations for a question diff --git a/backend/usecase/event_usecase.py b/backend/usecase/event_usecase.py index 5eeb3112..d6910691 100644 --- a/backend/usecase/event_usecase.py +++ b/backend/usecase/event_usecase.py @@ -17,6 +17,7 @@ from starlette.responses import JSONResponse from usecase.email_usecase import EmailUsecase from usecase.file_s3_usecase import FileS3Usecase +from utils.logger import log_execution, logger from utils.utils import Utils @@ -30,6 +31,7 @@ def __init__(self): self.__faqs_repository = FAQsRepository() self.__ticket_type_repository = TicketTypeRepository() + @log_execution def create_event(self, event_in: EventIn) -> Union[JSONResponse, EventOut]: """Create a new event @@ -71,10 +73,12 @@ def create_event(self, event_in: EventIn) -> Union[JSONResponse, EventOut]: if email_status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info(f'Event created: {event.name} (event_id={event.eventId}, admin_id={event.adminId})') event_data = self.__convert_data_entry_to_dict(event) event_out = EventOut(**event_data) return self.collect_pre_signed_url(event_out) + @log_execution def update_event(self, event_id: str, event_in: EventIn) -> Union[JSONResponse, EventOut]: """Update an existing event. @@ -105,6 +109,12 @@ def update_event(self, event_id: str, event_in: EventIn) -> Union[JSONResponse, if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info(f'Event updated: {update_event.name} (event_id={event_id}, status={update_event.status})') + if original_status != update_event.status: + logger.info( + f'Event status changed for {update_event.name} (event_id={event_id}) from {original_status} to {update_event.status}' + ) + if event_in.ticketTypes: _, ticket_types_entries, _ = self.__ticket_type_repository.query_ticket_types(event_id=event_id) existing_ticket_types_map = { @@ -174,6 +184,7 @@ def update_event(self, event_id: str, event_in: EventIn) -> Union[JSONResponse, return self.collect_pre_signed_url(event_out) + @log_execution def get_event(self, event_id: str) -> Union[JSONResponse, EventOut, EventAdminOut]: """Get an event by its ID @@ -203,6 +214,7 @@ def get_event(self, event_id: str) -> Union[JSONResponse, EventOut, EventAdminOu return self.collect_pre_signed_url(event_out) + @log_execution def get_events(self, admin_id: str = None) -> Union[JSONResponse, List[EventOut]]: """Get all events or all events for a specific admin @@ -227,6 +239,7 @@ def get_events(self, admin_id: str = None) -> Union[JSONResponse, List[EventOut] event_model = EventAdminOut if current_user else EventOut return [self.collect_pre_signed_url(event_model(**event_data)) for event_data in events_data] + @log_execution def delete_event(self, event_id: str) -> Union[None, JSONResponse]: """Delete an event by its ID @@ -245,6 +258,8 @@ def delete_event(self, event_id: str) -> Union[None, JSONResponse]: if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info(f'Event deleted: {event.name} (event_id={event_id})') + _, faqs, _ = self.__faqs_repository.query_faq_entry(event_id) if faqs: status, message = self.__faqs_repository.delete_faqs(faqs_entry=faqs) @@ -253,6 +268,7 @@ def delete_event(self, event_id: str) -> Union[None, JSONResponse]: return None + @log_execution def update_event_after_s3_upload(self, object_key) -> Union[JSONResponse, EventOut]: """Update an event after an S3 upload @@ -280,10 +296,12 @@ def update_event_after_s3_upload(self, object_key) -> Union[JSONResponse, EventO if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info(f'Event updated after S3 upload: event_id={event_id}, upload_type={upload_type}') event_data = self.__convert_data_entry_to_dict(update_event) event_out = EventOut(**event_data) return self.collect_pre_signed_url(event_out) + @log_execution def collect_pre_signed_url(self, event: EventOut): """Collect pre-signed URLs for an event. diff --git a/backend/usecase/export_data_usecase.py b/backend/usecase/export_data_usecase.py index b61ae80e..0f5e451e 100644 --- a/backend/usecase/export_data_usecase.py +++ b/backend/usecase/export_data_usecase.py @@ -12,7 +12,7 @@ from PIL import Image as PilImage from repository.registrations_repository import RegistrationsRepository from usecase.pycon_registration_usecase import PyconRegistrationUsecase -from utils.logger import logger +from utils.logger import log_execution, logger class ExportDataUsecase: @@ -23,6 +23,7 @@ def __init__(self): self.__EXCEL_COLUMN_WIDTH_FACTOR = 0.15 self.__EXCEL_ROW_HEIGHT_FACTOR = 0.75 + @log_execution async def export_registrations_to_excel(self, event_id: str, file_name: str): """ Exports an event's registration list to an Excel file, embedding ID images where available. @@ -39,7 +40,10 @@ async def export_registrations_to_excel(self, event_id: str, file_name: str): df, column_mapping = self._create_dataframe(registrations_data) output_path = await self._write_excel_with_images_async(df, file_name, column_mapping) - logger.info(f'Successfully exported data to {output_path}') + logger.info( + f'Exported {len(registrations_data)} registrations to Excel for event_id={event_id} ' + f'(file: {Path(output_path).name})' + ) return JSONResponse( status_code=HTTPStatus.OK, content={'message': f'Data exported to {Path(output_path).name}'} ) diff --git a/backend/usecase/faqs_usecase.py b/backend/usecase/faqs_usecase.py index be10da6c..1f4450ca 100644 --- a/backend/usecase/faqs_usecase.py +++ b/backend/usecase/faqs_usecase.py @@ -5,6 +5,7 @@ from repository.events_repository import EventsRepository from repository.faqs_repository import FAQsRepository from starlette.responses import JSONResponse +from utils.logger import log_execution, logger class FAQsUsecase: @@ -12,6 +13,7 @@ def __init__(self): self.__faqs_repository = FAQsRepository() self.__events_repository = EventsRepository() + @log_execution def create_update_faqs(self, faqs_in: FAQsIn, event_id: str) -> Union[JSONResponse, FAQsOut]: """Create or update FAQs for an event @@ -35,6 +37,7 @@ def create_update_faqs(self, faqs_in: FAQsIn, event_id: str) -> Union[JSONRespon _, ) = self.__faqs_repository.query_faq_entry(event_id=event_id) + is_update = bool(faqs) if faqs: ( status, @@ -51,8 +54,11 @@ def create_update_faqs(self, faqs_in: FAQsIn, event_id: str) -> Union[JSONRespon if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + action = 'updated' if is_update else 'created' + logger.info(f'FAQs {action} for event_id={event_id}') return FAQsOut(**self.__convert_data_entry_to_dict(faqs)) + @log_execution def get_faqs(self, event_id: str) -> Union[JSONResponse, FAQsOut]: """Get the FAQs for an event diff --git a/backend/usecase/file_s3_usecase.py b/backend/usecase/file_s3_usecase.py index 84ca81fd..b2dab6c5 100755 --- a/backend/usecase/file_s3_usecase.py +++ b/backend/usecase/file_s3_usecase.py @@ -11,7 +11,7 @@ from model.file_uploads.file_upload import FileDownloadOut, FileUploadOut from model.file_uploads.file_upload_constants import ClientMethods from starlette.responses import JSONResponse -from utils.logger import logger +from utils.logger import log_execution, logger class FileS3Usecase: @@ -24,6 +24,7 @@ def __init__(self): self.__bucket = os.getenv('S3_BUCKET') self.__presigned_url_expiration_time = 30 + @log_execution def create_presigned_url(self, object_key) -> FileUploadOut: """Create a presigned url for uploading files to s3 @@ -54,12 +55,14 @@ def create_presigned_url(self, object_key) -> FileUploadOut: ) url_data = {'uploadLink': presigned_url, 'objectKey': unique_object_key} + logger.info(f'Presigned upload URL created for object_key: {unique_object_key}') return FileUploadOut(**url_data) except ClientError as e: logger.error('Error creating presigned url: %s', e) return JSONResponse(status_code=500, content={'message': 'Error creating presigned url'}) + @log_execution def create_download_url(self, object_key) -> FileDownloadOut: """Create a presigned url for downloading files from s3 @@ -85,6 +88,7 @@ def create_download_url(self, object_key) -> FileDownloadOut: status_code=HTTPStatus.INTERNAL_SERVER_ERROR, content={'message': 'Error fetching download url'} ) + @log_execution def upload_file(self, file_name: str, object_name: str = None, verbose: bool = True) -> bool: # If S3 object_name was not specified, use file_name if object_name is None: @@ -92,8 +96,7 @@ def upload_file(self, file_name: str, object_name: str = None, verbose: bool = T try: self.__s3_client.upload_file(file_name, self.__bucket, object_name) - if verbose: - logger.info(f'Stored file in S3: {self.__bucket}/{object_name}') + logger.info(f'File uploaded to S3: {self.__bucket}/{object_name}') except Exception as e: message = f'Failed to upload file ({file_name}) to S3, Reason: {type(e).__name__} - {str(e)}' logger.error(message) diff --git a/backend/usecase/payment_tracking_sqs_usecase.py b/backend/usecase/payment_tracking_sqs_usecase.py index a6830109..6aeccfe1 100644 --- a/backend/usecase/payment_tracking_sqs_usecase.py +++ b/backend/usecase/payment_tracking_sqs_usecase.py @@ -3,7 +3,7 @@ import boto3 from usecase.payment_tracking_usecase import PaymentTrackingUsecase -from utils.logger import logger +from utils.logger import log_execution, logger class PaymentTrackingSQSUsecase: @@ -12,6 +12,7 @@ def __init__(self): self.PAYMENT_QUEUE = os.environ.get('PAYMENT_QUEUE') self.payment_tracking_usecase = PaymentTrackingUsecase() + @log_execution def process_payment_message(self, event: dict) -> None: """ Processes payment messages received from an AWS SQS event and updates the transactionStatus of a payment_transaction. diff --git a/backend/usecase/payment_tracking_usecase.py b/backend/usecase/payment_tracking_usecase.py index 9161b2c0..c03168d2 100644 --- a/backend/usecase/payment_tracking_usecase.py +++ b/backend/usecase/payment_tracking_usecase.py @@ -12,7 +12,7 @@ from repository.payment_transaction_repository import PaymentTransactionRepository from repository.registrations_repository import RegistrationsRepository from usecase.email_usecase import EmailUsecase -from utils.logger import logger +from utils.logger import log_execution, logger, mask_email class PaymentTrackingUsecase: @@ -23,13 +23,14 @@ def __init__(self): self.payment_transaction_repository = PaymentTransactionRepository() self.registration_repository = RegistrationsRepository() + @log_execution def process_payment_event(self, message_body: dict) -> None: """ Processes a payment event message, updates the payment transaction status, and stores the registration details. """ try: - logger.info(f'Processing payment event message: {message_body}') + logger.info('Processing payment event message') self._update_timestamps(message_body) payment_tracking_body = PaymentTrackingBody(**message_body) @@ -66,7 +67,7 @@ def process_payment_event(self, message_body: dict) -> None: if status == HTTPStatus.OK and registration_details: logger.info( - f'Skipping duplicate email for {registration_data.email} - user already has existing registration' + f'Skipping duplicate email for {mask_email(registration_data.email)} - user already has existing registration' ) return @@ -76,6 +77,10 @@ def process_payment_event(self, message_body: dict) -> None: ) if not recorded_registration_data: logger.error(f'Failed to save registration for entryId {entry_id}') + else: + logger.info( + f'Registration created via payment tracking for entry_id={entry_id}, event_id={event_id}, email={mask_email(registration_data.email)}' + ) elif transaction_status == TransactionStatus.FAILED: status, registrations, msg = self.registration_repository.query_registrations_with_email( @@ -83,7 +88,7 @@ def process_payment_event(self, message_body: dict) -> None: ) if status == HTTPStatus.OK and registrations: logger.info( - f'Skipping failed payment email for {registration_data.email} - user already has existing registration' + f'Skipping failed payment email for {mask_email(registration_data.email)} - user already has existing registration' ) return @@ -96,7 +101,7 @@ def process_payment_event(self, message_body: dict) -> None: status=transaction_status, event_detail=event_detail, ) - logger.info(f'Successfully processed registration for {registration_data.email}') + logger.info(f'Successfully processed registration for {mask_email(registration_data.email)}') except Exception as e: logger.error(f'Failed to process successful payment for entryId {registration_details.entryId}: {e}') @@ -252,7 +257,7 @@ def _create_failed_body(name: str, transaction_id: str) -> list[str]: logger.error(f'No email template found for status: {status}') return - logger.info(f'Preparing to send email for event {event_detail.eventId} with status {status} to {email}.') + logger.info(f'Preparing to send email for event {event_detail.eventId} with status {status} to {mask_email(email)}.') email_in = EmailIn( to=[email], @@ -265,4 +270,4 @@ def _create_failed_body(name: str, transaction_id: str) -> list[str]: isDurianPy=is_pycon_event, ) self.email_usecase.send_email(email_in=email_in, event=event_detail) - logger.info(f'Email notification sent for event {event_detail.eventId} with status {status}.') + logger.info(f'Email notification sent for event {event_detail.eventId} with status {status} to {mask_email(email)}.') diff --git a/backend/usecase/payment_usecase.py b/backend/usecase/payment_usecase.py index 96a1c217..f485957b 100644 --- a/backend/usecase/payment_usecase.py +++ b/backend/usecase/payment_usecase.py @@ -20,7 +20,7 @@ from starlette.responses import JSONResponse from usecase.email_usecase import EmailUsecase from usecase.pycon_registration_usecase import PyconRegistrationUsecase -from utils.logger import logger +from utils.logger import log_execution, logger, mask_email class PaymentUsecase: @@ -30,6 +30,7 @@ def __init__(self): self.pycon_registration_usecase = PyconRegistrationUsecase() self.email_usecase = EmailUsecase() + @log_execution def create_payment_transaction(self, payment_transaction: PaymentTransactionIn) -> PaymentTransactionOut: """ Create a new payment transaction @@ -40,17 +41,16 @@ def create_payment_transaction(self, payment_transaction: PaymentTransactionIn) Returns: PaymentTransactionOut -- The created payment transaction """ - logger.info(f'Creating payment transaction for {payment_transaction.eventId}') - logger.info(f'Payment transaction data: {payment_transaction}') status, payment_transaction, message = self.payment_repo.store_payment_transaction(payment_transaction) if status != HTTPStatus.OK: logger.error(f'[{payment_transaction.eventId}] {message}') return JSONResponse(status_code=status, content={'message': message}) - logger.info(f'Payment transaction created for {payment_transaction.eventId}') + logger.info(f'Payment transaction created: transaction_id={payment_transaction.rangeKey}, event_id={payment_transaction.eventId}') payment_transaction_dict = self.__convert_data_entry_to_dict(payment_transaction) return PaymentTransactionOut(**payment_transaction_dict) + @log_execution def update_payment_transaction( self, payment_transaction_id: str, payment_transaction_in: PaymentTransactionIn ) -> PaymentTransactionOut: @@ -64,7 +64,6 @@ def update_payment_transaction( Returns: PaymentTransactionOut -- The updated payment transaction """ - logger.info(f'Updating payment transaction for {payment_transaction_id}') status, existing_payment_transaction, message = self.payment_repo.query_payment_transaction_by_id_only( payment_transaction_id=payment_transaction_id ) @@ -80,10 +79,11 @@ def update_payment_transaction( logger.error(f'[{payment_transaction_id}] {message}') return JSONResponse(status_code=status, content={'message': message}) - logger.info(f'Payment transaction updated for {payment_transaction_id}') + logger.info(f'Payment transaction updated: transaction_id={payment_transaction_id}, status={payment_transaction_in.transactionStatus}') payment_transaction_dict = self.__convert_data_entry_to_dict(updated_payment_transaction) return PaymentTransactionOut(**payment_transaction_dict) + @log_execution def query_pending_payment_transactions(self) -> list[PaymentTransactionOut]: """ Query all pending payment transactions @@ -114,6 +114,7 @@ def query_pending_payment_transactions(self) -> list[PaymentTransactionOut]: return payment_transaction_list + @log_execution def payment_callback(self, payment_transaction_id: str, event_id: str): """ Update the payment transaction status to SUCCESS and redirect to the success page @@ -183,7 +184,7 @@ def payment_callback(self, payment_transaction_id: str, event_id: str): content={'message': 'Redirecting to error page'}, ) - logger.info(f'Payment transaction updated for {payment_transaction_id}') + logger.info(f'Payment successful: transaction_id={payment_transaction_id}, event_id={event_id}') redirect_url = ( f'{frontend_base_url}/{event_id}/register?step=Success&paymentTransactionId={payment_transaction_id}' @@ -313,7 +314,7 @@ def _create_failed_body(event_name: str, transaction_id: str) -> list[str]: ) self.email_usecase.send_email(email_in=email_in, event=event_detail) - logger.info(f'[{payment_transaction_id}] Payment failed email sent to {email}') + logger.info(f'Payment failed email sent for transaction_id={payment_transaction_id} to {mask_email(email)}') except Exception as e: logger.error(f'[{payment_transaction_id}] Failed to send payment failed email: {e}') diff --git a/backend/usecase/preregistration_usecase.py b/backend/usecase/preregistration_usecase.py index ebfbfdd2..b13669bc 100644 --- a/backend/usecase/preregistration_usecase.py +++ b/backend/usecase/preregistration_usecase.py @@ -17,7 +17,7 @@ from starlette.responses import JSONResponse from usecase.email_usecase import EmailUsecase from usecase.file_s3_usecase import FileS3Usecase -from utils.logger import logger +from utils.logger import log_execution, logger, mask_email class PreRegistrationUsecase: @@ -36,6 +36,7 @@ def __init__(self): self.__email_usecase = EmailUsecase() self.__file_s3_usecase = FileS3Usecase() + @log_execution def create_preregistration(self, preregistration_in: PreRegistrationIn) -> Union[JSONResponse, PreRegistrationOut]: """Creates a new pre-registration entry. @@ -69,7 +70,7 @@ def create_preregistration(self, preregistration_in: PreRegistrationIn) -> Union if status == HTTPStatus.OK and preregistrations: return JSONResponse( status_code=HTTPStatus.CONFLICT, - content={'message': f'Pre-registration with email {email} already exists'}, + content={'message': f'Pre-registration with email {mask_email(email)} already exists'}, ) preregistration_id = ulid.ulid() @@ -84,6 +85,10 @@ def create_preregistration(self, preregistration_in: PreRegistrationIn) -> Union if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info( + f'Pre-registration created successfully: event_id={event_id}, preregistration_id={preregistration_id}, email={mask_email(preregistration_in.email)}' + ) + preregistration_data = self.__convert_data_entry_to_dict(preregistration) if not preregistration.preRegistrationEmailSent: @@ -92,6 +97,7 @@ def create_preregistration(self, preregistration_in: PreRegistrationIn) -> Union preregistration_out = PreRegistrationOut(**preregistration_data) return preregistration_out + @log_execution def update_preregistration( self, event_id: str, @@ -134,11 +140,13 @@ def update_preregistration( if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info(f'Pre-registration updated: event_id={event_id}, preregistration_id={preregistration_id}') preregistration_data = self.__convert_data_entry_to_dict(update_preregistration) preregistration_out = PreRegistrationOut(**preregistration_data) return preregistration_out + @log_execution def get_preregistration(self, event_id: str, preregistration_id: str) -> Union[JSONResponse, PreRegistrationOut]: """Retrieves a specific pre-registration entry by its ID. @@ -169,6 +177,7 @@ def get_preregistration(self, event_id: str, preregistration_id: str) -> Union[J preregistration_data = self.__convert_data_entry_to_dict(preregistration) return PreRegistrationOut(**preregistration_data) + @log_execution def get_preregistration_by_email(self, event_id: str, email: str) -> PreRegistrationOut: """Retrieves a specific pre-registration entry by its email. @@ -195,6 +204,7 @@ def get_preregistration_by_email(self, event_id: str, email: str) -> PreRegistra return PreRegistrationOut(**preregistration_data) + @log_execution def get_preregistrations(self, event_id: str = None) -> Union[JSONResponse, List[PreRegistrationOut]]: """Retrieves a list of pre-registration preregistration_entries. @@ -222,6 +232,7 @@ def get_preregistrations(self, event_id: str = None) -> Union[JSONResponse, List for preregistration in preregistrations ] + @log_execution def delete_preregistration(self, event_id: str, preregistration_id: str) -> Union[None, JSONResponse]: """Deletes a specific preregistration entry by its ID. @@ -255,8 +266,10 @@ def delete_preregistration(self, event_id: str, preregistration_id: str) -> Unio if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info(f'Pre-registration deleted: event_id={event_id}, preregistration_id={preregistration_id}') return None + @log_execution def get_preregistration_csv(self, event_id: str) -> FileDownloadOut: """Returns the FileDownloadOut of the CSV for the specified event diff --git a/backend/usecase/pycon_registration_email_notification.py b/backend/usecase/pycon_registration_email_notification.py index 2dcf52ea..c2194aee 100644 --- a/backend/usecase/pycon_registration_email_notification.py +++ b/backend/usecase/pycon_registration_email_notification.py @@ -8,7 +8,7 @@ from repository.events_repository import EventsRepository from repository.registrations_repository import RegistrationsRepository from usecase.email_usecase import EmailUsecase -from utils.logger import logger +from utils.logger import log_execution, logger, mask_email class PyConRegistrationEmailNotification: @@ -17,15 +17,17 @@ def __init__(self): self.__registrations_repository = RegistrationsRepository() self.__events_repository = EventsRepository() + @log_execution def send_registration_success_email(self, email: str, event: Event, is_pycon_event: bool = True) -> None: - logger.info(f'Preparing to send registration success email to {email} for event {event.name}') + masked_email = mask_email(email) + logger.info(f'Preparing to send registration success email to {masked_email} for event {event.name}') _, registration, _ = self.__registrations_repository.query_registrations_with_email( email=email, event_id=event.eventId ) registration_data = registration[0] if registration else None if not registration: - logger.error(f'No registration found for email: {email} and event_id: {event.eventId}') + logger.error(f'No registration found for email: {masked_email} and event_id: {event.eventId}') return body = [ @@ -61,11 +63,15 @@ def send_registration_success_email(self, email: str, event: Event, is_pycon_eve isDurianPy=is_pycon_event, ) self.__email_usecase.send_email(email_in=email_in, event=event) - logger.info(f'Sent registration success email to {email} for event {event.name}') + logger.info( + f'Registration success email sent to {masked_email} for event {event.name} (event_id={event.eventId})' + ) + @log_execution def send_registration_failure_email( self, email: str, event: Event, payment_transaction: PaymentTransactionOut, is_pycon_event: bool = True ) -> None: + masked_email = mask_email(email) body = [ f'There was an issue processing your payment for {event.name}. Please check your payment details or try again.', f'If the problem persists, please contact our support team at durianpy.davao@gmail.com and present your transaction ID: {payment_transaction.transactionId}.' @@ -89,9 +95,14 @@ def send_registration_failure_email( ) self.__email_usecase.send_email(email_in=email_in, event=event) - logger.info(f'Sent registration failure email to {email} for event {event.name}') + logger.info( + f'Registration failure email sent to {masked_email} for event {event.name} ' + f'(transaction_id={payment_transaction.transactionId})' + ) + @log_execution def resend_confirmation_email(self, event_id: str, email: str) -> JSONResponse: + masked_email = mask_email(email) event_status, event_detail, event_message = self.__events_repository.query_events(event_id=event_id) if event_status != HTTPStatus.OK: return JSONResponse(status_code=event_status, content={'message': event_message}) @@ -105,15 +116,15 @@ def resend_confirmation_email(self, event_id: str, email: str) -> JSONResponse: return JSONResponse(status_code=HTTPStatus.NOT_FOUND, content={'message': message}) logger.info( - f'Found registration for email {email} and event {event_detail.name}, resending confirmation email.' + f'Found registration for email {masked_email} and event {event_detail.name}, resending confirmation email.' ) try: self.send_registration_success_email(email=email, event=event_detail, is_pycon_event=True) - logger.info(f'Resent confirmation email to {email} for event {event_id}') + logger.info(f'Resent confirmation email to {masked_email} for event {event_id}') return JSONResponse(status_code=HTTPStatus.OK, content={'message': f'Confirmation email sent to {email}'}) except Exception as e: - logger.error(f'Failed to resend confirmation email to {email}: {e}') + logger.error(f'Failed to resend confirmation email to {masked_email}: {e}') return JSONResponse( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, content={'message': 'Failed to send email.'} ) diff --git a/backend/usecase/pycon_registration_usecase.py b/backend/usecase/pycon_registration_usecase.py index dc7c6ca4..de6b5bf9 100644 --- a/backend/usecase/pycon_registration_usecase.py +++ b/backend/usecase/pycon_registration_usecase.py @@ -20,7 +20,7 @@ from usecase.discount_usecase import DiscountUsecase from usecase.email_usecase import EmailUsecase from usecase.file_s3_usecase import FileS3Usecase -from utils.logger import logger +from utils.logger import log_execution, logger, mask_email class PyconRegistrationUsecase: @@ -43,6 +43,7 @@ def __init__(self): self.__ticket_type_repository = TicketTypeRepository() self.__payment_transaction_repository = PaymentTransactionRepository() + @log_execution def create_pycon_registration( self, registration_in: PyconRegistrationIn ) -> Union[JSONResponse, PyconRegistrationOut]: @@ -55,7 +56,7 @@ def create_pycon_registration( :rtype: Union[JSONResponse, PyconRegistrationOut] """ - logger.info(f'Saving PyCon registration: {registration_in}') + logger.info(f'Saving PyCon registration for email={mask_email(registration_in.email)}, event_id={registration_in.eventId}') status, event, message = self.__events_repository.query_events(event_id=registration_in.eventId) if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) @@ -182,6 +183,10 @@ def create_pycon_registration( if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info( + f'PyCon registration created successfully: event_id={event_id}, registration_id={registration_id}, email={mask_email(registration_in.email)}' + ) + status, __, message = self.__events_repository.append_event_registration_count( event_entry=event, registration_sprint_day=registration_in.sprintDay ) @@ -203,6 +208,7 @@ def create_pycon_registration( registration_out = PyconRegistrationOut(**registration_data) return self.collect_pre_signed_url_pycon(registration_out) + @log_execution def update_pycon_registration( self, event_id: str, registration_id: str, registration_in: PyconRegistrationPatch ) -> Union[JSONResponse, PyconRegistrationOut]: @@ -245,10 +251,12 @@ def update_pycon_registration( if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info(f'PyCon registration updated: event_id={event_id}, registration_id={registration_id}') registration_data = self.__convert_data_entry_to_dict(update_registration) registration_out = PyconRegistrationOut(**registration_data) return self.collect_pre_signed_url_pycon(registration_out) + @log_execution def get_pycon_registration(self, event_id: str, registration_id: str) -> Union[JSONResponse, PyconRegistrationOut]: """Retrieves a specific PyCon registration entry by its ID. @@ -281,6 +289,7 @@ def get_pycon_registration(self, event_id: str, registration_id: str) -> Union[J registration_out = PyconRegistrationOut(**registration_data) return self.collect_pre_signed_url_pycon(registration_out) + @log_execution def get_pycon_registration_by_email(self, event_id: str, email: str) -> Union[JSONResponse, PyconRegistrationOut]: """Retrieves a specific PyCon registration entry by its email. @@ -308,6 +317,7 @@ def get_pycon_registration_by_email(self, event_id: str, email: str) -> Union[JS return self.collect_pre_signed_url_pycon(registration_out) + @log_execution def get_pycon_registrations( self, event_id: str = None, is_deleted: bool = False ) -> Union[JSONResponse, List[PyconRegistrationOut]]: @@ -337,6 +347,7 @@ def get_pycon_registrations( for registration in registrations ] + @log_execution def get_pycon_registration_csv(self, event_id: str) -> FileDownloadOut: """Returns the FileDownloadOut of the CSV for the specified PyCon event @@ -378,6 +389,7 @@ def get_pycon_registration_csv(self, event_id: str) -> FileDownloadOut: logger.error(f'Error generating the PyCon CSV for {event_id}: {e}') return + @log_execution def delete_pycon_registration(self, event_id: str, registration_id: str) -> Union[None, JSONResponse]: """Deletes a specific PyCon registration entry by its ID. @@ -409,8 +421,10 @@ def delete_pycon_registration(self, event_id: str, registration_id: str) -> Unio if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info(f'PyCon registration deleted: event_id={event_id}, registration_id={registration_id}') return None + @log_execution def collect_pre_signed_url_pycon(self, registration: PyconRegistrationOut) -> PyconRegistrationOut: """Collects the pre-signed URL for the valid ID image for PyCon registrations. @@ -427,6 +441,7 @@ def collect_pre_signed_url_pycon(self, registration: PyconRegistrationOut) -> Py return registration + @log_execution def resend_confirmation_email(self, event_id: str, email: str): """Resends the registration confirmation email for a specific PyCon registration entry. @@ -450,7 +465,7 @@ def resend_confirmation_email(self, event_id: str, email: str): if status == HTTPStatus.OK and registrations and registrations[0].transactionId: registration = registrations[0] - logger.info(f'Resending confirmation email to {email} for event {event_id}') + logger.info(f'Resending confirmation email for event {event_id} to {mask_email(email)}') self.__email_usecase.send_registration_creation_email(registration=registration, event=event) return JSONResponse(status_code=HTTPStatus.OK, content={'message': f'Confirmation email sent to {email}'}) diff --git a/backend/usecase/registration_usecase.py b/backend/usecase/registration_usecase.py index cf7abbc0..60645c83 100644 --- a/backend/usecase/registration_usecase.py +++ b/backend/usecase/registration_usecase.py @@ -27,7 +27,7 @@ from usecase.email_usecase import EmailUsecase from usecase.file_s3_usecase import FileS3Usecase from usecase.preregistration_usecase import PreRegistrationUsecase -from utils.logger import logger +from utils.logger import log_execution, logger, mask_email class RegistrationUsecase: @@ -51,6 +51,7 @@ def __init__(self): self.__konfhub_gateway = KonfHubGateway() self.__payment_transaction_repository = PaymentTransactionRepository() + @log_execution def create_registration(self, registration_in: RegistrationIn) -> Union[JSONResponse, RegistrationOut]: """Creates a new registration entry. @@ -103,7 +104,7 @@ def create_registration(self, registration_in: RegistrationIn) -> Union[JSONResp message, ) = self.__registrations_repository.query_registrations_with_email(event_id=event_id, email=email) if status == HTTPStatus.OK and registrations: - logger.info(f'Registration with email {email} already exists, returning existing registration') + logger.info(f'Registration with email {mask_email(email)} already exists, returning existing registration') registration = registrations[0] registration_data = self.__convert_data_entry_to_dict(registration) registration_out = RegistrationOut(**registration_data) @@ -160,6 +161,10 @@ def create_registration(self, registration_in: RegistrationIn) -> Union[JSONResp if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info( + f'User registration created successfully: event_id={event_id}, registration_id={registration_id}, email={mask_email(registration_in.email)}' + ) + status, __, message = self.__events_repository.append_event_registration_count(event_entry=event) if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) @@ -185,6 +190,7 @@ def create_registration(self, registration_in: RegistrationIn) -> Union[JSONResp registration_out = RegistrationOut(**registration_data) return self.collect_pre_signed_url(registration_out) + @log_execution def create_registration_approval_flow( self, event: Event, registration_in: RegistrationIn ) -> Union[JSONResponse, RegistrationOut]: @@ -227,6 +233,10 @@ def create_registration_approval_flow( if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info( + f'Approval flow registration created successfully: event_id={event_id}, registration_id={registration_id}, email={mask_email(registration_in.email)}' + ) + status, __, message = self.__events_repository.append_event_registration_count(event_entry=event) if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) @@ -244,6 +254,7 @@ def create_registration_approval_flow( registration_out = RegistrationOut(**registration_data) return self.collect_pre_signed_url(registration_out) + @log_execution def update_registration( self, event_id: str, registration_id: str, registration_in: RegistrationIn ) -> Union[JSONResponse, RegistrationOut]: @@ -282,10 +293,12 @@ def update_registration( if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info(f'Registration updated: event_id={event_id}, registration_id={registration_id}') registration_data = self.__convert_data_entry_to_dict(update_registration) registration_out = RegistrationOut(**registration_data) return self.collect_pre_signed_url(registration_out) + @log_execution def get_registration(self, event_id: str, registration_id: str) -> Union[JSONResponse, RegistrationOut]: """Retrieves a specific registration entry by its ID. @@ -318,6 +331,7 @@ def get_registration(self, event_id: str, registration_id: str) -> Union[JSONRes registration_out = RegistrationOut(**registration_data) return self.collect_pre_signed_url(registration_out) + @log_execution def get_registration_by_email(self, event_id: str, email: str) -> RegistrationOut: """Retrieves a specific registration entry by its email. @@ -345,6 +359,7 @@ def get_registration_by_email(self, event_id: str, email: str) -> RegistrationOu return self.collect_pre_signed_url(registration_out) + @log_execution def get_registrations( self, event_id: str = None, is_deleted: bool = False ) -> Union[JSONResponse, List[RegistrationOut]]: @@ -374,6 +389,7 @@ def get_registrations( for registration in registrations ] + @log_execution def get_registration_csv(self, event_id: str) -> FileDownloadOut: """Returns the FileDownloadOut of the CSV for the specified event @@ -433,6 +449,7 @@ def get_registration_csv(self, event_id: str) -> FileDownloadOut: logger.error(f'Error generating the CSV for {event_id}: {e}') return + @log_execution def delete_registration(self, event_id: str, registration_id: str) -> Union[None, JSONResponse]: """Deletes a specific registration entry by its ID. @@ -464,8 +481,10 @@ def delete_registration(self, event_id: str, registration_id: str) -> Union[None if status != HTTPStatus.OK: return JSONResponse(status_code=status, content={'message': message}) + logger.info(f'Registration deleted: event_id={event_id}, registration_id={registration_id}') return None + @log_execution def collect_pre_signed_url(self, registration: RegistrationOut) -> RegistrationOut: """Collects the pre-signed URL for the GCash payment image. @@ -482,6 +501,7 @@ def collect_pre_signed_url(self, registration: RegistrationOut) -> RegistrationO return registration + @log_execution def collect_pre_signed_url_pycon(self, registration: RegistrationOut) -> RegistrationOut: """Collects the pre-signed URL for the valid ID image. @@ -498,6 +518,7 @@ def collect_pre_signed_url_pycon(self, registration: RegistrationOut) -> Registr return registration + @log_execution def register_konfhub(self, registration_in: RegistrationIn, event_id: str, event: Event): ticket_type_id = registration_in.ticketTypeId if not ticket_type_id: diff --git a/backend/utils/logger.py b/backend/utils/logger.py index 25859499..9b16c604 100644 --- a/backend/utils/logger.py +++ b/backend/utils/logger.py @@ -1,14 +1,550 @@ +"""Logging module providing singleton Logger, decorator, and mask_string utility.""" + +from __future__ import annotations + +import datetime + +import functools +import inspect import logging import os -from sys import stdout - -logger = logging.getLogger('techtix-events-service') -handler = logging.StreamHandler(stdout) -if os.getenv('AWS_EXECUTION_ENV'): # pragma: no cover - logger.propagate = False - log_formatter = logging.Formatter('[%(levelname)s] %(message)s') -else: - log_formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s') -handler.setFormatter(log_formatter) -logger.addHandler(handler) -logger.setLevel(os.getenv('LOG_LEVEL', logging.getLevelName(logging.DEBUG))) +import sys +import threading +from typing import Any, Callable, Optional, Type, TypeVar, Union + +try: + from src.core.settings import LogLevel, Settings # type: ignore[import-not-found] +except ImportError: + try: + from constants.common_constants import LogLevel + except ImportError: + from enum import Enum + + class LogLevel(str, Enum): # type: ignore[no-redef] + """Supported logging severity levels.""" + + CRITICAL = 'CRITICAL' + FATAL = 'FATAL' + ERROR = 'ERROR' + WARNING = 'WARNING' + WARN = 'WARN' + INFO = 'INFO' + DEBUG = 'DEBUG' + NOTSET = 'NOTSET' + + class Settings: + """Application settings for logger configuration.""" + + APP_NAME: str = os.getenv('APP_NAME', 'techtix-events-service') + LOG_LEVEL: Union[str, LogLevel] = os.getenv('LOG_LEVEL', LogLevel.DEBUG.value) + + def __init__( + self, + app_name: Optional[str] = None, + log_level: Optional[Union[str, LogLevel]] = None, + ) -> None: + """ + Initialize settings with environment defaults or overrides. + + :param app_name: Optional application name identifier. + :type app_name: Optional[str] + :param log_level: Optional logging level threshold. + :type log_level: Optional[Union[str, LogLevel]] + """ + self.APP_NAME: str = app_name or os.getenv('APP_NAME', 'techtix-events-service') + self.LOG_LEVEL: Union[str, LogLevel] = log_level or os.getenv('LOG_LEVEL', LogLevel.DEBUG.value) + +F = TypeVar('F', bound=Callable[..., Any]) + + +class _ISOFormatter(logging.Formatter): + """Logging formatter that outputs timestamps in ISO 8601 format.""" + + def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str: + """ + Format record timestamp into ISO 8601 string representation. + + :param record: Log record containing timestamp. + :type record: logging.LogRecord + :param datefmt: Optional date format string (ignored in favor of ISO 8601). + :type datefmt: Optional[str] + :returns: ISO 8601 formatted timestamp string. + :rtype: str + """ + dt = datetime.datetime.fromtimestamp(record.created).astimezone() + return dt.isoformat() + + def format(self, record: logging.LogRecord) -> str: + uid = getattr(record, 'user_id', None) or os.getenv('CURRENT_USER') + if uid: + msg_str = str(record.msg) + if '[userid=' not in msg_str and '[user=' not in msg_str: + record.msg = f'[userid={uid}] {record.msg}' + return super().format(record) + + +class Logger: + """Singleton Logger class wrapping Python's standard logging module.""" + + __instance: Optional['Logger'] = None + __lock: threading.Lock = threading.Lock() + + def __new__(cls, *_args: Any, **_kwargs: Any) -> 'Logger': + """ + Create or return the singleton Logger instance. + + :returns: The singleton Logger instance. + :rtype: Logger + """ + if cls.__instance is None: + with cls.__lock: + if cls.__instance is None: + instance = super().__new__(cls) + instance.__initialized = False + cls.__instance = instance + return cls.__instance + + def __init__( + self, + name: Optional[str] = None, + level: Optional[Union[str, int, LogLevel]] = None, + ) -> None: + """ + Initialize the Logger instance if not already initialized. + + :param name: Optional logger name identifier. + :type name: Optional[str] + :param level: Optional log severity level threshold. + :type level: Optional[Union[str, int, LogLevel]] + :returns: None + :rtype: None + """ + if self.__initialized: + return + + with self.__lock: + if self.__initialized: + return + + settings = Settings() + logger_name = name or settings.APP_NAME + raw_level = level if level is not None else settings.LOG_LEVEL + + if isinstance(raw_level, LogLevel): + str_level = raw_level.value + elif isinstance(raw_level, str): + str_level = raw_level + else: + str_level = None + + if str_level is not None: + int_level = getattr(logging, str_level.upper(), logging.INFO) + else: + int_level = int(raw_level) + + self.__logger = logging.getLogger(logger_name) + self.__logger.setLevel(int_level) + + if not self.__logger.handlers: + handler = logging.StreamHandler(sys.stdout) + if os.getenv('AWS_EXECUTION_ENV'): # pragma: no cover + self.__logger.propagate = False + formatter = _ISOFormatter('[%(levelname)s] %(message)s') + else: + formatter = _ISOFormatter('%(asctime)s [%(levelname)s] %(message)s') + handler.setFormatter(formatter) + self.__logger.addHandler(handler) + + self.__initialized = True + + @classmethod + def get_logger( + cls, + logger_name: Optional[str] = None, + log_level: Optional[Union[str, int, LogLevel]] = None, + ) -> 'Logger': + """ + Initialize and return the configured singleton Logger instance. + + :param logger_name: Optional logger name identifier. + :type logger_name: Optional[str] + :param log_level: Optional log level threshold. + :type log_level: Optional[Union[str, int, LogLevel]] + :returns: Configured singleton Logger instance. + :rtype: Logger + """ + return cls(name=logger_name, level=log_level) + + @classmethod + def _reset(cls) -> None: + """Reset the singleton instance (intended for test isolation).""" + with cls.__lock: + cls.__instance = None + + def setLevel(self, level: Union[str, int, LogLevel]) -> None: + """ + Set the logging severity level threshold. + + :param level: Severity level threshold. + :type level: Union[str, int, LogLevel] + :returns: None + :rtype: None + """ + if isinstance(level, LogLevel): + str_level = level.value + elif isinstance(level, str): + str_level = level + else: + str_level = None + + if str_level is not None: + int_level = getattr(logging, str_level.upper(), logging.INFO) + else: + int_level = int(level) + + self.__logger.setLevel(int_level) + + @staticmethod + def log_execution(domain_exception: Optional[Any] = None) -> Any: + """ + Log function/method entrypoint and optional domain exception mapping. + + :param domain_exception: Optional domain exception class to re-raise upon + error, or decorated target function. + :type domain_exception: Optional[Any] + :returns: Decorated target function or decorator wrapper. + :rtype: Any + """ + return log_execution(domain_exception) + + def _format_message(self, msg: Any, user_id: Optional[str] = None) -> str: + """ + Prepend [userid=] to log message if a user is authenticated and not already tagged. + + :param msg: Original log message. + :type msg: Any + :param user_id: Optional explicit user ID. + :type user_id: Optional[str] + :returns: Formatted message with user tag if applicable. + :rtype: str + """ + str_msg = str(msg) + uid = user_id or os.getenv('CURRENT_USER') + if uid and '[userid=' not in str_msg and '[user=' not in str_msg: + return f'[userid={uid}] {str_msg}' + return str_msg + + def debug(self, msg: Any, *args: Any, **kwargs: Any) -> None: + """Log message with DEBUG severity, automatically tagging user context.""" + user_id = kwargs.pop('user_id', None) + self.__logger.debug(self._format_message(msg, user_id), *args, **kwargs) + + def info(self, msg: Any, *args: Any, **kwargs: Any) -> None: + """Log message with INFO severity, automatically tagging user context.""" + user_id = kwargs.pop('user_id', None) + self.__logger.info(self._format_message(msg, user_id), *args, **kwargs) + + def warning(self, msg: Any, *args: Any, **kwargs: Any) -> None: + """Log message with WARNING severity, automatically tagging user context.""" + user_id = kwargs.pop('user_id', None) + self.__logger.warning(self._format_message(msg, user_id), *args, **kwargs) + + def warn(self, msg: Any, *args: Any, **kwargs: Any) -> None: + """Log message with WARNING severity, automatically tagging user context.""" + user_id = kwargs.pop('user_id', None) + self.__logger.warning(self._format_message(msg, user_id), *args, **kwargs) + + def error(self, msg: Any, *args: Any, **kwargs: Any) -> None: + """Log message with ERROR severity, automatically tagging user context.""" + user_id = kwargs.pop('user_id', None) + self.__logger.error(self._format_message(msg, user_id), *args, **kwargs) + + def critical(self, msg: Any, *args: Any, **kwargs: Any) -> None: + """Log message with CRITICAL severity, automatically tagging user context.""" + user_id = kwargs.pop('user_id', None) + self.__logger.critical(self._format_message(msg, user_id), *args, **kwargs) + + def exception(self, msg: Any, *args: Any, **kwargs: Any) -> None: + """Log message with ERROR severity including exception trace and user context.""" + user_id = kwargs.pop('user_id', None) + self.__logger.exception(self._format_message(msg, user_id), *args, **kwargs) + + def log(self, level: int, msg: Any, *args: Any, **kwargs: Any) -> None: + """Log message with specified severity level, automatically tagging user context.""" + user_id = kwargs.pop('user_id', None) + self.__logger.log(level, self._format_message(msg, user_id), *args, **kwargs) + + @staticmethod + def mask_string( + value: Optional[str], + visible_prefix: int = 2, + visible_suffix: int = 2, + mask_char: str = '*', + ) -> str: + """ + Mask sensitive string data while preserving visible prefix and suffix. + + :param value: The string value to mask. + :type value: Optional[str] + :param visible_prefix: Number of characters to leave visible at the start. + :type visible_prefix: int + :param visible_suffix: Number of characters to leave visible at the end. + :type visible_suffix: int + :param mask_char: Masking character used to hide middle content. + :type mask_char: str + :returns: The masked string representation. + :rtype: str + """ + return mask_string( + value, + visible_prefix=visible_prefix, + visible_suffix=visible_suffix, + mask_char=mask_char, + ) + + @staticmethod + def mask_email(email: Optional[str]) -> str: + """ + Mask email address for privacy-safe logging. + + :param email: Email address to mask. + :type email: Optional[str] + :returns: Masked email string. + :rtype: str + """ + return mask_email(email) + + def __getattr__(self, name: str) -> Any: + """ + Delegate attribute access to underlying logging.Logger instance. + + :param name: Attribute or method name to access. + :type name: str + :returns: Attribute from underlying logger instance. + :rtype: Any + """ + logger_inst = self.__dict__.get('_Logger__logger') + if logger_inst is not None: + return getattr(logger_inst, name) + raise AttributeError(f"'Logger' object has no attribute '{name}'") + + +def __extract_class_name(func: Callable[..., Any], args: tuple[Any, ...]) -> str: + """ + Extract class name or module name for logging format. + + :param func: Target callable function or method. + :type func: Callable[..., Any] + :param args: Positional arguments passed to the function. + :type args: tuple[Any, ...] + :returns: Extracted class or module name. + :rtype: str + """ + if args: + first_arg = args[0] + if inspect.isclass(first_arg): + return first_arg.__name__ + if hasattr(first_arg, '__class__') and '.' in getattr(func, '__qualname__', ''): + return first_arg.__class__.__name__ + + qualname = getattr(func, '__qualname__', '') + if '.' in qualname: + return qualname.rsplit('.', 1)[0] + + return getattr(func, '__module__', 'App') + + +def __extract_user_id(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Optional[str]: + """ + Extract authenticated user ID from function parameters or environment context. + + :param args: Positional arguments passed to the function. + :type args: tuple[Any, ...] + :param kwargs: Keyword arguments passed to the function. + :type kwargs: dict[str, Any] + :returns: User ID string if found, otherwise None. + :rtype: Optional[str] + """ + if 'current_user' in kwargs: + current_user = kwargs['current_user'] + if hasattr(current_user, 'sub') and getattr(current_user, 'sub'): + return str(getattr(current_user, 'sub')) + if isinstance(current_user, str) and current_user: + return current_user + + if 'user_id' in kwargs and kwargs['user_id']: + return str(kwargs['user_id']) + + for arg in args: + if hasattr(arg, 'sub') and getattr(arg, 'sub'): + return str(getattr(arg, 'sub')) + + env_user = os.getenv('CURRENT_USER') + if env_user: + return env_user + + return None + + +def __decorate( + func: Callable[..., Any], + domain_exception: Optional[Type[BaseException]], +) -> Any: + """ + Apply entrypoint logging and domain exception mapping to a function. + + :param func: Function to decorate. + :type func: Callable[..., Any] + :param domain_exception: Optional domain exception class to re-raise upon error. + :type domain_exception: Optional[Type[BaseException]] + :returns: Decorated sync or async wrapper function. + :rtype: Any + """ + logger = Logger() + + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + classname = __extract_class_name(func, args) + methodname = getattr(func, '__name__', str(func)) + user_id = __extract_user_id(args, kwargs) + user_tag = f' [userid={user_id}]' if user_id else '' + logger.info(f'[{classname}]{user_tag} Executing {methodname}') + try: + return await func(*args, **kwargs) + except Exception as exc: + logger.error(f'[{classname}]{user_tag} Exception in {methodname}: {exc}') + if domain_exception is not None: + if isinstance(exc, domain_exception): + raise + try: + raise domain_exception(str(exc)) from exc + except TypeError: + raise domain_exception() from exc + raise + + return async_wrapper + + @functools.wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + classname = __extract_class_name(func, args) + methodname = getattr(func, '__name__', str(func)) + user_id = __extract_user_id(args, kwargs) + user_tag = f' [userid={user_id}]' if user_id else '' + logger.info(f'[{classname}]{user_tag} Executing {methodname}') + try: + return func(*args, **kwargs) + except Exception as exc: + logger.error(f'[{classname}]{user_tag} Exception in {methodname}: {exc}') + if domain_exception is not None: + if isinstance(exc, domain_exception): + raise + try: + raise domain_exception(str(exc)) from exc + except TypeError: + raise domain_exception() from exc + raise + + return sync_wrapper + + +def log_execution( + domain_exception: Optional[Any] = None, +) -> Any: + """ + Log function/method entrypoint and optional domain exception mapping. + + :param domain_exception: Optional domain exception class to re-raise upon + error, or decorated target function. + :type domain_exception: Optional[Any] + :returns: Decorated target function or decorator wrapper. + :rtype: Any + """ + if callable(domain_exception) and not ( + inspect.isclass(domain_exception) and issubclass(domain_exception, BaseException) + ): + func = domain_exception + return __decorate(func, None) + + def decorator(func: F) -> F: + return __decorate(func, domain_exception) + + return decorator + + +def mask_string( + value: Optional[str], + visible_prefix: int = 2, + visible_suffix: int = 2, + mask_char: str = '*', +) -> str: + """ + Mask sensitive string data while preserving visible prefix and suffix characters. + + :param value: The string value to mask. + :type value: Optional[str] + :param visible_prefix: Number of characters to leave visible at the start. + :type visible_prefix: int + :param visible_suffix: Number of characters to leave visible at the end. + :type visible_suffix: int + :param mask_char: Masking character used to hide middle content. + :type mask_char: str + :returns: The masked string representation. + :rtype: str + """ + if not value: + return '' + + str_val = str(value) + length = len(str_val) + + prefix_len = max(0, visible_prefix) + suffix_len = max(0, visible_suffix) + + if length <= prefix_len + suffix_len: + return mask_char * length + + prefix = str_val[:prefix_len] + suffix = str_val[length - suffix_len :] if suffix_len > 0 else '' + masked_part = mask_char * (length - prefix_len - suffix_len) + + return f'{prefix}{masked_part}{suffix}' + + +def mask_email(email: Optional[str]) -> str: + """ + Mask sensitive email address while keeping domain and only masking the middle 2 characters of the local part. + + Example: 'john.doe@example.com' -> 'joh**doe@example.com' + + :param email: Email string to mask. + :type email: Optional[str] + :returns: Masked email string representation. + :rtype: str + """ + if not email: + return '' + + str_email = str(email).strip() + if '@' not in str_email: + return mask_string(str_email) + + local_part, domain = str_email.rsplit('@', 1) + n = len(local_part) + if n <= 1: + masked_local = '*' * n + elif n == 2: + masked_local = '**' + elif n == 3: + masked_local = f'{local_part[0]}*{local_part[-1]}' + else: + start = (n - 2) // 2 + end = start + 2 + masked_local = f'{local_part[:start]}**{local_part[end:]}' + + return f'{masked_local}@{domain}' + + + +logger = Logger() diff --git a/frontend/src/api/payments.ts b/frontend/src/api/payments.ts index 703ee47d..1a4794b0 100644 --- a/frontend/src/api/payments.ts +++ b/frontend/src/api/payments.ts @@ -4,14 +4,17 @@ import { createApi } from './utils/createApi'; export const getTransactionDetails = (transactionDetails: TransactionDetails) => createApi({ method: 'post', + authorize: true, apiService: 'payments', url: '/transaction/fees', body: { ...transactionDetails } }); + export const createEwalletPaymentRequest = (paymentDetails: EWalletPaymentIn) => createApi({ method: 'post', + authorize: true, apiService: 'payments', url: '/e_wallet/payment_method', body: { ...paymentDetails } @@ -20,7 +23,9 @@ export const createEwalletPaymentRequest = (paymentDetails: EWalletPaymentIn) => export const initiateDirectDebitPayment = (paymentDetails: DirectDebitPaymentIn) => createApi({ method: 'post', + authorize: true, apiService: 'payments', url: '/direct_debit/payment_request', body: { ...paymentDetails } }); + diff --git a/frontend/src/api/pycon/payments.ts b/frontend/src/api/pycon/payments.ts index 84f8885b..14678665 100644 --- a/frontend/src/api/pycon/payments.ts +++ b/frontend/src/api/pycon/payments.ts @@ -4,14 +4,17 @@ import { createApi } from '../utils/createApi'; export const getTransactionDetails = (transactionDetails: TransactionDetails) => createApi({ method: 'post', + authorize: true, apiService: 'payments', url: '/transaction/fees', body: { ...transactionDetails } }); + export const createEwalletPaymentRequest = (paymentDetails: EWalletPaymentIn) => createApi({ method: 'post', + authorize: true, apiService: 'payments', url: '/e_wallet/payment_method', body: { ...paymentDetails } @@ -20,7 +23,9 @@ export const createEwalletPaymentRequest = (paymentDetails: EWalletPaymentIn) => export const initiateDirectDebitPayment = (paymentDetails: DirectDebitPaymentIn) => createApi({ method: 'post', + authorize: true, apiService: 'payments', url: '/direct_debit/payment_request', body: { ...paymentDetails } }); +