From 402bc6e04247d7bfbf2f23804f0048e9792f7480 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Wed, 5 Nov 2025 22:17:05 -0600 Subject: [PATCH 01/46] Initial commit --- .python-version | 1 + app/python/src/main.py | 6 +++--- pyproject.toml | 7 +++++++ 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 .python-version create mode 100644 pyproject.toml diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/app/python/src/main.py b/app/python/src/main.py index e9fdd3b..0ccb4ea 100755 --- a/app/python/src/main.py +++ b/app/python/src/main.py @@ -1,5 +1,5 @@ def handler(event, context): return { - 'statusCode': 200, - 'body': 'Hello from Python Lambda!' - } \ No newline at end of file + "statusCode": 200, + "body": "Hello from Python Lambda!", + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6bd6b54 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "fds-aws-coding-exercise" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] From 8538bced38a0274e49d1331463800b753ae23bb2 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Wed, 5 Nov 2025 22:22:49 -0600 Subject: [PATCH 02/46] Adding uv.lock --- pyproject.toml | 4 +++- uv.lock | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 uv.lock diff --git a/pyproject.toml b/pyproject.toml index 6bd6b54..6b10a8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,4 +4,6 @@ version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "python-dotenv>=1.2.1", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1fa1e4b --- /dev/null +++ b/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "fds-aws-coding-exercise" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "python-dotenv" }, +] + +[package.metadata] +requires-dist = [{ name = "python-dotenv", specifier = ">=1.2.1" }] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 }, +] From c9e7ece4d8a9ed08f988a1955ee13f09db6305df Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Wed, 5 Nov 2025 22:46:22 -0600 Subject: [PATCH 03/46] feat: add CSV ignore rule and enhance build tooling - Add *.csv to .gitignore to exclude data files - Add set_env_variables target for AWS credential management - Add export_serverless_requirements target for Python dependency compilation - Update requirements.txt with uv-generated dependencies including python-dotenv --- .gitignore | 3 ++- Makefile | 12 +++++++++++- app/python/requirements.txt | 5 ++++- 3 files changed, 17 insertions(+), 3 deletions(-) mode change 100755 => 100644 app/python/requirements.txt diff --git a/.gitignore b/.gitignore index c92f186..226d6e7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ node_modules/ .env vendor/ -.idea/ \ No newline at end of file +.idea/ +*.csv \ No newline at end of file diff --git a/Makefile b/Makefile index 45a0024..a007e73 100755 --- a/Makefile +++ b/Makefile @@ -1,3 +1,4 @@ + deploy-env: scripts/deploy-env.sh > /dev/null @@ -8,4 +9,13 @@ deploy-python: scripts/deploy-python.sh > /dev/null deploy-node: - scripts/deploy-node.sh > /dev/null \ No newline at end of file + scripts/deploy-node.sh > /dev/null + +set_env_variables: + @echo "Setting AWS Credentials..." + export $(grep -v '^#' .env) + +# Python only +export_serverless_requirements: + @echo "Exporting requirements..." + uv pip compile pyproject.toml -o app/python/requirements.txt \ No newline at end of file diff --git a/app/python/requirements.txt b/app/python/requirements.txt old mode 100755 new mode 100644 index 1db657b..aa1f361 --- a/app/python/requirements.txt +++ b/app/python/requirements.txt @@ -1 +1,4 @@ -boto3 \ No newline at end of file +# This file was autogenerated by uv via the following command: +# uv pip compile pyproject.toml -o app/python/requirements.txt +python-dotenv==1.2.1 + # via fds-aws-coding-exercise (pyproject.toml) From a8b0c592d897061a47e873b95cf90df430cad4ff Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Wed, 5 Nov 2025 23:24:46 -0600 Subject: [PATCH 04/46] feat: add lambda status check and switch to uv for pip install - Add check_lambda_status target to Makefile for monitoring function state - Replace pip with uv pip install in deploy script for faster dependency resolution --- Makefile | 9 ++++++++- scripts/deploy-python.sh | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index a007e73..0db7051 100755 --- a/Makefile +++ b/Makefile @@ -18,4 +18,11 @@ set_env_variables: # Python only export_serverless_requirements: @echo "Exporting requirements..." - uv pip compile pyproject.toml -o app/python/requirements.txt \ No newline at end of file + uv pip compile pyproject.toml -o app/python/requirements.txt + +check_lambda_status: + @echo "Checking Lambda function status..." + aws lambda get-function-configuration \ + --profile "fender" \ + --function-name fender_digital_code_exercise \ + --query '{State:State, LastUpdateStatus:LastUpdateStatus, LastUpdateStatusReason:LastUpdateStatusReason}' \ No newline at end of file diff --git a/scripts/deploy-python.sh b/scripts/deploy-python.sh index 45eb394..2864a43 100755 --- a/scripts/deploy-python.sh +++ b/scripts/deploy-python.sh @@ -3,7 +3,7 @@ mkdir .temp mkdir .temp/package -pip install -r app/python/requirements.txt -t .temp/package +uv pip install -r app/python/requirements.txt --target .temp/package cp -r app/python/src/. .temp/package cd .temp/package From a628ad45d1404739bc1c45430502822cda4640a0 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Wed, 5 Nov 2025 23:41:23 -0600 Subject: [PATCH 05/46] feat: add boto3 dependency and improve HTTP status handling Replace hardcoded status code with HTTPStatus.OK enum and add boto3>=1.40.67 dependency for AWS services integration --- app/python/src/__init__.py | 0 app/python/src/config.py | 15 +++++++ app/python/src/db/dynamo.py | 27 ++++++++++++ app/python/src/main.py | 5 ++- pyproject.toml | 1 + uv.lock | 85 ++++++++++++++++++++++++++++++++++++- 6 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 app/python/src/__init__.py create mode 100644 app/python/src/config.py create mode 100644 app/python/src/db/dynamo.py diff --git a/app/python/src/__init__.py b/app/python/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/python/src/config.py b/app/python/src/config.py new file mode 100644 index 0000000..7869246 --- /dev/null +++ b/app/python/src/config.py @@ -0,0 +1,15 @@ +import os + +from dotenv import load_dotenv + +load_dotenv(override=True) + + +class Config: + ENVIRONMENT = os.getenv("ENVIRONMENT", "development") + AWS_KEY_ID = os.getenv("AWS_KEY_ID", "") + AWS_KEY_SECRET = os.getenv("AWS_KEY_SECRET", "") + AWS_REGION_NAME = os.getenv("AWS_REGION_NAME", "us-east-1") + + +IS_DEVELOPMENT = Config.ENVIRONMENT == "development" diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py new file mode 100644 index 0000000..4dea3d8 --- /dev/null +++ b/app/python/src/db/dynamo.py @@ -0,0 +1,27 @@ +import boto3 +from config import IS_DEVELOPMENT, Config + + +class DynamoFender: + """ + DynamoDB connection handler for Fender application. + """ + + def __init__(self, tablename) -> None: + if IS_DEVELOPMENT: + auth_params = { + "aws_access_key_id": Config.AWS_KEY_ID, + "aws_secret_access_key": Config.AWS_KEY_SECRET, + "region_name": Config.AWS_REGION_NAME, + } + self.dynamodb = boto3.resource("dynamodb", **auth_params) + self.client = boto3.client("dynamodb", **auth_params) + else: + self.dynamodb = boto3.resource("dynamodb") + self.client = boto3.client("dynamodb") + try: + self.table = self.dynamodb.Table(tablename) + except Exception as error: + raise ValueError( + f"Could't make connection to `{tablename}` table due `{error}`" + ) diff --git a/app/python/src/main.py b/app/python/src/main.py index 0ccb4ea..a5b1a13 100755 --- a/app/python/src/main.py +++ b/app/python/src/main.py @@ -1,5 +1,8 @@ +from http import HTTPStatus + + def handler(event, context): return { - "statusCode": 200, + "statusCode": HTTPStatus.OK, "body": "Hello from Python Lambda!", } diff --git a/pyproject.toml b/pyproject.toml index 6b10a8e..2eff005 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,5 +5,6 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12" dependencies = [ + "boto3>=1.40.67", "python-dotenv>=1.2.1", ] diff --git a/uv.lock b/uv.lock index 1fa1e4b..72f37bd 100644 --- a/uv.lock +++ b/uv.lock @@ -2,16 +2,69 @@ version = 1 revision = 1 requires-python = ">=3.12" +[[package]] +name = "boto3" +version = "1.40.67" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/a5/0e87ff413d2ca57500b1ec9e583a83589ed56fc27af8bacf8f0681c28672/boto3-1.40.67.tar.gz", hash = "sha256:3e4317139ace6d44658b8e1f2b5b6612f05b45720721841c90cdee45b02aa514", size = 111587 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/2d/f4896b59ff21d1bf228bde2973b5c5af2debc71e11137533dd088f094846/boto3-1.40.67-py3-none-any.whl", hash = "sha256:3d06e9b3c7abedb8253c7d75b9ab27005480ca1e6e448d1f3c3cc3e209673ca0", size = 139362 }, +] + +[[package]] +name = "botocore" +version = "1.40.67" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/aa/4d3d04e3fb2f497fbe574051d50180a6326ffef481caea80837605a0016d/botocore-1.40.67.tar.gz", hash = "sha256:cc086f39c877aee0ea8dc88ef69062c9f395b9d30d49bfcfac7b8b7e61864b3a", size = 14417097 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/65/2b50bb0112d6e2c171c8e07cc7f2a0581d39b850921d4defdf5421098fc9/botocore-1.40.67-py3-none-any.whl", hash = "sha256:e49e61f6718e8bc8b34e9bb8a97f16c8dc560485faef4981b55d76f825c9d78a", size = 14081807 }, +] + [[package]] name = "fds-aws-coding-exercise" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "boto3" }, { name = "python-dotenv" }, ] [package.metadata] -requires-dist = [{ name = "python-dotenv", specifier = ">=1.2.1" }] +requires-dist = [ + { name = "boto3", specifier = ">=1.40.67" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] [[package]] name = "python-dotenv" @@ -21,3 +74,33 @@ sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edb wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 }, ] + +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, +] From 6a96bcbdc7ee4192fd1c63adb7bd94461163d76b Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 00:02:48 -0600 Subject: [PATCH 06/46] feat: add SubscriptionTable class and refactor handler response - Add SubscriptionTable class extending DynamoFender for subscription data operations - Replace inline response dict with success_response utility function - Simplify handler to use centralized response formatting --- app/python/src/db/dynamo.py | 12 +++++++++++ app/python/src/main.py | 7 ++----- app/python/src/schemas/schemas.py | 33 +++++++++++++++++++++++++++++++ app/python/src/utils/response.py | 8 ++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 app/python/src/schemas/schemas.py create mode 100644 app/python/src/utils/response.py diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index 4dea3d8..a12d9f6 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -25,3 +25,15 @@ def __init__(self, tablename) -> None: raise ValueError( f"Could't make connection to `{tablename}` table due `{error}`" ) + + +class SubscriptionTable(DynamoFender): + """ + DynamoDB handler for Subscription table. + """ + + __tablename__ = "sub" + + def __init__(self, tablename: str = None) -> None: + _tablename = tablename or self.__tablename__ + super().__init__(_tablename) diff --git a/app/python/src/main.py b/app/python/src/main.py index a5b1a13..fb4bdbc 100755 --- a/app/python/src/main.py +++ b/app/python/src/main.py @@ -1,8 +1,5 @@ -from http import HTTPStatus +from src.utils.response import success_response def handler(event, context): - return { - "statusCode": HTTPStatus.OK, - "body": "Hello from Python Lambda!", - } + return success_response("Fender Python Lambda is up and running!") diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py new file mode 100644 index 0000000..b4d36ee --- /dev/null +++ b/app/python/src/schemas/schemas.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from enum import StrEnum + + +class SubscriptionStatus(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + + +@dataclass +class SubscriptionSchema: + pk: str + sk: str + type: str + planSku: str + startDate: str + expiresAt: str + cancelledAt: str + lastModified: str + attributes: dict + + +@dataclass +class PlanSchema: + pk: str + sk: str + type: str + name: str + price: float + currency: str + billingCycle: str + features: list + status: SubscriptionStatus diff --git a/app/python/src/utils/response.py b/app/python/src/utils/response.py new file mode 100644 index 0000000..3cf5993 --- /dev/null +++ b/app/python/src/utils/response.py @@ -0,0 +1,8 @@ +from http import HTTPStatus + + +def success_response(body: str) -> dict: + return { + "statusCode": HTTPStatus.OK, + "body": body, + } From 1ef9dbf316133f2dd9c512358d2e43049568ff56 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 00:03:44 -0600 Subject: [PATCH 07/46] feat: add pydantic dependency for data validation Add pydantic>=2.12.4 to project dependencies along with all required transitive dependencies including annotated-types, pydantic-core, typing-extensions, and typing-inspection. This enables robust data validation and serialization capabilities for the application. --- app/python/requirements.txt | 31 ++++++++++ pyproject.toml | 1 + uv.lock | 117 ++++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/app/python/requirements.txt b/app/python/requirements.txt index aa1f361..0dcd232 100644 --- a/app/python/requirements.txt +++ b/app/python/requirements.txt @@ -1,4 +1,35 @@ # This file was autogenerated by uv via the following command: # uv pip compile pyproject.toml -o app/python/requirements.txt +annotated-types==0.7.0 + # via pydantic +boto3==1.40.67 + # via fds-aws-coding-exercise (pyproject.toml) +botocore==1.40.67 + # via + # boto3 + # s3transfer +jmespath==1.0.1 + # via + # boto3 + # botocore +pydantic==2.12.4 + # via fds-aws-coding-exercise (pyproject.toml) +pydantic-core==2.41.5 + # via pydantic +python-dateutil==2.9.0.post0 + # via botocore python-dotenv==1.2.1 # via fds-aws-coding-exercise (pyproject.toml) +s3transfer==0.14.0 + # via boto3 +six==1.17.0 + # via python-dateutil +typing-extensions==4.15.0 + # via + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.2 + # via pydantic +urllib3==2.5.0 + # via botocore diff --git a/pyproject.toml b/pyproject.toml index 2eff005..b6c66c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,5 +6,6 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "boto3>=1.40.67", + "pydantic>=2.12.4", "python-dotenv>=1.2.1", ] diff --git a/uv.lock b/uv.lock index 72f37bd..c4a8374 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 1 requires-python = ">=3.12" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + [[package]] name = "boto3" version = "1.40.67" @@ -36,12 +45,14 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "boto3" }, + { name = "pydantic" }, { name = "python-dotenv" }, ] [package.metadata] requires-dist = [ { name = "boto3", specifier = ">=1.40.67" }, + { name = "pydantic", specifier = ">=2.12.4" }, { name = "python-dotenv", specifier = ">=1.2.1" }, ] @@ -54,6 +65,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, ] +[[package]] +name = "pydantic" +version = "2.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400 }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -96,6 +192,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + [[package]] name = "urllib3" version = "2.5.0" From 2c5567b2ea24a1962ab39a82e60c297ec5841958 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 00:29:12 -0600 Subject: [PATCH 08/46] feat(db): add write method to DynamoFender and migrate schemas to Pydantic - Add generic write method to DynamoFender base class with batch writing support - Implement type-safe write method in SubscriptionTable using SubscriptionSchema - Migrate dataclass schemas to Pydantic BaseModel for better validation - Update table name from "sub" to "FenderSubscriptions" - Add proper type annotations and validation for subscription status --- app/python/src/db/dynamo.py | 24 +++++++++++++++++++++++- app/python/src/models/models.py | 13 +++++++++++++ app/python/src/schemas/schemas.py | 15 +++++++++------ 3 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 app/python/src/models/models.py diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index a12d9f6..ede320a 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -1,6 +1,8 @@ import boto3 from config import IS_DEVELOPMENT, Config +from ..schemas.schemas import SubscriptionSchema + class DynamoFender: """ @@ -26,14 +28,34 @@ def __init__(self, tablename) -> None: f"Could't make connection to `{tablename}` table due `{error}`" ) + def write(self, data: list) -> bool: + """ + Writes data into table + """ + + if not data: + raise ValueError("Data cannot be empty or null") + + if not isinstance(data, list): + raise ValueError(f"Data must be type `list`, given `{type(data)}`") + + with self.table.batch_writer() as batch: + for values in data: + batch.put_item(Item=values) + + return True + class SubscriptionTable(DynamoFender): """ DynamoDB handler for Subscription table. """ - __tablename__ = "sub" + __tablename__ = "FenderSubscriptions" def __init__(self, tablename: str = None) -> None: _tablename = tablename or self.__tablename__ super().__init__(_tablename) + + def write(self, data: SubscriptionSchema) -> bool: + return super().write(data.model_dump()) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py new file mode 100644 index 0000000..87aba6e --- /dev/null +++ b/app/python/src/models/models.py @@ -0,0 +1,13 @@ +from ..schemas.schemas import PlanSchema, SubscriptionSchema + + +class SubscriptionModel: + + def create(self, data: SubscriptionSchema) -> None: + pass + + +class PlanModel: + + def create(self, data: PlanSchema) -> None: + pass diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index b4d36ee..a191dbc 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -1,5 +1,7 @@ -from dataclasses import dataclass from enum import StrEnum +from typing import Annotated + +from pydantic import BaseModel class SubscriptionStatus(StrEnum): @@ -7,8 +9,7 @@ class SubscriptionStatus(StrEnum): INACTIVE = "inactive" -@dataclass -class SubscriptionSchema: +class SubscriptionSchema(BaseModel): pk: str sk: str type: str @@ -20,8 +21,7 @@ class SubscriptionSchema: attributes: dict -@dataclass -class PlanSchema: +class PlanSchema(BaseModel): pk: str sk: str type: str @@ -30,4 +30,7 @@ class PlanSchema: currency: str billingCycle: str features: list - status: SubscriptionStatus + status: Annotated[ + list[SubscriptionStatus], + SubscriptionStatus.ACTIVE | SubscriptionStatus.INACTIVE, + ] From 4bc6e11d6f8d1a75fd4289cc36e0d96a05d6b3e9 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 00:40:26 -0600 Subject: [PATCH 09/46] fix: update import path and add debug logging to handler - Changed import from src.utils.response to utils.response for correct module resolution - Added debug print statement to log event and context parameters in handler function --- app/python/src/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/python/src/main.py b/app/python/src/main.py index fb4bdbc..2663a8c 100755 --- a/app/python/src/main.py +++ b/app/python/src/main.py @@ -1,5 +1,6 @@ -from src.utils.response import success_response +from utils.response import success_response def handler(event, context): + print(f"{event=}, {context=}") return success_response("Fender Python Lambda is up and running!") From 2e0b5e79ce8df9de91b24bf145f0a869e7a5345f Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 00:47:34 -0600 Subject: [PATCH 10/46] feat: move boto3 to dev dependencies and add ipython Move boto3 from main dependencies to dev dependency group since it's only needed for development/testing. Add ipython to dev dependencies for better development experience. Update requirements.txt to reflect removal of AWS-related packages from production dependencies. --- app/python/requirements.txt | 18 ---- pyproject.toml | 7 +- uv.lock | 199 +++++++++++++++++++++++++++++++++++- 3 files changed, 203 insertions(+), 21 deletions(-) diff --git a/app/python/requirements.txt b/app/python/requirements.txt index 0dcd232..27e6d4f 100644 --- a/app/python/requirements.txt +++ b/app/python/requirements.txt @@ -2,28 +2,12 @@ # uv pip compile pyproject.toml -o app/python/requirements.txt annotated-types==0.7.0 # via pydantic -boto3==1.40.67 - # via fds-aws-coding-exercise (pyproject.toml) -botocore==1.40.67 - # via - # boto3 - # s3transfer -jmespath==1.0.1 - # via - # boto3 - # botocore pydantic==2.12.4 # via fds-aws-coding-exercise (pyproject.toml) pydantic-core==2.41.5 # via pydantic -python-dateutil==2.9.0.post0 - # via botocore python-dotenv==1.2.1 # via fds-aws-coding-exercise (pyproject.toml) -s3transfer==0.14.0 - # via boto3 -six==1.17.0 - # via python-dateutil typing-extensions==4.15.0 # via # pydantic @@ -31,5 +15,3 @@ typing-extensions==4.15.0 # typing-inspection typing-inspection==0.4.2 # via pydantic -urllib3==2.5.0 - # via botocore diff --git a/pyproject.toml b/pyproject.toml index b6c66c4..0a29b78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,12 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12" dependencies = [ - "boto3>=1.40.67", "pydantic>=2.12.4", "python-dotenv>=1.2.1", ] + +[dependency-groups] +dev = [ + "boto3>=1.40.67", + "ipython>=9.7.0", +] diff --git a/uv.lock b/uv.lock index c4a8374..4d532c5 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + [[package]] name = "boto3" version = "1.40.67" @@ -39,23 +48,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/65/2b50bb0112d6e2c171c8e07cc7f2a0581d39b850921d4defdf5421098fc9/botocore-1.40.67-py3-none-any.whl", hash = "sha256:e49e61f6718e8bc8b34e9bb8a97f16c8dc560485faef4981b55d76f825c9d78a", size = 14081807 }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317 }, +] + [[package]] name = "fds-aws-coding-exercise" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "boto3" }, { name = "pydantic" }, { name = "python-dotenv" }, ] +[package.dev-dependencies] +dev = [ + { name = "boto3" }, + { name = "ipython" }, +] + [package.metadata] requires-dist = [ - { name = "boto3", specifier = ">=1.40.67" }, { name = "pydantic", specifier = ">=2.12.4" }, { name = "python-dotenv", specifier = ">=1.2.1" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "boto3", specifier = ">=1.40.67" }, + { name = "ipython", specifier = ">=9.7.0" }, +] + +[[package]] +name = "ipython" +version = "9.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/e6/48c74d54039241a456add616464ea28c6ebf782e4110d419411b83dae06f/ipython-9.7.0.tar.gz", hash = "sha256:5f6de88c905a566c6a9d6c400a8fed54a638e1f7543d17aae2551133216b1e4e", size = 4422115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/aa/62893d6a591d337aa59dcc4c6f6c842f1fe20cd72c8c5c1f980255243252/ipython-9.7.0-py3-none-any.whl", hash = "sha256:bce8ac85eb9521adc94e1845b4c03d88365fd6ac2f4908ec4ed1eb1b0a065f9f", size = 618911 }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + [[package]] name = "jmespath" version = "1.0.1" @@ -65,6 +156,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, ] +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516 }, +] + +[[package]] +name = "parso" +version = "0.8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668 }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + [[package]] name = "pydantic" version = "2.12.4" @@ -150,6 +304,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -192,6 +355,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -221,3 +407,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599 wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, ] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286 }, +] From cf410ef03ca9bdcec28c3fa1859ece25b3967c4e Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 21:09:43 -0600 Subject: [PATCH 11/46] feat: add subscription event schemas and type definitions Add SubscriptionType enum, MetadataSchema, and SubscriptionEvent models with helper properties to support subscription event handling and type checking. --- app/python/src/schemas/schemas.py | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index a191dbc..b24f9ab 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -9,6 +9,43 @@ class SubscriptionStatus(StrEnum): INACTIVE = "inactive" +class SubscriptionType(StrEnum): + RENEWAL = "subscription.renewed" + CREATED = "subscription.created" + CANCELLED = "subscription.cancelled" + + +class MetadataSchema(BaseModel): + planSku: str + autoRenew: bool + paymentMethod: str + + +class SubscriptionEvent(BaseModel): + eventId: str + eventType: str + timestamp: str + provider: str + subscriptionId: str + paymentId: str + userId: str + customerId: str + expiresAt: str + metadata: MetadataSchema + + @property + def is_renewal(self) -> bool: + return self.eventType == SubscriptionType.RENEWAL + + @property + def is_created(self) -> bool: + return self.eventType == SubscriptionType.CREATED + + @property + def is_cancelled(self) -> bool: + return self.eventType == SubscriptionType.CANCELLED + + class SubscriptionSchema(BaseModel): pk: str sk: str From 7bbaba44edeef346c55df1a75860f2f24140a0c4 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 21:17:17 -0600 Subject: [PATCH 12/46] build: add pytest to dev dependencies Add pytest>=8.4.2 to development dependencies for unit testing support. This includes the pytest package and its required dependencies (iniconfig, packaging, pluggy) in the lock file. --- .vscode/launch.json | 18 +++++++++++++ app/python/src/tests/__init__.py | 0 app/python/src/tests/main.py | 7 +++++ pyproject.toml | 1 + uv.lock | 46 ++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 app/python/src/tests/__init__.py create mode 100644 app/python/src/tests/main.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a20824f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "[PYTEST] Debugger", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": ["app/python/src/tests/main.py"], + "console": "integratedTerminal", + "justMyCode": true, + "env": { + "PYTHONPATH": "${workspaceFolder}" + }, + "purpose": ["debug-test"] + } + ] +} diff --git a/app/python/src/tests/__init__.py b/app/python/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py new file mode 100644 index 0000000..ce753e9 --- /dev/null +++ b/app/python/src/tests/main.py @@ -0,0 +1,7 @@ +import pytest + + +def test_sample(): + print("Hello, World!") + + print("This is a test file.") diff --git a/pyproject.toml b/pyproject.toml index 0a29b78..d328049 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,4 +13,5 @@ dependencies = [ dev = [ "boto3>=1.40.67", "ipython>=9.7.0", + "pytest>=8.4.2", ] diff --git a/uv.lock b/uv.lock index 4d532c5..7bbffce 100644 --- a/uv.lock +++ b/uv.lock @@ -88,6 +88,7 @@ dependencies = [ dev = [ { name = "boto3" }, { name = "ipython" }, + { name = "pytest" }, ] [package.metadata] @@ -100,6 +101,16 @@ requires-dist = [ dev = [ { name = "boto3", specifier = ">=1.40.67" }, { name = "ipython", specifier = ">=9.7.0" }, + { name = "pytest", specifier = ">=8.4.2" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] [[package]] @@ -168,6 +179,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516 }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + [[package]] name = "parso" version = "0.8.5" @@ -189,6 +209,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -241,6 +270,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } wheels = [ { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, @@ -313,6 +343,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 703e725cf42001d12dd611440b89959356c677ea Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 21:24:02 -0600 Subject: [PATCH 13/46] feat(schemas): simplify PlanSchema status field and add subscription event tests - Remove complex Annotated type constraint from PlanSchema.status field - Replace with simple list type for better flexibility - Add comprehensive test for SubscriptionEvent schema validation - Include sample data structure for subscription event testing --- app/python/src/schemas/schemas.py | 5 +---- app/python/src/tests/main.py | 28 +++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index b24f9ab..e496646 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -67,7 +67,4 @@ class PlanSchema(BaseModel): currency: str billingCycle: str features: list - status: Annotated[ - list[SubscriptionStatus], - SubscriptionStatus.ACTIVE | SubscriptionStatus.INACTIVE, - ] + status: list diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index ce753e9..839f98e 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -1,7 +1,29 @@ import pytest +from ..schemas.schemas import SubscriptionEvent -def test_sample(): - print("Hello, World!") +SAMPLE_DATA = { + "eventId": "evt_123456789", + "eventType": "subscription.created", + "timestamp": "2024-03-20T10:00:00Z", + "provider": "STRIPE", + "subscriptionId": "sub_456789", + "paymentId": "pm_123456", + "userId": "123", + "customerId": "cus_789012", + "expiresAt": "2024-04-20T10:00:00Z", + "metadata": { + "planSku": "PREMIUM_MONTHLY", + "autoRenew": True, + "paymentMethod": "CREDIT_CARD", + }, +} - print("This is a test file.") + +def test_subscription_event_creation(): + subscription_event = SubscriptionEvent(**SAMPLE_DATA) + + assert subscription_event.is_created is True + assert subscription_event.metadata.planSku == "PREMIUM_MONTHLY" + assert subscription_event.metadata.autoRenew is True + assert subscription_event.metadata.paymentMethod == "CREDIT_CARD" From d6b211cf306ec6758759acefe15f110da1701fc0 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 22:54:37 -0600 Subject: [PATCH 14/46] feat: add API Gateway event sample and improve response formatting - Add EVENT_SAMPLE with realistic AWS API Gateway event structure for testing - Import json module and wrap response body in JSON format with message field - Enhance test data coverage for subscription endpoint scenarios --- app/python/src/tests/main.py | 65 ++++++++++++++++++++++++++++++++ app/python/src/utils/response.py | 3 +- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 839f98e..7ea1555 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -19,6 +19,71 @@ }, } +EVENT_SAMPLE = { + "resource": "/api/v1/subscriptions/{userId}", + "path": "/api/v1/subscriptions/dummy_user", + "httpMethod": "GET", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Host": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", + "Postman-Token": "fa1c9874-89e9-4277-a5b1-e65017947519", + "User-Agent": "PostmanRuntime/7.49.1", + "X-Amzn-Trace-Id": "Root=1-690d790f-52e7f1510ea0af3242a5d1d6", + "X-Forwarded-For": "189.179.129.75", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https", + }, + "multiValueHeaders": { + "Accept": ["*/*"], + "Accept-Encoding": ["gzip, deflate, br"], + "Host": ["0s2r8aurv7.execute-api.us-east-1.amazonaws.com"], + "Postman-Token": ["fa1c9874-89e9-4277-a5b1-e65017947519"], + "User-Agent": ["PostmanRuntime/7.49.1"], + "X-Amzn-Trace-Id": ["Root=1-690d790f-52e7f1510ea0af3242a5d1d6"], + "X-Forwarded-For": ["189.179.129.75"], + "X-Forwarded-Port": ["443"], + "X-Forwarded-Proto": ["https"], + }, + "queryStringParameters": None, + "multiValueQueryStringParameters": None, + "pathParameters": {"userId": "dummy_user"}, + "stageVariables": None, + "requestContext": { + "resourceId": "xxxxyyy", + "resourcePath": "/api/v1/subscriptions/{userId}", + "httpMethod": "GET", + "extendedRequestId": "Tp_ajGsBoAMEdCw=", + "requestTime": "07/Nov/2025:04:43:59 +0000", + "path": "/dev/api/v1/subscriptions/dummy_user", + "accountId": "929676127859", + "protocol": "HTTP/1.1", + "stage": "dev", + "domainPrefix": "xxxxxyyy", + "requestTimeEpoch": 1762490639902, + "requestId": "fb9a86bd-d6cf-4812-b69d-21f3ce009155", + "identity": { + "cognitoIdentityPoolId": None, + "accountId": None, + "cognitoIdentityId": None, + "caller": None, + "sourceIp": "189.179.129.75", + "principalOrgId": None, + "accessKey": None, + "cognitoAuthenticationType": None, + "cognitoAuthenticationProvider": None, + "userArn": None, + "userAgent": "PostmanRuntime/7.49.1", + "user": None, + }, + "domainName": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", + "deploymentId": "w3dwr7", + "apiId": "0s2r8aurv7", + }, + "body": None, + "isBase64Encoded": False, +} + def test_subscription_event_creation(): subscription_event = SubscriptionEvent(**SAMPLE_DATA) diff --git a/app/python/src/utils/response.py b/app/python/src/utils/response.py index 3cf5993..a980950 100644 --- a/app/python/src/utils/response.py +++ b/app/python/src/utils/response.py @@ -1,8 +1,9 @@ +import json from http import HTTPStatus def success_response(body: str) -> dict: return { "statusCode": HTTPStatus.OK, - "body": body, + "body": json.dumps({"message": body}), } From 550ba300f856ca545ce3495d06cc431e9785a95b Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 23:09:48 -0600 Subject: [PATCH 15/46] feat: add HTTP method support and event schema validation - Add SupportedMethods enum for GET and POST operations - Create EventSchema with httpMethod validation and convenience properties - Rename EVENT_SAMPLE to GET_EVENT_SUBSCRIPTION for clarity - Add POST_EVENT_SUBSCRIPTION test data with request body - Enable method-specific validation through is_get and is_post properties --- app/python/src/schemas/schemas.py | 17 ++++++++ app/python/src/tests/main.py | 66 ++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index e496646..5193dc9 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -15,6 +15,23 @@ class SubscriptionType(StrEnum): CANCELLED = "subscription.cancelled" +class SupportedMethods(StrEnum): + GET = "GET" + POST = "POST" + + +class EventSchema(BaseModel): + httpMethod: str + + @property + def is_get(self) -> bool: + return self.httpMethod == SupportedMethods.GET + + @property + def is_post(self) -> bool: + return self.httpMethod == SupportedMethods.POST + + class MetadataSchema(BaseModel): planSku: str autoRenew: bool diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 7ea1555..e3eeace 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -19,7 +19,7 @@ }, } -EVENT_SAMPLE = { +GET_EVENT_SUBSCRIPTION = { "resource": "/api/v1/subscriptions/{userId}", "path": "/api/v1/subscriptions/dummy_user", "httpMethod": "GET", @@ -83,6 +83,70 @@ "body": None, "isBase64Encoded": False, } +POST_EVENT_SUBSCRIPTION = { + "resource": "/api/v1/subscriptions/{userId}", + "path": "/api/v1/subscriptions/dummy_user", + "httpMethod": "POST", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Host": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", + "Postman-Token": "fa1c9874-89e9-4277-a5b1-e65017947519", + "User-Agent": "PostmanRuntime/7.49.1", + "X-Amzn-Trace-Id": "Root=1-690d790f-52e7f1510ea0af3242a5d1d6", + "X-Forwarded-For": "189.179.129.75", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https", + }, + "multiValueHeaders": { + "Accept": ["*/*"], + "Accept-Encoding": ["gzip, deflate, br"], + "Host": ["0s2r8aurv7.execute-api.us-east-1.amazonaws.com"], + "Postman-Token": ["fa1c9874-89e9-4277-a5b1-e65017947519"], + "User-Agent": ["PostmanRuntime/7.49.1"], + "X-Amzn-Trace-Id": ["Root=1-690d790f-52e7f1510ea0af3242a5d1d6"], + "X-Forwarded-For": ["189.179.129.75"], + "X-Forwarded-Port": ["443"], + "X-Forwarded-Proto": ["https"], + }, + "queryStringParameters": None, + "multiValueQueryStringParameters": None, + "pathParameters": {"userId": "dummy_user"}, + "stageVariables": None, + "requestContext": { + "resourceId": "xxxxyyy", + "resourcePath": "/api/v1/subscriptions/{userId}", + "httpMethod": "GET", + "extendedRequestId": "Tp_ajGsBoAMEdCw=", + "requestTime": "07/Nov/2025:04:43:59 +0000", + "path": "/dev/api/v1/subscriptions/dummy_user", + "accountId": "929676127859", + "protocol": "HTTP/1.1", + "stage": "dev", + "domainPrefix": "xxxxxyyy", + "requestTimeEpoch": 1762490639902, + "requestId": "fb9a86bd-d6cf-4812-b69d-21f3ce009155", + "identity": { + "cognitoIdentityPoolId": None, + "accountId": None, + "cognitoIdentityId": None, + "caller": None, + "sourceIp": "189.179.129.75", + "principalOrgId": None, + "accessKey": None, + "cognitoAuthenticationType": None, + "cognitoAuthenticationProvider": None, + "userArn": None, + "userAgent": "PostmanRuntime/7.49.1", + "user": None, + }, + "domainName": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", + "deploymentId": "w3dwr7", + "apiId": "0s2r8aurv7", + }, + "body": {"sample_key": "sample_value"}, + "isBase64Encoded": False, +} def test_subscription_event_creation(): From 4e2b23a79ff568373f1b9f9234fc7a4f7994b222 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 23:19:52 -0600 Subject: [PATCH 16/46] feat: improve schema type safety and add event method validation - Replace generic typing with Literal for stronger type constraints - Make cancelledAt field optional with union type - Add EventSchema import and implement HTTP method validation tests - Rename test constants for better AWS context clarity --- app/python/src/schemas/schemas.py | 6 +++--- app/python/src/tests/main.py | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index 5193dc9..0af47e3 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -1,5 +1,5 @@ from enum import StrEnum -from typing import Annotated +from typing import Literal from pydantic import BaseModel @@ -70,7 +70,7 @@ class SubscriptionSchema(BaseModel): planSku: str startDate: str expiresAt: str - cancelledAt: str + cancelledAt: str | None lastModified: str attributes: dict @@ -84,4 +84,4 @@ class PlanSchema(BaseModel): currency: str billingCycle: str features: list - status: list + status: Literal[SubscriptionStatus.ACTIVE, SubscriptionStatus.INACTIVE] diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index e3eeace..aa7bf48 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -1,6 +1,6 @@ import pytest -from ..schemas.schemas import SubscriptionEvent +from ..schemas.schemas import EventSchema, SubscriptionEvent SAMPLE_DATA = { "eventId": "evt_123456789", @@ -19,7 +19,7 @@ }, } -GET_EVENT_SUBSCRIPTION = { +AWS_GET_EVENT_SUBSCRIPTION = { "resource": "/api/v1/subscriptions/{userId}", "path": "/api/v1/subscriptions/dummy_user", "httpMethod": "GET", @@ -83,7 +83,7 @@ "body": None, "isBase64Encoded": False, } -POST_EVENT_SUBSCRIPTION = { +AWS_POST_EVENT_SUBSCRIPTION = { "resource": "/api/v1/subscriptions/{userId}", "path": "/api/v1/subscriptions/dummy_user", "httpMethod": "POST", @@ -149,6 +149,16 @@ } +def test_event_schema_get_method(): + event = EventSchema(**AWS_GET_EVENT_SUBSCRIPTION) + assert event.is_get is True + assert event.is_post is False + + event = EventSchema(**AWS_POST_EVENT_SUBSCRIPTION) + assert event.is_get is False + assert event.is_post is True + + def test_subscription_event_creation(): subscription_event = SubscriptionEvent(**SAMPLE_DATA) From 48b86e28d1092b40812c1bf813923abd9d20e3d1 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Thu, 6 Nov 2025 23:46:07 -0600 Subject: [PATCH 17/46] feat: enhance subscription status management with cancellation logic - Add new SubscriptionStatus enum with ACTIVE, PENDING, CANCELLED states - Add PlanStatus enum separate from SubscriptionStatus - Add cancelledAt field to SubscriptionEvent model - Implement cancellation date parsing and status computation logic - Add properties to determine pending/cancelled states based on timestamps - Update test data variable name for clarity (SAMPLE_DATA -> CREATED_SUBSCRIPTION_EVENT) --- app/python/src/schemas/schemas.py | 36 +++++++++++++++++++++++++++++++ app/python/src/tests/main.py | 4 ++-- app/python/src/utils/utils.py | 17 +++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 app/python/src/utils/utils.py diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index 0af47e3..f5a34bf 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -1,10 +1,19 @@ +from datetime import datetime, timezone from enum import StrEnum from typing import Literal from pydantic import BaseModel +from ..utils.utils import parse_iso8601 + class SubscriptionStatus(StrEnum): + ACTIVE = "active" + PENDING = "pending" + CANCELLED = "cancelled" + + +class PlanStatus(StrEnum): ACTIVE = "active" INACTIVE = "inactive" @@ -48,8 +57,13 @@ class SubscriptionEvent(BaseModel): userId: str customerId: str expiresAt: str + cancelledAt: str | None metadata: MetadataSchema + @property + def _current_datetime(self) -> datetime: + return datetime.now(timezone.utc) + @property def is_renewal(self) -> bool: return self.eventType == SubscriptionType.RENEWAL @@ -62,6 +76,28 @@ def is_created(self) -> bool: def is_cancelled(self) -> bool: return self.eventType == SubscriptionType.CANCELLED + @property + def parse_cancelledAt(self) -> str | None: + if self.cancelledAt: + return parse_iso8601(self.cancelledAt) + + @property + def is_pending(self) -> bool: + return self._current_datetime <= self.parse_cancelledAt + + @property + def is_cancelled(self) -> bool: + return self._current_datetime > self.parse_cancelledAt + + @property + def compute_status(self) -> SubscriptionStatus: + if not self.cancelledAt: + return SubscriptionStatus.ACTIVE + if self.is_pending: + return SubscriptionStatus.PENDING + if self.is_cancelled: + return SubscriptionStatus.CANCELLED + class SubscriptionSchema(BaseModel): pk: str diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index aa7bf48..37e7777 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -2,7 +2,7 @@ from ..schemas.schemas import EventSchema, SubscriptionEvent -SAMPLE_DATA = { +CREATED_SUBSCRIPTION_EVENT = { "eventId": "evt_123456789", "eventType": "subscription.created", "timestamp": "2024-03-20T10:00:00Z", @@ -160,7 +160,7 @@ def test_event_schema_get_method(): def test_subscription_event_creation(): - subscription_event = SubscriptionEvent(**SAMPLE_DATA) + subscription_event = SubscriptionEvent(**CREATED_SUBSCRIPTION_EVENT) assert subscription_event.is_created is True assert subscription_event.metadata.planSku == "PREMIUM_MONTHLY" diff --git a/app/python/src/utils/utils.py b/app/python/src/utils/utils.py new file mode 100644 index 0000000..2e22413 --- /dev/null +++ b/app/python/src/utils/utils.py @@ -0,0 +1,17 @@ +from datetime import datetime, timezone + + +def parse_iso8601(date_string: str) -> datetime: + """ + Parse an ISO 8601 date string and return a timezone-aware datetime object. + """ + return datetime.fromisoformat(date_string.replace("Z", "+00:00")).astimezone( + timezone.utc + ) + + +def format_iso8601(dt: datetime) -> str: + """ + Format a timezone-aware datetime object as an ISO 8601 date string. + """ + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") From 6b03b453553629c434780b4e62b81a991036f54f Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Fri, 7 Nov 2025 00:00:44 -0600 Subject: [PATCH 18/46] feat: add PlanTable DynamoDB handler and enhance schema definitions - Add PlanTable class to dynamo.py for FenderPlans table management - Create BillingCycle enum with MONTHLY and YEARLY options - Set default value "sub" for SubscriptionSchema type field - Improve PlanSchema type hints with stricter literal types and list[str] --- app/python/src/db/dynamo.py | 12 ++++++++++++ app/python/src/schemas/schemas.py | 13 +++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index ede320a..24ca7cb 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -59,3 +59,15 @@ def __init__(self, tablename: str = None) -> None: def write(self, data: SubscriptionSchema) -> bool: return super().write(data.model_dump()) + + +class PlanTable(DynamoFender): + """ + DynamoDB handler for Plan table. + """ + + __tablename__ = "FenderPlans" + + def __init__(self, tablename: str = None) -> None: + _tablename = tablename or self.__tablename__ + super().__init__(_tablename) diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index f5a34bf..1ff0d49 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -18,6 +18,11 @@ class PlanStatus(StrEnum): INACTIVE = "inactive" +class BillingCycle(StrEnum): + MONTHLY = "monthly" + YEARLY = "yearly" + + class SubscriptionType(StrEnum): RENEWAL = "subscription.renewed" CREATED = "subscription.created" @@ -102,7 +107,7 @@ def compute_status(self) -> SubscriptionStatus: class SubscriptionSchema(BaseModel): pk: str sk: str - type: str + type: str = "sub" planSku: str startDate: str expiresAt: str @@ -118,6 +123,6 @@ class PlanSchema(BaseModel): name: str price: float currency: str - billingCycle: str - features: list - status: Literal[SubscriptionStatus.ACTIVE, SubscriptionStatus.INACTIVE] + billingCycle: Literal[BillingCycle.MONTHLY, BillingCycle.YEARLY] + features: list[str] + status: Literal[PlanStatus.ACTIVE, PlanStatus.INACTIVE] From de004f58bcddefd1a69507fb0d1e929381b6a15b Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Fri, 7 Nov 2025 16:15:35 -0600 Subject: [PATCH 19/46] refactor: enhance EventSchema and rename SubscriptionEvent model - Add path, body, and pathParameters fields to EventSchema for better request handling - Rename SubscriptionEvent to SubscriptionEventPayload for clarity - Implement event validation in Lambda handler using EventSchema - Update tests to reflect model rename and maintain functionality --- app/python/src/main.py | 4 +++- app/python/src/routes.py | 25 +++++++++++++++++++++++++ app/python/src/schemas/schemas.py | 5 ++++- app/python/src/tests/main.py | 12 ++++++------ 4 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 app/python/src/routes.py diff --git a/app/python/src/main.py b/app/python/src/main.py index 2663a8c..1320428 100755 --- a/app/python/src/main.py +++ b/app/python/src/main.py @@ -1,6 +1,8 @@ from utils.response import success_response +from .schemas.schemas import EventSchema + def handler(event, context): - print(f"{event=}, {context=}") + event = EventSchema(**event) return success_response("Fender Python Lambda is up and running!") diff --git a/app/python/src/routes.py b/app/python/src/routes.py new file mode 100644 index 0000000..ee6e482 --- /dev/null +++ b/app/python/src/routes.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel +from schemas.schemas import EventSchema, SubscriptionEventPayload + + +class Router(BaseModel): + event: EventSchema + + def get_user_subscription(self) -> str | None: + pass + + def post_user_subscription(self, body: SubscriptionEventPayload) -> None: + # TODO: Add DynamoDB logic to store the subscription event + pass + + def process_event(self) -> dict: + + if self.event.is_get: + pass + + elif self.event.is_post: + body = SubscriptionEventPayload(**self.event.body) + self.post_user_subscription(body=body) + + else: + raise ValueError("Unsupported HTTP method") diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index 1ff0d49..855e4d5 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -36,6 +36,9 @@ class SupportedMethods(StrEnum): class EventSchema(BaseModel): httpMethod: str + path: str + body: dict | None + pathParameters: dict | None @property def is_get(self) -> bool: @@ -52,7 +55,7 @@ class MetadataSchema(BaseModel): paymentMethod: str -class SubscriptionEvent(BaseModel): +class SubscriptionEventPayload(BaseModel): eventId: str eventType: str timestamp: str diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 37e7777..edd9b36 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -1,6 +1,6 @@ import pytest -from ..schemas.schemas import EventSchema, SubscriptionEvent +from ..schemas.schemas import EventSchema, SubscriptionEventPayload CREATED_SUBSCRIPTION_EVENT = { "eventId": "evt_123456789", @@ -160,9 +160,9 @@ def test_event_schema_get_method(): def test_subscription_event_creation(): - subscription_event = SubscriptionEvent(**CREATED_SUBSCRIPTION_EVENT) + subscription_payload = SubscriptionEventPayload(**CREATED_SUBSCRIPTION_EVENT) - assert subscription_event.is_created is True - assert subscription_event.metadata.planSku == "PREMIUM_MONTHLY" - assert subscription_event.metadata.autoRenew is True - assert subscription_event.metadata.paymentMethod == "CREDIT_CARD" + assert subscription_payload.is_created is True + assert subscription_payload.metadata.planSku == "PREMIUM_MONTHLY" + assert subscription_payload.metadata.autoRenew is True + assert subscription_payload.metadata.paymentMethod == "CREDIT_CARD" From 74a1518f1568a258595d35dd49fe9e55da96271b Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Fri, 7 Nov 2025 16:32:13 -0600 Subject: [PATCH 20/46] feat: implement router-based request handling in Lambda function - Replace hardcoded response with Router class for event processing - Add router import and instantiation in main handler - Fix relative imports in routes.py and tests - Add basic handler test for success response validation --- app/python/src/main.py | 8 +++++--- app/python/src/routes.py | 3 ++- app/python/src/schemas/__init__.py | 0 app/python/src/tests/main.py | 8 ++++++++ 4 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 app/python/src/schemas/__init__.py diff --git a/app/python/src/main.py b/app/python/src/main.py index 1320428..350a84f 100755 --- a/app/python/src/main.py +++ b/app/python/src/main.py @@ -1,8 +1,10 @@ -from utils.response import success_response - +from .routes import Router from .schemas.schemas import EventSchema +from .utils.response import success_response def handler(event, context): event = EventSchema(**event) - return success_response("Fender Python Lambda is up and running!") + router = Router(event=event) + + return router.process_event() diff --git a/app/python/src/routes.py b/app/python/src/routes.py index ee6e482..04d9784 100644 --- a/app/python/src/routes.py +++ b/app/python/src/routes.py @@ -1,5 +1,6 @@ from pydantic import BaseModel -from schemas.schemas import EventSchema, SubscriptionEventPayload + +from .schemas.schemas import EventSchema, SubscriptionEventPayload class Router(BaseModel): diff --git a/app/python/src/schemas/__init__.py b/app/python/src/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index edd9b36..cb555a0 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -1,5 +1,6 @@ import pytest +from ..main import handler from ..schemas.schemas import EventSchema, SubscriptionEventPayload CREATED_SUBSCRIPTION_EVENT = { @@ -149,6 +150,13 @@ } +def test_handler_returns_success_response(): + event = AWS_GET_EVENT_SUBSCRIPTION + context = {} + + response = handler(event, context) + + def test_event_schema_get_method(): event = EventSchema(**AWS_GET_EVENT_SUBSCRIPTION) assert event.is_get is True From 864cf783d1543919c5a4db86ec17b696c27cd0cc Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Fri, 7 Nov 2025 16:34:27 -0600 Subject: [PATCH 21/46] refactor: move success_response import to routes module and implement GET handler - Moved success_response import from main.py to routes.py where it's actually used - Implemented GET method handler to return success response instead of placeholder pass statement - Improves code organization by placing imports closer to their usage --- app/python/src/main.py | 1 - app/python/src/routes.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/python/src/main.py b/app/python/src/main.py index 350a84f..2885191 100755 --- a/app/python/src/main.py +++ b/app/python/src/main.py @@ -1,6 +1,5 @@ from .routes import Router from .schemas.schemas import EventSchema -from .utils.response import success_response def handler(event, context): diff --git a/app/python/src/routes.py b/app/python/src/routes.py index 04d9784..738f98d 100644 --- a/app/python/src/routes.py +++ b/app/python/src/routes.py @@ -1,6 +1,7 @@ from pydantic import BaseModel from .schemas.schemas import EventSchema, SubscriptionEventPayload +from .utils.response import success_response class Router(BaseModel): @@ -16,7 +17,7 @@ def post_user_subscription(self, body: SubscriptionEventPayload) -> None: def process_event(self) -> dict: if self.event.is_get: - pass + return success_response("GET method processed successfully") elif self.event.is_post: body = SubscriptionEventPayload(**self.event.body) From 9cf8d7053cbfaa6ed0b9a26abd837e462fb3e626 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Fri, 7 Nov 2025 16:44:39 -0600 Subject: [PATCH 22/46] feat: add SubscriptionPlanModel and import SubscriptionEventPayload - Add new SubscriptionPlanModel class with payload attribute - Import SubscriptionEventPayload from schemas module - Extend model layer to support subscription plan events --- app/python/src/db/tables.py | 6 ++++++ app/python/src/models/models.py | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 app/python/src/db/tables.py diff --git a/app/python/src/db/tables.py b/app/python/src/db/tables.py new file mode 100644 index 0000000..ef7a418 --- /dev/null +++ b/app/python/src/db/tables.py @@ -0,0 +1,6 @@ +from .dynamo import PlanTable, SubscriptionTable + + +class DynamoFenderTables: + SUBSCRIPTION = SubscriptionTable() + PLAN = PlanTable() diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 87aba6e..333657d 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -1,4 +1,4 @@ -from ..schemas.schemas import PlanSchema, SubscriptionSchema +from ..schemas.schemas import PlanSchema, SubscriptionEventPayload, SubscriptionSchema class SubscriptionModel: @@ -11,3 +11,7 @@ class PlanModel: def create(self, data: PlanSchema) -> None: pass + + +class SubscriptionPlanModel: + payload: SubscriptionEventPayload From 2c2ad099917e22beff7e708da61d01862bae71ad Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Fri, 7 Nov 2025 18:05:33 -0600 Subject: [PATCH 23/46] refactor: standardize AWS credential variable names and improve model structure - Update AWS_KEY_ID and AWS_KEY_SECRET to standard AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY - Fix import path in dynamo.py to use relative import - Add BaseModel inheritance to data models with proper payload attributes - Add documentation to SubscriptionPlanModel class - Import DynamoFenderTables in test module --- app/python/src/config.py | 4 ++-- app/python/src/db/dynamo.py | 6 +++--- app/python/src/models/models.py | 14 +++++++++++--- app/python/src/tests/main.py | 1 + 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/app/python/src/config.py b/app/python/src/config.py index 7869246..82e5a5a 100644 --- a/app/python/src/config.py +++ b/app/python/src/config.py @@ -7,8 +7,8 @@ class Config: ENVIRONMENT = os.getenv("ENVIRONMENT", "development") - AWS_KEY_ID = os.getenv("AWS_KEY_ID", "") - AWS_KEY_SECRET = os.getenv("AWS_KEY_SECRET", "") + AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "") + AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "") AWS_REGION_NAME = os.getenv("AWS_REGION_NAME", "us-east-1") diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index 24ca7cb..9f22c0a 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -1,6 +1,6 @@ import boto3 -from config import IS_DEVELOPMENT, Config +from ..config import IS_DEVELOPMENT, Config from ..schemas.schemas import SubscriptionSchema @@ -12,8 +12,8 @@ class DynamoFender: def __init__(self, tablename) -> None: if IS_DEVELOPMENT: auth_params = { - "aws_access_key_id": Config.AWS_KEY_ID, - "aws_secret_access_key": Config.AWS_KEY_SECRET, + "aws_access_key_id": Config.AWS_ACCESS_KEY_ID, + "aws_secret_access_key": Config.AWS_SECRET_ACCESS_KEY, "region_name": Config.AWS_REGION_NAME, } self.dynamodb = boto3.resource("dynamodb", **auth_params) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 333657d..cf3338c 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -1,17 +1,25 @@ +from pydantic import BaseModel + from ..schemas.schemas import PlanSchema, SubscriptionEventPayload, SubscriptionSchema -class SubscriptionModel: +class SubscriptionModel(BaseModel): + payload: SubscriptionEventPayload def create(self, data: SubscriptionSchema) -> None: pass -class PlanModel: +class PlanModel(BaseModel): + payload: SubscriptionEventPayload def create(self, data: PlanSchema) -> None: pass -class SubscriptionPlanModel: +class SubscriptionPlanModel(BaseModel): + """ + Determines the data model for both Subscription and Plan. + """ + payload: SubscriptionEventPayload diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index cb555a0..d317bec 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -1,5 +1,6 @@ import pytest +from ..db.tables import DynamoFenderTables from ..main import handler from ..schemas.schemas import EventSchema, SubscriptionEventPayload From 8fa3ac150f2966f8966137f26265c7bb58182bfb Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Fri, 7 Nov 2025 18:27:54 -0600 Subject: [PATCH 24/46] feat: refactor DynamoDB models and enhance query capabilities - Add DynamoDB serialization utilities for data type conversion - Simplify batch_write method to accept both dict and list inputs - Add get_by_pk and get_or_create methods to DynamoFender class - Move data models from schemas to models module with proper separation - Add pk/sk properties to SubscriptionEventPayload for DynamoDB keys - Create adapter pattern for subscription and plan operations - Remove duplicate enum definitions and consolidate in models --- app/python/src/db/dynamo.py | 32 ++++++++++++++++--- app/python/src/models/models.py | 52 ++++++++++++++++++++++++++++--- app/python/src/schemas/schemas.py | 42 +++++-------------------- 3 files changed, 82 insertions(+), 44 deletions(-) diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index 9f22c0a..5b17570 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -1,9 +1,21 @@ +import json + import boto3 +from boto3.dynamodb.conditions import And, Key from ..config import IS_DEVELOPMENT, Config from ..schemas.schemas import SubscriptionSchema +def serialize_dynamo(dict): + return json.loads(json.dumps(dict, default=str_dynamo_data)) + + +def str_dynamo_data(obj): + # Coerce every object into string + return str(obj) + + class DynamoFender: """ DynamoDB connection handler for Fender application. @@ -33,11 +45,8 @@ def write(self, data: list) -> bool: Writes data into table """ - if not data: - raise ValueError("Data cannot be empty or null") - - if not isinstance(data, list): - raise ValueError(f"Data must be type `list`, given `{type(data)}`") + if isinstance(data, dict): + data = [data] with self.table.batch_writer() as batch: for values in data: @@ -45,6 +54,19 @@ def write(self, data: list) -> bool: return True + def get_by_pk(self, pk: str) -> dict: + response = self.table.query(KeyConditionExpression=Key("pk").eq(pk)) + return serialize_dynamo(response["Items"]) + + def get_or_create(self, pk: str, sk: str) -> dict: + response = self.table.query( + KeyConditionExpression=And(Key("pk").eq(pk), Key("sk").eq(sk)) + ) + items = serialize_dynamo(response["Items"]) + if items: + return items[0] + return {} + class SubscriptionTable(DynamoFender): """ diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index cf3338c..687272c 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -1,19 +1,61 @@ +from enum import StrEnum +from typing import Literal + from pydantic import BaseModel -from ..schemas.schemas import PlanSchema, SubscriptionEventPayload, SubscriptionSchema +from ..db.tables import DynamoFenderTables +from ..schemas.schemas import SubscriptionEventPayload + + +class BillingCycle(StrEnum): + MONTHLY = "monthly" + YEARLY = "yearly" + + +class PlanStatus(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" class SubscriptionModel(BaseModel): + pk: str + sk: str + type: str = "sub" + planSku: str + startDate: str + expiresAt: str + cancelledAt: str | None + lastModified: str + attributes: dict + + +class PlanModel(BaseModel): + pk: str + sk: str + type: str + name: str + price: float + currency: str + billingCycle: Literal[BillingCycle.MONTHLY, BillingCycle.YEARLY] + features: list[str] + status: Literal[PlanStatus.ACTIVE, PlanStatus.INACTIVE] + + +class SubscriptionAdapter(BaseModel): payload: SubscriptionEventPayload - def create(self, data: SubscriptionSchema) -> None: - pass + def get_or_create(self) -> None: + DynamoFenderTables.SUBSCRIPTION.get_by_pk(self.payload.pk) + def create(self, data: SubscriptionModel) -> None: + data = {"pk": self.payload.pk} + SubscriptionModel(**data) -class PlanModel(BaseModel): + +class PlanAdapter(BaseModel): payload: SubscriptionEventPayload - def create(self, data: PlanSchema) -> None: + def create(self, data: PlanModel) -> None: pass diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index 855e4d5..341990e 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -13,16 +13,6 @@ class SubscriptionStatus(StrEnum): CANCELLED = "cancelled" -class PlanStatus(StrEnum): - ACTIVE = "active" - INACTIVE = "inactive" - - -class BillingCycle(StrEnum): - MONTHLY = "monthly" - YEARLY = "yearly" - - class SubscriptionType(StrEnum): RENEWAL = "subscription.renewed" CREATED = "subscription.created" @@ -68,6 +58,14 @@ class SubscriptionEventPayload(BaseModel): cancelledAt: str | None metadata: MetadataSchema + @property + def pk(self) -> str: + return self.userId + + @property + def sk(self) -> str: + return self.subscriptionId + @property def _current_datetime(self) -> datetime: return datetime.now(timezone.utc) @@ -105,27 +103,3 @@ def compute_status(self) -> SubscriptionStatus: return SubscriptionStatus.PENDING if self.is_cancelled: return SubscriptionStatus.CANCELLED - - -class SubscriptionSchema(BaseModel): - pk: str - sk: str - type: str = "sub" - planSku: str - startDate: str - expiresAt: str - cancelledAt: str | None - lastModified: str - attributes: dict - - -class PlanSchema(BaseModel): - pk: str - sk: str - type: str - name: str - price: float - currency: str - billingCycle: Literal[BillingCycle.MONTHLY, BillingCycle.YEARLY] - features: list[str] - status: Literal[PlanStatus.ACTIVE, PlanStatus.INACTIVE] From b96aa64100454510fe8b2d159dbb7a763c88755e Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Fri, 7 Nov 2025 18:40:13 -0600 Subject: [PATCH 25/46] feat(models): implement subscription creation and processing logic - Add create method to SubscriptionModel for DynamoDB writes - Replace placeholder create method in SubscriptionAdapter with process method - Implement complete subscription data mapping from event payload - Remove unused SubscriptionPlanModel class - Add proper field mapping for subscription attributes and metadata --- app/python/src/models/models.py | 38 +++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 687272c..427752f 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -28,6 +28,9 @@ class SubscriptionModel(BaseModel): lastModified: str attributes: dict + def create(self) -> None: + DynamoFenderTables.SUBSCRIPTION.write(self.model_dump()) + class PlanModel(BaseModel): pk: str @@ -47,9 +50,30 @@ class SubscriptionAdapter(BaseModel): def get_or_create(self) -> None: DynamoFenderTables.SUBSCRIPTION.get_by_pk(self.payload.pk) - def create(self, data: SubscriptionModel) -> None: - data = {"pk": self.payload.pk} - SubscriptionModel(**data) + def process(self) -> None: + """ + Process subscription event payload and create subscription record. + """ + DEFAULT_TYPE = "sub" + data = { + "pk": self.payload.userId, + "sk": self.payload.subscriptionId, + "type": DEFAULT_TYPE, + "planSku": self.payload.metadata.planSku, + "startDate": self.payload.timestamp, + "expiresAt": self.payload.expiresAt, + "cancelledAt": self.payload.cancelledAt, + "lastModified": self.payload.timestamp, + "attributes": { + "provider": self.payload.provider, + "paymentId": self.payload.paymentId, + "customerId": self.payload.customerId, + "autoRenew": self.payload.metadata.autoRenew, + "paymentMethod": self.payload.metadata.paymentMethod, + }, + } + subscription_model = SubscriptionModel(**data) + subscription_model.create() class PlanAdapter(BaseModel): @@ -57,11 +81,3 @@ class PlanAdapter(BaseModel): def create(self, data: PlanModel) -> None: pass - - -class SubscriptionPlanModel(BaseModel): - """ - Determines the data model for both Subscription and Plan. - """ - - payload: SubscriptionEventPayload From cb4759e001b0448a36c1d361db77472c81e5ec2c Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Fri, 7 Nov 2025 18:58:59 -0600 Subject: [PATCH 26/46] refactor: simplify SubscriptionAdapter and remove unused SubscriptionTable methods - Remove unused SubscriptionSchema import and write method from SubscriptionTable - Refactor SubscriptionAdapter.process() to use private _create() method for better separation of concerns - Replace union type syntax with Optional for better Python 3.8+ compatibility - Add new test for SubscriptionAdapter and skip existing tests temporarily --- app/python/src/db/dynamo.py | 4 ---- app/python/src/models/models.py | 5 ++++- app/python/src/schemas/schemas.py | 8 ++++---- app/python/src/tests/main.py | 10 ++++++++++ 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index 5b17570..bdad3b6 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -4,7 +4,6 @@ from boto3.dynamodb.conditions import And, Key from ..config import IS_DEVELOPMENT, Config -from ..schemas.schemas import SubscriptionSchema def serialize_dynamo(dict): @@ -79,9 +78,6 @@ def __init__(self, tablename: str = None) -> None: _tablename = tablename or self.__tablename__ super().__init__(_tablename) - def write(self, data: SubscriptionSchema) -> bool: - return super().write(data.model_dump()) - class PlanTable(DynamoFender): """ diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 427752f..8404f8e 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -50,7 +50,7 @@ class SubscriptionAdapter(BaseModel): def get_or_create(self) -> None: DynamoFenderTables.SUBSCRIPTION.get_by_pk(self.payload.pk) - def process(self) -> None: + def _create(self) -> None: """ Process subscription event payload and create subscription record. """ @@ -75,6 +75,9 @@ def process(self) -> None: subscription_model = SubscriptionModel(**data) subscription_model.create() + def process(self) -> None: + self._create() + class PlanAdapter(BaseModel): payload: SubscriptionEventPayload diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index 341990e..803d183 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone from enum import StrEnum -from typing import Literal +from typing import Optional from pydantic import BaseModel @@ -27,8 +27,8 @@ class SupportedMethods(StrEnum): class EventSchema(BaseModel): httpMethod: str path: str - body: dict | None - pathParameters: dict | None + body: Optional[dict] = None + pathParameters: Optional[dict] = None @property def is_get(self) -> bool: @@ -55,7 +55,7 @@ class SubscriptionEventPayload(BaseModel): userId: str customerId: str expiresAt: str - cancelledAt: str | None + cancelledAt: Optional[str] = None metadata: MetadataSchema @property diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index d317bec..37fa7f6 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -2,6 +2,7 @@ from ..db.tables import DynamoFenderTables from ..main import handler +from ..models.models import SubscriptionAdapter from ..schemas.schemas import EventSchema, SubscriptionEventPayload CREATED_SUBSCRIPTION_EVENT = { @@ -151,6 +152,7 @@ } +@pytest.mark.skip(reason="Skipping this test for now") def test_handler_returns_success_response(): event = AWS_GET_EVENT_SUBSCRIPTION context = {} @@ -158,6 +160,7 @@ def test_handler_returns_success_response(): response = handler(event, context) +@pytest.mark.skip(reason="Skipping this test for now") def test_event_schema_get_method(): event = EventSchema(**AWS_GET_EVENT_SUBSCRIPTION) assert event.is_get is True @@ -168,6 +171,7 @@ def test_event_schema_get_method(): assert event.is_post is True +@pytest.mark.skip(reason="Skipping this test for now") def test_subscription_event_creation(): subscription_payload = SubscriptionEventPayload(**CREATED_SUBSCRIPTION_EVENT) @@ -175,3 +179,9 @@ def test_subscription_event_creation(): assert subscription_payload.metadata.planSku == "PREMIUM_MONTHLY" assert subscription_payload.metadata.autoRenew is True assert subscription_payload.metadata.paymentMethod == "CREDIT_CARD" + + +def test_subscription_adapter(): + subscription_payload = SubscriptionEventPayload(**CREATED_SUBSCRIPTION_EVENT) + adapter = SubscriptionAdapter(payload=subscription_payload) + adapter.process() From 1f872ca4f45f8b05dd94fba20f004ca8ec207cd7 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sat, 8 Nov 2025 00:01:25 -0600 Subject: [PATCH 27/46] feat(models): implement PlanAdapter with faker data generation - Add faker dependency for generating mock plan data - Update DynamoFender.write() to accept dict or list parameters - Implement PlanAdapter.create() method with random billing cycles and features - Add plan_name property to SubscriptionEventPayload for formatted plan names - Update SubscriptionModel to ignore None values when writing to database --- app/python/src/db/dynamo.py | 2 +- app/python/src/models/models.py | 32 ++++++++++++++++++++++++++++--- app/python/src/schemas/schemas.py | 12 ++++-------- pyproject.toml | 1 + uv.lock | 23 ++++++++++++++++++++++ 5 files changed, 58 insertions(+), 12 deletions(-) diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index bdad3b6..6bcfc8a 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -39,7 +39,7 @@ def __init__(self, tablename) -> None: f"Could't make connection to `{tablename}` table due `{error}`" ) - def write(self, data: list) -> bool: + def write(self, data: list | dict) -> bool: """ Writes data into table """ diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 8404f8e..fe3845e 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -1,11 +1,15 @@ +import random from enum import StrEnum from typing import Literal +from faker import Faker from pydantic import BaseModel from ..db.tables import DynamoFenderTables from ..schemas.schemas import SubscriptionEventPayload +fake = Faker("es_MX") + class BillingCycle(StrEnum): MONTHLY = "monthly" @@ -29,7 +33,7 @@ class SubscriptionModel(BaseModel): attributes: dict def create(self) -> None: - DynamoFenderTables.SUBSCRIPTION.write(self.model_dump()) + DynamoFenderTables.SUBSCRIPTION.write(self.model_dump(ignore_none=True)) class PlanModel(BaseModel): @@ -82,5 +86,27 @@ def process(self) -> None: class PlanAdapter(BaseModel): payload: SubscriptionEventPayload - def create(self, data: PlanModel) -> None: - pass + def create(self) -> None: + """ + Process subscription event payload and create plan record. + """ + DEFAULT_TYPE = "plan" + DEFAULT_CURRENCY = "USD" + # Random price and currency for demonstration purposes + random_billing_cycle = random.choice( + [BillingCycle.MONTHLY, BillingCycle.YEARLY] + ) + random_features = [f"feature {el}" for el in fake.random_sample()] + data = { + "pk": self.payload.metadata.planSku, + "sk": self.payload.metadata.paymentMethod, + "type": DEFAULT_TYPE, + "name": self.payload.plan_name, + "price": float(fake.numerify()), + "currency": DEFAULT_CURRENCY, + "billingCycle": random_billing_cycle, + "features": random_features, + "status": PlanStatus.ACTIVE, + } + plan_model = PlanModel(**data) + DynamoFenderTables.PLAN.write(plan_model.model_dump()) diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index 803d183..563237d 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -58,18 +58,14 @@ class SubscriptionEventPayload(BaseModel): cancelledAt: Optional[str] = None metadata: MetadataSchema - @property - def pk(self) -> str: - return self.userId - - @property - def sk(self) -> str: - return self.subscriptionId - @property def _current_datetime(self) -> datetime: return datetime.now(timezone.utc) + @property + def plan_name(self) -> str: + return self.metadata.planSku.replace("_", " ").title() + @property def is_renewal(self) -> bool: return self.eventType == SubscriptionType.RENEWAL diff --git a/pyproject.toml b/pyproject.toml index d328049..63820c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12" dependencies = [ + "faker>=37.12.0", "pydantic>=2.12.4", "python-dotenv>=1.2.1", ] diff --git a/uv.lock b/uv.lock index 7bbffce..7eb314a 100644 --- a/uv.lock +++ b/uv.lock @@ -75,11 +75,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317 }, ] +[[package]] +name = "faker" +version = "37.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461 }, +] + [[package]] name = "fds-aws-coding-exercise" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "faker" }, { name = "pydantic" }, { name = "python-dotenv" }, ] @@ -93,6 +106,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "faker", specifier = ">=37.12.0" }, { name = "pydantic", specifier = ">=2.12.4" }, { name = "python-dotenv", specifier = ">=1.2.1" }, ] @@ -445,6 +459,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] + [[package]] name = "urllib3" version = "2.5.0" From 82cd2c3835f38bb44f540879d5b2ea962a2836b5 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sat, 8 Nov 2025 00:21:50 -0600 Subject: [PATCH 28/46] feat: enhance subscription and plan processing with validation and data flow - Add default values to model fields (cancelledAt, lastModified) - Implement get() methods for existence checking in adapters - Add create() method to PlanModel for database operations - Refactor adapter methods to check existence before creation - Create unified process_subscription_and_plan() function - Integrate processing logic into post_user_subscription route - Fix feature naming capitalization and add lastModified tracking --- app/python/src/models/models.py | 36 ++++++++++++++++++++++++++------- app/python/src/routes.py | 6 +++++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index fe3845e..4d77fd8 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -28,7 +28,7 @@ class SubscriptionModel(BaseModel): planSku: str startDate: str expiresAt: str - cancelledAt: str | None + cancelledAt: str | None = None lastModified: str attributes: dict @@ -46,13 +46,17 @@ class PlanModel(BaseModel): billingCycle: Literal[BillingCycle.MONTHLY, BillingCycle.YEARLY] features: list[str] status: Literal[PlanStatus.ACTIVE, PlanStatus.INACTIVE] + lastModified: str | None = None + + def create(self) -> None: + DynamoFenderTables.PLAN.write(self.model_dump()) class SubscriptionAdapter(BaseModel): payload: SubscriptionEventPayload - def get_or_create(self) -> None: - DynamoFenderTables.SUBSCRIPTION.get_by_pk(self.payload.pk) + def get(self) -> dict: + return DynamoFenderTables.PLAN.get_by_pk(self.payload.metadata.planSku) def _create(self) -> None: """ @@ -80,13 +84,17 @@ def _create(self) -> None: subscription_model.create() def process(self) -> None: - self._create() + if not self.get(): + self._create() class PlanAdapter(BaseModel): payload: SubscriptionEventPayload - def create(self) -> None: + def get(self) -> dict: + return DynamoFenderTables.PLAN.get_by_pk(self.payload.metadata.planSku) + + def _create(self) -> None: """ Process subscription event payload and create plan record. """ @@ -96,7 +104,7 @@ def create(self) -> None: random_billing_cycle = random.choice( [BillingCycle.MONTHLY, BillingCycle.YEARLY] ) - random_features = [f"feature {el}" for el in fake.random_sample()] + random_features = [f"Feature {el}" for el in fake.random_sample()] data = { "pk": self.payload.metadata.planSku, "sk": self.payload.metadata.paymentMethod, @@ -107,6 +115,20 @@ def create(self) -> None: "billingCycle": random_billing_cycle, "features": random_features, "status": PlanStatus.ACTIVE, + "lastModified": self.payload.timestamp, } plan_model = PlanModel(**data) - DynamoFenderTables.PLAN.write(plan_model.model_dump()) + plan_model.create() + + def process(self) -> None: + if not self.get(): + return self._create() + + +def process_subscription_and_plan(payload: SubscriptionEventPayload) -> None: + plan_adapter = PlanAdapter(payload=payload) + plan_adapter.process() + + subscription_adapter = SubscriptionAdapter(payload=payload) + subscription_adapter.process() + return True diff --git a/app/python/src/routes.py b/app/python/src/routes.py index 738f98d..c5a30e2 100644 --- a/app/python/src/routes.py +++ b/app/python/src/routes.py @@ -1,5 +1,6 @@ from pydantic import BaseModel +from .models.models import process_subscription_and_plan from .schemas.schemas import EventSchema, SubscriptionEventPayload from .utils.response import success_response @@ -12,7 +13,10 @@ def get_user_subscription(self) -> str | None: def post_user_subscription(self, body: SubscriptionEventPayload) -> None: # TODO: Add DynamoDB logic to store the subscription event - pass + adapter = process_subscription_and_plan(payload=body) + + if adapter: + return success_response("Subscription created successfully") def process_event(self) -> dict: From f04b5467025c9d5f4f4707f7a61308e93c5c4251 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sat, 8 Nov 2025 22:13:04 -0600 Subject: [PATCH 29/46] refactor: add validation wrapper and improve model methods - Add validation_wrapper decorator for error handling - Fix model_dump parameter from ignore_none to exclude_none - Change SubscriptionAdapter.process() return type to bool - Add error_response utility function - Enable test_handler_post_event and skip test_subscription_adapter - Import validation_wrapper in models module --- app/python/src/models/models.py | 6 +++-- app/python/src/tests/main.py | 13 +++++++-- app/python/src/utils/response.py | 45 ++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 4d77fd8..52c048c 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -7,6 +7,7 @@ from ..db.tables import DynamoFenderTables from ..schemas.schemas import SubscriptionEventPayload +from ..utils.response import validation_wrapper fake = Faker("es_MX") @@ -33,7 +34,7 @@ class SubscriptionModel(BaseModel): attributes: dict def create(self) -> None: - DynamoFenderTables.SUBSCRIPTION.write(self.model_dump(ignore_none=True)) + DynamoFenderTables.SUBSCRIPTION.write(self.model_dump(exclude_none=True)) class PlanModel(BaseModel): @@ -83,7 +84,7 @@ def _create(self) -> None: subscription_model = SubscriptionModel(**data) subscription_model.create() - def process(self) -> None: + def process(self) -> bool: if not self.get(): self._create() @@ -125,6 +126,7 @@ def process(self) -> None: return self._create() +@validation_wrapper def process_subscription_and_plan(payload: SubscriptionEventPayload) -> None: plan_adapter = PlanAdapter(payload=payload) plan_adapter.process() diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 37fa7f6..6063085 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -147,19 +147,27 @@ "deploymentId": "w3dwr7", "apiId": "0s2r8aurv7", }, - "body": {"sample_key": "sample_value"}, + "body": CREATED_SUBSCRIPTION_EVENT, "isBase64Encoded": False, } @pytest.mark.skip(reason="Skipping this test for now") -def test_handler_returns_success_response(): +def test_handler_get_event(): event = AWS_GET_EVENT_SUBSCRIPTION context = {} response = handler(event, context) +# @pytest.mark.skip(reason="Skipping this test for now") +def test_handler_post_event(): + event = AWS_POST_EVENT_SUBSCRIPTION + context = {} + + response = handler(event, context) + + @pytest.mark.skip(reason="Skipping this test for now") def test_event_schema_get_method(): event = EventSchema(**AWS_GET_EVENT_SUBSCRIPTION) @@ -181,6 +189,7 @@ def test_subscription_event_creation(): assert subscription_payload.metadata.paymentMethod == "CREDIT_CARD" +@pytest.mark.skip(reason="Skipping this test for now") def test_subscription_adapter(): subscription_payload = SubscriptionEventPayload(**CREATED_SUBSCRIPTION_EVENT) adapter = SubscriptionAdapter(payload=subscription_payload) diff --git a/app/python/src/utils/response.py b/app/python/src/utils/response.py index a980950..3abbf9d 100644 --- a/app/python/src/utils/response.py +++ b/app/python/src/utils/response.py @@ -1,9 +1,54 @@ import json +from functools import wraps from http import HTTPStatus +from pydantic import ValidationError + def success_response(body: str) -> dict: return { "statusCode": HTTPStatus.OK, "body": json.dumps({"message": body}), } + + +def error_response(body: str, status_code: HTTPStatus = HTTPStatus.BAD_REQUEST) -> dict: + return { + "statusCode": status_code, + "body": json.dumps({"error": body}), + } + + +def process_pydantic_error(e): + fields = [] + + for error in e.errors(): + if error.get("loc"): + location = error["loc"][0] + message = error["msg"] + error_message = f"'{location}': `{message}`" + fields.append(error_message) + else: + fields.append(error["msg"]) + + fields_error = f", ".join(fields) + + return fields_error + + +def validation_wrapper(f): + """ + Decorator to catch validation errors + """ + + @wraps(f) + def decorator(*args, **kwargs): + try: + return f(*args, **kwargs) + except ValidationError as e: + message = process_pydantic_error(e) + return False, error_response(message) + except Exception as e: + return False, error_response(str(e)) + + return decorator From 64bf1ce9e0b86a698222840eb559b544dae45394 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sat, 8 Nov 2025 22:42:10 -0600 Subject: [PATCH 30/46] feat: add DynamoDB float serialization and improve response handling - Add dynamo_write_serializer function to convert float values to Decimal for DynamoDB compatibility - Update batch_put_items to use serializer before writing data - Modify process_subscription_and_plan to return success_response instead of boolean - Streamline post_user_subscription to directly return processed response - Add debug print statement in test for response validation --- app/python/src/db/dynamo.py | 15 ++++++++++++++- app/python/src/models/models.py | 4 ++-- app/python/src/routes.py | 10 +++------- app/python/src/tests/main.py | 1 + 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index 6bcfc8a..cc9f015 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -1,4 +1,5 @@ import json +from decimal import Decimal import boto3 from boto3.dynamodb.conditions import And, Key @@ -15,6 +16,17 @@ def str_dynamo_data(obj): return str(obj) +def dynamo_write_serializer(dict: dict) -> dict: + """ + Serialize data before writing into DynamoDB + """ + for key, value in dict.items(): + if isinstance(value, float): + dict[key] = Decimal(str(value)) + + return dict + + class DynamoFender: """ DynamoDB connection handler for Fender application. @@ -49,7 +61,8 @@ def write(self, data: list | dict) -> bool: with self.table.batch_writer() as batch: for values in data: - batch.put_item(Item=values) + serialized_values = dynamo_write_serializer(values) + batch.put_item(Item=serialized_values) return True diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 52c048c..019ca12 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -7,7 +7,7 @@ from ..db.tables import DynamoFenderTables from ..schemas.schemas import SubscriptionEventPayload -from ..utils.response import validation_wrapper +from ..utils.response import success_response, validation_wrapper fake = Faker("es_MX") @@ -133,4 +133,4 @@ def process_subscription_and_plan(payload: SubscriptionEventPayload) -> None: subscription_adapter = SubscriptionAdapter(payload=payload) subscription_adapter.process() - return True + return success_response("Subscription and Plan processed successfully") diff --git a/app/python/src/routes.py b/app/python/src/routes.py index c5a30e2..2dc1246 100644 --- a/app/python/src/routes.py +++ b/app/python/src/routes.py @@ -11,12 +11,8 @@ class Router(BaseModel): def get_user_subscription(self) -> str | None: pass - def post_user_subscription(self, body: SubscriptionEventPayload) -> None: - # TODO: Add DynamoDB logic to store the subscription event - adapter = process_subscription_and_plan(payload=body) - - if adapter: - return success_response("Subscription created successfully") + def post_user_subscription(self, body: SubscriptionEventPayload) -> dict: + return process_subscription_and_plan(payload=body) def process_event(self) -> dict: @@ -25,7 +21,7 @@ def process_event(self) -> dict: elif self.event.is_post: body = SubscriptionEventPayload(**self.event.body) - self.post_user_subscription(body=body) + return self.post_user_subscription(body=body) else: raise ValueError("Unsupported HTTP method") diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 6063085..304aa1a 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -166,6 +166,7 @@ def test_handler_post_event(): context = {} response = handler(event, context) + print(response) @pytest.mark.skip(reason="Skipping this test for now") From 95443162eb8d553a60ff70752284cb3a215e2745 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sat, 8 Nov 2025 23:24:43 -0600 Subject: [PATCH 31/46] feat: refactor subscription handling and fix table reference - Fix DynamoDB table reference from PLAN to SUBSCRIPTION in SubscriptionAdapter - Extract subscription methods from Router class to standalone functions - Implement user ID extraction for GET requests from path parameters - Replace success_response with error_response for unsupported methods - Update test data with simplified user IDs and path parameters --- app/python/src/models/models.py | 2 +- app/python/src/routes.py | 23 +++++++++++++---------- app/python/src/tests/main.py | 4 ++-- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 019ca12..04ef218 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -57,7 +57,7 @@ class SubscriptionAdapter(BaseModel): payload: SubscriptionEventPayload def get(self) -> dict: - return DynamoFenderTables.PLAN.get_by_pk(self.payload.metadata.planSku) + return DynamoFenderTables.SUBSCRIPTION.get_by_pk(self.payload.metadata.planSku) def _create(self) -> None: """ diff --git a/app/python/src/routes.py b/app/python/src/routes.py index 2dc1246..dd57aed 100644 --- a/app/python/src/routes.py +++ b/app/python/src/routes.py @@ -2,26 +2,29 @@ from .models.models import process_subscription_and_plan from .schemas.schemas import EventSchema, SubscriptionEventPayload -from .utils.response import success_response +from .utils.response import error_response -class Router(BaseModel): - event: EventSchema +def get_user_subscription(user_id: str) -> dict: + return {} + - def get_user_subscription(self) -> str | None: - pass +def post_user_subscription(body: SubscriptionEventPayload) -> dict: + return process_subscription_and_plan(payload=body) - def post_user_subscription(self, body: SubscriptionEventPayload) -> dict: - return process_subscription_and_plan(payload=body) + +class Router(BaseModel): + event: EventSchema def process_event(self) -> dict: if self.event.is_get: - return success_response("GET method processed successfully") + user_id = self.event.pathParameters.get("userId") + return get_user_subscription(user_id=user_id) elif self.event.is_post: body = SubscriptionEventPayload(**self.event.body) - return self.post_user_subscription(body=body) + return post_user_subscription(body=body) else: - raise ValueError("Unsupported HTTP method") + return error_response("Unsupported HTTP method", status_code=405) diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 304aa1a..3e4345e 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -50,7 +50,7 @@ }, "queryStringParameters": None, "multiValueQueryStringParameters": None, - "pathParameters": {"userId": "dummy_user"}, + "pathParameters": {"userId": "123"}, "stageVariables": None, "requestContext": { "resourceId": "xxxxyyy", @@ -114,7 +114,7 @@ }, "queryStringParameters": None, "multiValueQueryStringParameters": None, - "pathParameters": {"userId": "dummy_user"}, + "pathParameters": None, "stageVariables": None, "requestContext": { "resourceId": "xxxxyyy", From 5354acdb2f7aad38f78ee1f377e57e691a5ff5ab Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sun, 9 Nov 2025 00:07:14 -0600 Subject: [PATCH 32/46] refactor: improve subscription/plan adapters and add user retrieval endpoint - Rename public methods to private (_get_by_pk) in SubscriptionAdapter and PlanAdapter - Fix SubscriptionAdapter to use userId instead of planSku for lookup - Add new SubscriptionAndPlanAdapter class for combined subscription/plan retrieval - Add retrieve() methods with proper error handling for missing records - Implement process_user_id function and router_get_user_subscription endpoint - Update route handlers with consistent naming (router_ prefix) - Enhance EventSchema with typed UserParamsSchema for path parameters - Improve pathParameters access from dict.get() to direct attribute access --- app/python/src/models/models.py | 45 +++++++++++++++++++++++++++---- app/python/src/routes.py | 14 +++++----- app/python/src/schemas/schemas.py | 6 ++++- app/python/src/tests/main.py | 7 ++--- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 04ef218..650b566 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -56,8 +56,8 @@ def create(self) -> None: class SubscriptionAdapter(BaseModel): payload: SubscriptionEventPayload - def get(self) -> dict: - return DynamoFenderTables.SUBSCRIPTION.get_by_pk(self.payload.metadata.planSku) + def _get_by_pk(self) -> dict: + return DynamoFenderTables.SUBSCRIPTION.get_by_pk(self.payload.userId) def _create(self) -> None: """ @@ -85,14 +85,17 @@ def _create(self) -> None: subscription_model.create() def process(self) -> bool: - if not self.get(): + if not self._get_by_pk(): self._create() + def retrieve(self) -> dict: + data = self._get_by_pk() + class PlanAdapter(BaseModel): payload: SubscriptionEventPayload - def get(self) -> dict: + def _get_by_pk(self) -> dict: return DynamoFenderTables.PLAN.get_by_pk(self.payload.metadata.planSku) def _create(self) -> None: @@ -122,10 +125,32 @@ def _create(self) -> None: plan_model.create() def process(self) -> None: - if not self.get(): + if not self._get_by_pk(): return self._create() +class SubscriptionAndPlanAdapter(BaseModel): + user_id: str + + def _get_sub_by_pk(self) -> SubscriptionModel: + if not ( + subscription := DynamoFenderTables.SUBSCRIPTION.get_by_pk(self.user_id) + ): + raise ValueError("Subscription not found") + + return SubscriptionModel(**subscription[0]) + + def _get_plan_by_pk(self, plan_sku: str) -> dict: + if not (plan := DynamoFenderTables.PLAN.get_by_pk(plan_sku)): + raise ValueError("Plan not found") + + return PlanModel(**plan[0]) + + def retrieve(self) -> dict: + subscription = self._get_sub_by_pk() + plan = self._get_plan_by_pk(subscription.planSku) + + @validation_wrapper def process_subscription_and_plan(payload: SubscriptionEventPayload) -> None: plan_adapter = PlanAdapter(payload=payload) @@ -134,3 +159,13 @@ def process_subscription_and_plan(payload: SubscriptionEventPayload) -> None: subscription_adapter = SubscriptionAdapter(payload=payload) subscription_adapter.process() return success_response("Subscription and Plan processed successfully") + + +@validation_wrapper +def process_user_id(user_id: str) -> dict: + """ + Fetch user subscription by user ID. + """ + subscription_adapter = SubscriptionAndPlanAdapter(user_id=user_id) + data = subscription_adapter.retrieve() + print(data) diff --git a/app/python/src/routes.py b/app/python/src/routes.py index dd57aed..c4ec8ca 100644 --- a/app/python/src/routes.py +++ b/app/python/src/routes.py @@ -1,15 +1,15 @@ from pydantic import BaseModel -from .models.models import process_subscription_and_plan +from .models.models import process_subscription_and_plan, process_user_id from .schemas.schemas import EventSchema, SubscriptionEventPayload from .utils.response import error_response -def get_user_subscription(user_id: str) -> dict: - return {} +def router_get_user_subscription(user_id: str) -> dict: + return process_user_id(user_id=user_id) -def post_user_subscription(body: SubscriptionEventPayload) -> dict: +def router_post_user_subscription(body: SubscriptionEventPayload) -> dict: return process_subscription_and_plan(payload=body) @@ -19,12 +19,12 @@ class Router(BaseModel): def process_event(self) -> dict: if self.event.is_get: - user_id = self.event.pathParameters.get("userId") - return get_user_subscription(user_id=user_id) + user_id = self.event.pathParameters.userId + return router_get_user_subscription(user_id=user_id) elif self.event.is_post: body = SubscriptionEventPayload(**self.event.body) - return post_user_subscription(body=body) + return router_post_user_subscription(body=body) else: return error_response("Unsupported HTTP method", status_code=405) diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index 563237d..37fc59e 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -24,11 +24,15 @@ class SupportedMethods(StrEnum): POST = "POST" +class UserParamsSchema(BaseModel): + userId: str + + class EventSchema(BaseModel): httpMethod: str path: str body: Optional[dict] = None - pathParameters: Optional[dict] = None + pathParameters: Optional[UserParamsSchema] = None @property def is_get(self) -> bool: diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 3e4345e..50e4b17 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -24,7 +24,7 @@ AWS_GET_EVENT_SUBSCRIPTION = { "resource": "/api/v1/subscriptions/{userId}", - "path": "/api/v1/subscriptions/dummy_user", + "path": "/api/v1/subscriptions/123", "httpMethod": "GET", "headers": { "Accept": "*/*", @@ -152,15 +152,16 @@ } -@pytest.mark.skip(reason="Skipping this test for now") +# @pytest.mark.skip(reason="Skipping this test for now") def test_handler_get_event(): event = AWS_GET_EVENT_SUBSCRIPTION context = {} response = handler(event, context) + print(response) -# @pytest.mark.skip(reason="Skipping this test for now") +@pytest.mark.skip(reason="Skipping this test for now") def test_handler_post_event(): event = AWS_POST_EVENT_SUBSCRIPTION context = {} From 3f8e03142f46bb37e793b42fb6c2c63929f91de6 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sun, 9 Nov 2025 00:24:49 -0600 Subject: [PATCH 33/46] feat(models): refactor subscription status logic and enhance data models - Move SubscriptionStatus enum from schemas to models module - Add SubscriptionAttributes model for structured subscription data - Implement status computation logic with pending/cancelled state detection - Update SubscriptionModel with datetime utilities and status methods - Enhance SubscriptionAndPlanAdapter to return structured subscription data - Remove redundant status logic from schemas module --- app/python/src/models/models.py | 69 +++++++++++++++++++++++++++++-- app/python/src/schemas/schemas.py | 35 ---------------- 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 650b566..50ac9ea 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -1,6 +1,7 @@ import random +from datetime import datetime, timezone from enum import StrEnum -from typing import Literal +from typing import Literal, Optional from faker import Faker from pydantic import BaseModel @@ -8,10 +9,17 @@ from ..db.tables import DynamoFenderTables from ..schemas.schemas import SubscriptionEventPayload from ..utils.response import success_response, validation_wrapper +from ..utils.utils import parse_iso8601 fake = Faker("es_MX") +class SubscriptionStatus(StrEnum): + ACTIVE = "active" + PENDING = "pending" + CANCELLED = "cancelled" + + class BillingCycle(StrEnum): MONTHLY = "monthly" YEARLY = "yearly" @@ -22,6 +30,15 @@ class PlanStatus(StrEnum): INACTIVE = "inactive" +class SubscriptionAttributes(BaseModel): + # Adding as optional in case some attributes are missing + provider: Optional[str] = None + paymentId: Optional[str] = None + customerId: Optional[str] = None + autoRenew: Optional[bool] = None + paymentMethod: Optional[str] = None + + class SubscriptionModel(BaseModel): pk: str sk: str @@ -31,11 +48,36 @@ class SubscriptionModel(BaseModel): expiresAt: str cancelledAt: str | None = None lastModified: str - attributes: dict + attributes: SubscriptionAttributes | None = None def create(self) -> None: DynamoFenderTables.SUBSCRIPTION.write(self.model_dump(exclude_none=True)) + @property + def _current_datetime(self) -> datetime: + return datetime.now(timezone.utc) + + @property + def parse_cancelledAt(self) -> str | None: + if self.cancelledAt: + return parse_iso8601(self.cancelledAt) + + @property + def is_pending(self) -> bool: + return self._current_datetime <= self.parse_cancelledAt + + @property + def is_cancelled(self) -> bool: + return self._current_datetime > self.parse_cancelledAt + + def compute_status(self) -> SubscriptionStatus: + if not self.cancelledAt: + return SubscriptionStatus.ACTIVE + if self.is_pending: + return SubscriptionStatus.PENDING + if self.is_cancelled: + return SubscriptionStatus.CANCELLED + class PlanModel(BaseModel): pk: str @@ -140,7 +182,7 @@ def _get_sub_by_pk(self) -> SubscriptionModel: return SubscriptionModel(**subscription[0]) - def _get_plan_by_pk(self, plan_sku: str) -> dict: + def _get_plan_by_pk(self, plan_sku: str) -> PlanModel: if not (plan := DynamoFenderTables.PLAN.get_by_pk(plan_sku)): raise ValueError("Plan not found") @@ -150,6 +192,27 @@ def retrieve(self) -> dict: subscription = self._get_sub_by_pk() plan = self._get_plan_by_pk(subscription.planSku) + return { + "userId": subscription.pk, + "subscriptionId": subscription.sk, + "plan": { + "sku": plan.pk, + "name": plan.name, + "price": plan.price, + "currency": plan.currency, + "billingCycle": plan.billingCycle, + "features": plan.features, + }, + "startDate": subscription.startDate, + "expiresAt": subscription.expiresAt, + "cancelledAt": subscription.cancelledAt, + "status": subscription.compute_status(), + "attributes": { + "autoRenew": subscription.attributes.autoRenew, + "paymentMethod": subscription.attributes.paymentMethod, + }, + } + @validation_wrapper def process_subscription_and_plan(payload: SubscriptionEventPayload) -> None: diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index 37fc59e..f32bac4 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -1,17 +1,8 @@ -from datetime import datetime, timezone from enum import StrEnum from typing import Optional from pydantic import BaseModel -from ..utils.utils import parse_iso8601 - - -class SubscriptionStatus(StrEnum): - ACTIVE = "active" - PENDING = "pending" - CANCELLED = "cancelled" - class SubscriptionType(StrEnum): RENEWAL = "subscription.renewed" @@ -62,10 +53,6 @@ class SubscriptionEventPayload(BaseModel): cancelledAt: Optional[str] = None metadata: MetadataSchema - @property - def _current_datetime(self) -> datetime: - return datetime.now(timezone.utc) - @property def plan_name(self) -> str: return self.metadata.planSku.replace("_", " ").title() @@ -81,25 +68,3 @@ def is_created(self) -> bool: @property def is_cancelled(self) -> bool: return self.eventType == SubscriptionType.CANCELLED - - @property - def parse_cancelledAt(self) -> str | None: - if self.cancelledAt: - return parse_iso8601(self.cancelledAt) - - @property - def is_pending(self) -> bool: - return self._current_datetime <= self.parse_cancelledAt - - @property - def is_cancelled(self) -> bool: - return self._current_datetime > self.parse_cancelledAt - - @property - def compute_status(self) -> SubscriptionStatus: - if not self.cancelledAt: - return SubscriptionStatus.ACTIVE - if self.is_pending: - return SubscriptionStatus.PENDING - if self.is_cancelled: - return SubscriptionStatus.CANCELLED From f1e4a33e693619c3fdcffef84e40e89a30b2bae7 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sun, 9 Nov 2025 00:37:21 -0600 Subject: [PATCH 34/46] feat: improve process_user_id to return structured response with data Replace print statement with proper success response and enhance success_response utility to support optional data payload for better API consistency --- app/python/src/models/models.py | 2 +- app/python/src/utils/response.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 50ac9ea..c9cccea 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -231,4 +231,4 @@ def process_user_id(user_id: str) -> dict: """ subscription_adapter = SubscriptionAndPlanAdapter(user_id=user_id) data = subscription_adapter.retrieve() - print(data) + return success_response("User subscription retrieved successfully", data=data) diff --git a/app/python/src/utils/response.py b/app/python/src/utils/response.py index 3abbf9d..5727ade 100644 --- a/app/python/src/utils/response.py +++ b/app/python/src/utils/response.py @@ -5,10 +5,16 @@ from pydantic import ValidationError -def success_response(body: str) -> dict: +def success_response(body: str, data: dict = None) -> dict: + + body = {"message": body} + + if data: + body["data"] = data + return { "statusCode": HTTPStatus.OK, - "body": json.dumps({"message": body}), + "body": json.dumps(body), } From a9373e2a4b4362059e323aa2413f51182d160552 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sun, 9 Nov 2025 01:37:56 -0600 Subject: [PATCH 35/46] feat: add faker dependency and fix import compatibility for AWS Lambda - Add faker==37.12.0 and tzdata==2025.2 dependencies for fake data generation - Change default environment from "development" to "staging" in config - Implement try/except import pattern across all modules for dual compatibility between local development (relative imports) and AWS Lambda deployment (absolute imports) - Ensure application works seamlessly in both local and serverless environments --- app/python/requirements.txt | 4 ++++ app/python/src/config.py | 2 +- app/python/src/db/dynamo.py | 6 +++++- app/python/src/db/tables.py | 7 ++++++- app/python/src/main.py | 10 ++++++++-- app/python/src/models/models.py | 16 ++++++++++++---- app/python/src/routes.py | 13 ++++++++++--- app/python/src/tests/main.py | 16 ++++++++++++---- scripts/deploy-python.sh | 8 ++++++++ 9 files changed, 66 insertions(+), 16 deletions(-) diff --git a/app/python/requirements.txt b/app/python/requirements.txt index 27e6d4f..42b78e4 100644 --- a/app/python/requirements.txt +++ b/app/python/requirements.txt @@ -2,6 +2,8 @@ # uv pip compile pyproject.toml -o app/python/requirements.txt annotated-types==0.7.0 # via pydantic +faker==37.12.0 + # via fds-aws-coding-exercise (pyproject.toml) pydantic==2.12.4 # via fds-aws-coding-exercise (pyproject.toml) pydantic-core==2.41.5 @@ -15,3 +17,5 @@ typing-extensions==4.15.0 # typing-inspection typing-inspection==0.4.2 # via pydantic +tzdata==2025.2 + # via faker diff --git a/app/python/src/config.py b/app/python/src/config.py index 82e5a5a..5578e5a 100644 --- a/app/python/src/config.py +++ b/app/python/src/config.py @@ -6,7 +6,7 @@ class Config: - ENVIRONMENT = os.getenv("ENVIRONMENT", "development") + ENVIRONMENT = os.getenv("ENVIRONMENT", "staging") AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "") AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "") AWS_REGION_NAME = os.getenv("AWS_REGION_NAME", "us-east-1") diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index cc9f015..fdea089 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -4,7 +4,11 @@ import boto3 from boto3.dynamodb.conditions import And, Key -from ..config import IS_DEVELOPMENT, Config +try: + # For local development + from ..config import IS_DEVELOPMENT, Config +except: + from config import IS_DEVELOPMENT, Config def serialize_dynamo(dict): diff --git a/app/python/src/db/tables.py b/app/python/src/db/tables.py index ef7a418..0b7a237 100644 --- a/app/python/src/db/tables.py +++ b/app/python/src/db/tables.py @@ -1,4 +1,9 @@ -from .dynamo import PlanTable, SubscriptionTable +try: + # For local development + from .dynamo import PlanTable, SubscriptionTable +except: + # For AWS Lambda deployment + from dynamo import PlanTable, SubscriptionTable class DynamoFenderTables: diff --git a/app/python/src/main.py b/app/python/src/main.py index 2885191..ac74d23 100755 --- a/app/python/src/main.py +++ b/app/python/src/main.py @@ -1,5 +1,11 @@ -from .routes import Router -from .schemas.schemas import EventSchema +try: + # For local development + from .routes import Router + from .schemas.schemas import EventSchema +except: + # For AWS Lambda deployment + from routes import Router + from schemas.schemas import EventSchema def handler(event, context): diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index c9cccea..a82c446 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -6,10 +6,18 @@ from faker import Faker from pydantic import BaseModel -from ..db.tables import DynamoFenderTables -from ..schemas.schemas import SubscriptionEventPayload -from ..utils.response import success_response, validation_wrapper -from ..utils.utils import parse_iso8601 +try: + # For local development + from ..db.tables import DynamoFenderTables + from ..schemas.schemas import SubscriptionEventPayload + from ..utils.response import success_response, validation_wrapper + from ..utils.utils import parse_iso8601 +except: + # For AWS Lambda deployment + from db.tables import DynamoFenderTables + from schemas.schemas import SubscriptionEventPayload + from utils.response import success_response, validation_wrapper + from utils.utils import parse_iso8601 fake = Faker("es_MX") diff --git a/app/python/src/routes.py b/app/python/src/routes.py index c4ec8ca..b4e4e6b 100644 --- a/app/python/src/routes.py +++ b/app/python/src/routes.py @@ -1,8 +1,15 @@ from pydantic import BaseModel -from .models.models import process_subscription_and_plan, process_user_id -from .schemas.schemas import EventSchema, SubscriptionEventPayload -from .utils.response import error_response +try: + # For local development + from .models.models import process_subscription_and_plan, process_user_id + from .schemas.schemas import EventSchema, SubscriptionEventPayload + from .utils.response import error_response +except: + # For AWS Lambda deployment + from models.models import process_subscription_and_plan, process_user_id + from schemas.schemas import EventSchema, SubscriptionEventPayload + from utils.response import error_response def router_get_user_subscription(user_id: str) -> dict: diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 50e4b17..89612e4 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -1,9 +1,17 @@ import pytest -from ..db.tables import DynamoFenderTables -from ..main import handler -from ..models.models import SubscriptionAdapter -from ..schemas.schemas import EventSchema, SubscriptionEventPayload +try: + # For local development + from ..db.tables import DynamoFenderTables + from ..main import handler + from ..models.models import SubscriptionAdapter + from ..schemas.schemas import EventSchema, SubscriptionEventPayload +except: + # For AWS Lambda deployment + from db.tables import DynamoFenderTables + from main import handler + from models.models import SubscriptionAdapter + from schemas.schemas import EventSchema, SubscriptionEventPayload CREATED_SUBSCRIPTION_EVENT = { "eventId": "evt_123456789", diff --git a/scripts/deploy-python.sh b/scripts/deploy-python.sh index 2864a43..d5c4375 100755 --- a/scripts/deploy-python.sh +++ b/scripts/deploy-python.sh @@ -4,6 +4,14 @@ mkdir .temp mkdir .temp/package uv pip install -r app/python/requirements.txt --target .temp/package +pip install \ + --platform manylinux2014_x86_64 \ + --implementation cp \ + --python-version 3.13 \ + --only-binary=:all: \ + --target .temp/package \ + --upgrade pydantic + cp -r app/python/src/. .temp/package cd .temp/package From 16c362dbb9c909896dcc8bd80ebd378e55dd8e81 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sun, 9 Nov 2025 09:26:31 -0600 Subject: [PATCH 36/46] feat(deploy): add pydantic v2 deployment guidance and uv usage - Add comment explaining uv usage for faster package installs - Include detailed NOTE about pydantic v2 AWS Lambda deployment requirements - Reference official pydantic documentation for Lambda compatibility - Provide option to comment out pydantic-specific steps if not using v2 --- e2e/Fender.postman_collection.json | 165 +++++++++++++++++++++++++++++ scripts/deploy-python.sh | 5 + 2 files changed, 170 insertions(+) create mode 100644 e2e/Fender.postman_collection.json diff --git a/e2e/Fender.postman_collection.json b/e2e/Fender.postman_collection.json new file mode 100644 index 0000000..f1e87b7 --- /dev/null +++ b/e2e/Fender.postman_collection.json @@ -0,0 +1,165 @@ +{ + "info": { + "_postman_id": "6bd79d8a-2b00-4d2b-bd09-e46941f91906", + "name": "Fender", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "9398736" + }, + "item": [ + { + "name": "subscriptions", + "item": [ + { + "name": "userId", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/v1/subscriptions/123", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "subscriptions", "123"] + } + }, + "response": [] + } + ] + }, + { + "name": "webhooks", + "item": [ + { + "name": "New Request", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"content\": \"dummy\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/api/v1/webhooks/subscriptions", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "webhooks", "subscriptions"] + } + }, + "response": [] + } + ] + }, + { + "name": "E2E", + "item": [ + { + "name": "createNewSubscription", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"eventId\": \"evt_123456789\",\n \"eventType\": \"subscription.created\",\n \"timestamp\": \"2024-03-20T10:00:00Z\",\n \"provider\": \"STRIPE\",\n \"subscriptionId\": \"sub_456789\",\n \"paymentId\": \"pm_123456\",\n \"userId\": \"123\",\n \"customerId\": \"cus_789012\",\n \"expiresAt\": \"2024-04-20T10:00:00Z\",\n \"metadata\": {\n \"planSku\": \"PREMIUM_MONTHLY\",\n \"autoRenew\": true,\n \"paymentMethod\": \"CREDIT_CARD\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/api/v1/webhooks/subscriptions", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "webhooks", "subscriptions"] + } + }, + "response": [] + }, + { + "name": "getNewSubscription", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/v1/subscriptions/123", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "subscriptions", "123"] + } + }, + "response": [] + }, + { + "name": "renewSubscription", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"eventId\": \"evt_123456789\",\n \"eventType\": \"subscription.renewed\",\n \"timestamp\": \"2024-03-20T10:00:00Z\",\n \"provider\": \"STRIPE\",\n \"subscriptionId\": \"sub_456789\",\n \"paymentId\": \"pm_123456\",\n \"userId\": \"123\",\n \"customerId\": \"cus_789012\",\n \"expiresAt\": \"2024-04-20T10:00:00Z\",\n \"metadata\": {\n \"planSku\": \"PREMIUM_MONTHLY\",\n \"autoRenew\": true,\n \"paymentMethod\": \"CREDIT_CARD\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/api/v1/webhooks/subscriptions", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "webhooks", "subscriptions"] + } + }, + "response": [] + }, + { + "name": "getRenewSubscription", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/v1/subscriptions/123", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "subscriptions", "123"] + } + }, + "response": [] + }, + { + "name": "cancelSubscription", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"eventId\": \"evt_123456789\",\n \"eventType\": \"subscription.cancelled\",\n \"timestamp\": \"2024-03-20T10:00:00Z\",\n \"provider\": \"STRIPE\",\n \"subscriptionId\": \"sub_456789\",\n \"paymentId\": \"pm_123456\",\n \"userId\": \"123\",\n \"customerId\": \"cus_789012\",\n \"expiresAt\": \"2024-04-20T10:00:00Z\",\n \"metadata\": {\n \"planSku\": \"PREMIUM_MONTHLY\",\n \"autoRenew\": true,\n \"paymentMethod\": \"CREDIT_CARD\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/api/v1/webhooks/subscriptions", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "webhooks", "subscriptions"] + } + }, + "response": [] + }, + { + "name": "getCancelSubscription", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/v1/subscriptions/123", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "subscriptions", "123"] + } + }, + "response": [] + } + ] + } + ] +} diff --git a/scripts/deploy-python.sh b/scripts/deploy-python.sh index d5c4375..8c0c615 100755 --- a/scripts/deploy-python.sh +++ b/scripts/deploy-python.sh @@ -3,7 +3,12 @@ mkdir .temp mkdir .temp/package +# User `uv` for faster installs uv pip install -r app/python/requirements.txt --target .temp/package +#! NOTE: Since we're using pydantic v2, please watch step in how to deploy +#! pydantic with AWS Lambda: +#! https://docs.pydantic.dev/latest/integrations/aws_lambda/#installing-pydantic-for-aws-lambda-functions +#! Commente out if not using pydantic v2 pip install \ --platform manylinux2014_x86_64 \ --implementation cp \ From 33e468cefa96d07dc27bfcac2bedb966365f1eb7 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sun, 9 Nov 2025 09:50:49 -0600 Subject: [PATCH 37/46] refactor: improve subscription processing and error handling - Change parse_cancelledAt from property to method with error handling - Reorder subscription processing before plan processing - Add route documentation and use HTTPStatus enum for better readability - Update method calls to use new parse_cancelled_at() function signature --- app/python/src/models/models.py | 17 +++++++++-------- app/python/src/routes.py | 14 +++++++++++++- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index a82c446..6425e75 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -65,18 +65,18 @@ def create(self) -> None: def _current_datetime(self) -> datetime: return datetime.now(timezone.utc) - @property - def parse_cancelledAt(self) -> str | None: - if self.cancelledAt: - return parse_iso8601(self.cancelledAt) + def parse_cancelled_at(self) -> str: + if not self.cancelledAt: + raise ValueError("cancelledAt is None") + return parse_iso8601(self.cancelledAt) @property def is_pending(self) -> bool: - return self._current_datetime <= self.parse_cancelledAt + return self._current_datetime <= self.parse_cancelled_at() @property def is_cancelled(self) -> bool: - return self._current_datetime > self.parse_cancelledAt + return self._current_datetime > self.parse_cancelled_at() def compute_status(self) -> SubscriptionStatus: if not self.cancelledAt: @@ -224,11 +224,12 @@ def retrieve(self) -> dict: @validation_wrapper def process_subscription_and_plan(payload: SubscriptionEventPayload) -> None: + subscription_adapter = SubscriptionAdapter(payload=payload) + subscription_adapter.process() + plan_adapter = PlanAdapter(payload=payload) plan_adapter.process() - subscription_adapter = SubscriptionAdapter(payload=payload) - subscription_adapter.process() return success_response("Subscription and Plan processed successfully") diff --git a/app/python/src/routes.py b/app/python/src/routes.py index b4e4e6b..45fd790 100644 --- a/app/python/src/routes.py +++ b/app/python/src/routes.py @@ -1,3 +1,5 @@ +from http import HTTPStatus + from pydantic import BaseModel try: @@ -12,11 +14,19 @@ from utils.response import error_response +# /api/v1/subscriptions/{userId} def router_get_user_subscription(user_id: str) -> dict: + """ + Router function to handle GET /api/v1/subscriptions/{userId} requests. + """ return process_user_id(user_id=user_id) +# /api/v1/webhooks/subscriptions def router_post_user_subscription(body: SubscriptionEventPayload) -> dict: + """ + Router function to handle POST /api/v1/webhooks/subscriptions requests. + """ return process_subscription_and_plan(payload=body) @@ -34,4 +44,6 @@ def process_event(self) -> dict: return router_post_user_subscription(body=body) else: - return error_response("Unsupported HTTP method", status_code=405) + return error_response( + "Method not allowed", status_code=HTTPStatus.METHOD_NOT_ALLOWED + ) From 77f1491d4d504b0a52d865eb7a3a2bbf32156e3a Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sun, 9 Nov 2025 22:11:03 -0600 Subject: [PATCH 38/46] feat(subscription): add update functionality and refactor event processing - Add DynamoDB update method with field constants for pk/sk handling - Implement renewal and cancellation update logic in SubscriptionAdapter - Fix subscription status logic to use lastModified instead of current time - Remove redundant cancelledAt field from initial subscription creation - Rename retrieve methods to process for better semantic clarity --- app/python/src/db/dynamo.py | 34 +++++++++++++++++++ app/python/src/models/models.py | 54 ++++++++++++++++++++++++------- app/python/src/schemas/schemas.py | 1 + app/python/src/tests/main.py | 39 ++++++++++++++++++++-- 4 files changed, 114 insertions(+), 14 deletions(-) diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index fdea089..3139663 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -31,6 +31,10 @@ def dynamo_write_serializer(dict: dict) -> dict: return dict +PK_FIELD = "pk" +SK_FIELD = "sk" + + class DynamoFender: """ DynamoDB connection handler for Fender application. @@ -70,6 +74,36 @@ def write(self, data: list | dict) -> bool: return True + def _convert_updatable_dict(self, payload: dict) -> dict: + """ + Convert payload to corresponding format to update + """ + + final_dict = {} + + for key, value in payload.items(): + if key == PK_FIELD or key == SK_FIELD: + continue + + final_dict.update({key: {"Value": value}}) + + return final_dict + + def update(self, data: dict) -> None: + """ + Updates DB in dynamo + """ + + if PK_FIELD not in data.keys() or not (pk_value := data.get(PK_FIELD)): + raise ValueError(f"`pk` is mandatory") + + # Convert dict to value field + attributes = self._convert_updatable_dict(data) + + return self.table.update_item( + Key={PK_FIELD: pk_value}, AttributeUpdates=attributes + ) + def get_by_pk(self, pk: str) -> dict: response = self.table.query(KeyConditionExpression=Key("pk").eq(pk)) return serialize_dynamo(response["Items"]) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 6425e75..128562e 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -62,21 +62,21 @@ def create(self) -> None: DynamoFenderTables.SUBSCRIPTION.write(self.model_dump(exclude_none=True)) @property - def _current_datetime(self) -> datetime: - return datetime.now(timezone.utc) + def last_date_modified(self) -> datetime: + return parse_iso8601(self.lastModified) def parse_cancelled_at(self) -> str: if not self.cancelledAt: raise ValueError("cancelledAt is None") - return parse_iso8601(self.cancelledAt) + return parse_iso8601(self.expiresAt) @property def is_pending(self) -> bool: - return self._current_datetime <= self.parse_cancelled_at() + return self.last_date_modified <= self.parse_cancelled_at() @property def is_cancelled(self) -> bool: - return self._current_datetime > self.parse_cancelled_at() + return self.last_date_modified > self.parse_cancelled_at() def compute_status(self) -> SubscriptionStatus: if not self.cancelledAt: @@ -121,7 +121,6 @@ def _create(self) -> None: "planSku": self.payload.metadata.planSku, "startDate": self.payload.timestamp, "expiresAt": self.payload.expiresAt, - "cancelledAt": self.payload.cancelledAt, "lastModified": self.payload.timestamp, "attributes": { "provider": self.payload.provider, @@ -134,12 +133,43 @@ def _create(self) -> None: subscription_model = SubscriptionModel(**data) subscription_model.create() - def process(self) -> bool: + def _update_renewal(self) -> None: + """ + Update plan on renewal if needed. + """ + update_dict = { + "pk": self.payload.userId, + "lastModified": self.payload.timestamp, + "expiresAt": self.payload.expiresAt, + } + DynamoFenderTables.SUBSCRIPTION.update(update_dict) + return + + def _update_cancelled(self) -> None: + """ + Update plan on renewal if needed. + """ + update_dict = { + "pk": self.payload.userId, + "lastModified": self.payload.timestamp, + "expiresAt": self.payload.expiresAt, + "cancelledAt": self.payload.cancelledAt, + } + DynamoFenderTables.SUBSCRIPTION.update(update_dict) + + def process(self) -> None: + """ + Process subscription event payload. + """ if not self._get_by_pk(): - self._create() + return self._create() - def retrieve(self) -> dict: - data = self._get_by_pk() + if self.payload.is_renewal: + # Add logic to update plan if needed on renewal + return self._update_renewal() + elif self.payload.is_cancelled: + # Add logic to update plan if needed on cancellation + return self._update_cancelled() class PlanAdapter(BaseModel): @@ -196,7 +226,7 @@ def _get_plan_by_pk(self, plan_sku: str) -> PlanModel: return PlanModel(**plan[0]) - def retrieve(self) -> dict: + def process(self) -> dict: subscription = self._get_sub_by_pk() plan = self._get_plan_by_pk(subscription.planSku) @@ -239,5 +269,5 @@ def process_user_id(user_id: str) -> dict: Fetch user subscription by user ID. """ subscription_adapter = SubscriptionAndPlanAdapter(user_id=user_id) - data = subscription_adapter.retrieve() + data = subscription_adapter.process() return success_response("User subscription retrieved successfully", data=data) diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index f32bac4..c7def70 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -38,6 +38,7 @@ class MetadataSchema(BaseModel): planSku: str autoRenew: bool paymentMethod: str + cancelReason: Optional[str] = None class SubscriptionEventPayload(BaseModel): diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 89612e4..4b5a226 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -29,6 +29,41 @@ "paymentMethod": "CREDIT_CARD", }, } +RENEWED_SUBSCRIPTION_EVENT = { + "eventId": "evt_987654321", + "eventType": "subscription.renewed", + "timestamp": "2024-04-20T10:00:00Z", + "provider": "STRIPE", + "subscriptionId": "sub_456789", + "paymentId": "pm_654321", + "userId": "123", + "customerId": "cus_789012", + "expiresAt": "2024-05-20T10:00:00Z", + "metadata": { + "planSku": "PREMIUM_MONTHLY", + "autoRenew": True, + "paymentMethod": "CREDIT_CARD", + }, +} + +CANCELLED_SUBSCRIPTION_EVENT = { + "eventId": "evt_456789123", + "eventType": "subscription.cancelled", + "timestamp": "2024-05-20T10:00:00Z", + "provider": "STRIPE", + "subscriptionId": "sub_456789", + "paymentId": None, + "userId": "123", + "customerId": "cus_789012", + "expiresAt": "2024-05-20T10:00:00Z", + "cancelledAt": "2024-05-20T10:00:00Z", + "metadata": { + "planSku": "PREMIUM_MONTHLY", + "autoRenew": False, + "paymentMethod": "CREDIT_CARD", + "cancelReason": "USER_REQUESTED", + }, +} AWS_GET_EVENT_SUBSCRIPTION = { "resource": "/api/v1/subscriptions/{userId}", @@ -160,7 +195,7 @@ } -# @pytest.mark.skip(reason="Skipping this test for now") +@pytest.mark.skip(reason="Skipping this test for now") def test_handler_get_event(): event = AWS_GET_EVENT_SUBSCRIPTION context = {} @@ -169,7 +204,7 @@ def test_handler_get_event(): print(response) -@pytest.mark.skip(reason="Skipping this test for now") +# @pytest.mark.skip(reason="Skipping this test for now") def test_handler_post_event(): event = AWS_POST_EVENT_SUBSCRIPTION context = {} From 59905158eb5c64398cffa60dda2c7884faab5b1d Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sun, 9 Nov 2025 22:54:15 -0600 Subject: [PATCH 39/46] feat: add sort key support and status tracking to DynamoDB operations - Add SK_FIELD validation and inclusion in DynamoDB update operations - Include sort key in subscription update methods for proper composite key handling - Add internalStatus field tracking for ACTIVE/CANCELLED subscription states - Conditionally include cancelledAt field only when subscription is cancelled - Remove unused return statement and improve data structure handling --- app/python/src/db/dynamo.py | 13 ++- app/python/src/models/models.py | 17 +++- app/python/src/tests/main.py | 149 +++++++++++++++++--------------- 3 files changed, 104 insertions(+), 75 deletions(-) diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index 3139663..3647149 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -93,15 +93,24 @@ def update(self, data: dict) -> None: """ Updates DB in dynamo """ + pk_value = data.get(PK_FIELD) + sk_value = data.get(SK_FIELD) - if PK_FIELD not in data.keys() or not (pk_value := data.get(PK_FIELD)): + if PK_FIELD not in data.keys() or not pk_value: raise ValueError(f"`pk` is mandatory") + if SK_FIELD not in data.keys() or not sk_value: + raise ValueError(f"`sk` is mandatory") + # Convert dict to value field attributes = self._convert_updatable_dict(data) return self.table.update_item( - Key={PK_FIELD: pk_value}, AttributeUpdates=attributes + Key={ + PK_FIELD: pk_value, + SK_FIELD: sk_value, + }, + AttributeUpdates=attributes, ) def get_by_pk(self, pk: str) -> dict: diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 128562e..465b30b 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -130,6 +130,10 @@ def _create(self) -> None: "paymentMethod": self.payload.metadata.paymentMethod, }, } + + if self.payload.is_cancelled: + data["cancelledAt"] = self.payload.cancelledAt + subscription_model = SubscriptionModel(**data) subscription_model.create() @@ -139,11 +143,12 @@ def _update_renewal(self) -> None: """ update_dict = { "pk": self.payload.userId, + "sk": self.payload.subscriptionId, "lastModified": self.payload.timestamp, "expiresAt": self.payload.expiresAt, + "internalStatus": SubscriptionStatus.ACTIVE, } DynamoFenderTables.SUBSCRIPTION.update(update_dict) - return def _update_cancelled(self) -> None: """ @@ -151,9 +156,11 @@ def _update_cancelled(self) -> None: """ update_dict = { "pk": self.payload.userId, + "sk": self.payload.subscriptionId, "lastModified": self.payload.timestamp, "expiresAt": self.payload.expiresAt, "cancelledAt": self.payload.cancelledAt, + "internalStatus": SubscriptionStatus.CANCELLED, } DynamoFenderTables.SUBSCRIPTION.update(update_dict) @@ -230,7 +237,7 @@ def process(self) -> dict: subscription = self._get_sub_by_pk() plan = self._get_plan_by_pk(subscription.planSku) - return { + data = { "userId": subscription.pk, "subscriptionId": subscription.sk, "plan": { @@ -243,7 +250,6 @@ def process(self) -> dict: }, "startDate": subscription.startDate, "expiresAt": subscription.expiresAt, - "cancelledAt": subscription.cancelledAt, "status": subscription.compute_status(), "attributes": { "autoRenew": subscription.attributes.autoRenew, @@ -251,6 +257,11 @@ def process(self) -> dict: }, } + if subscription.cancelledAt: + data["cancelledAt"] = subscription.cancelledAt + + return data + @validation_wrapper def process_subscription_and_plan(payload: SubscriptionEventPayload) -> None: diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 4b5a226..0baacfb 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -65,6 +65,7 @@ }, } + AWS_GET_EVENT_SUBSCRIPTION = { "resource": "/api/v1/subscriptions/{userId}", "path": "/api/v1/subscriptions/123", @@ -129,84 +130,92 @@ "body": None, "isBase64Encoded": False, } -AWS_POST_EVENT_SUBSCRIPTION = { - "resource": "/api/v1/subscriptions/{userId}", - "path": "/api/v1/subscriptions/dummy_user", - "httpMethod": "POST", - "headers": { - "Accept": "*/*", - "Accept-Encoding": "gzip, deflate, br", - "Host": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", - "Postman-Token": "fa1c9874-89e9-4277-a5b1-e65017947519", - "User-Agent": "PostmanRuntime/7.49.1", - "X-Amzn-Trace-Id": "Root=1-690d790f-52e7f1510ea0af3242a5d1d6", - "X-Forwarded-For": "189.179.129.75", - "X-Forwarded-Port": "443", - "X-Forwarded-Proto": "https", - }, - "multiValueHeaders": { - "Accept": ["*/*"], - "Accept-Encoding": ["gzip, deflate, br"], - "Host": ["0s2r8aurv7.execute-api.us-east-1.amazonaws.com"], - "Postman-Token": ["fa1c9874-89e9-4277-a5b1-e65017947519"], - "User-Agent": ["PostmanRuntime/7.49.1"], - "X-Amzn-Trace-Id": ["Root=1-690d790f-52e7f1510ea0af3242a5d1d6"], - "X-Forwarded-For": ["189.179.129.75"], - "X-Forwarded-Port": ["443"], - "X-Forwarded-Proto": ["https"], - }, - "queryStringParameters": None, - "multiValueQueryStringParameters": None, - "pathParameters": None, - "stageVariables": None, - "requestContext": { - "resourceId": "xxxxyyy", - "resourcePath": "/api/v1/subscriptions/{userId}", - "httpMethod": "GET", - "extendedRequestId": "Tp_ajGsBoAMEdCw=", - "requestTime": "07/Nov/2025:04:43:59 +0000", - "path": "/dev/api/v1/subscriptions/dummy_user", - "accountId": "929676127859", - "protocol": "HTTP/1.1", - "stage": "dev", - "domainPrefix": "xxxxxyyy", - "requestTimeEpoch": 1762490639902, - "requestId": "fb9a86bd-d6cf-4812-b69d-21f3ce009155", - "identity": { - "cognitoIdentityPoolId": None, - "accountId": None, - "cognitoIdentityId": None, - "caller": None, - "sourceIp": "189.179.129.75", - "principalOrgId": None, - "accessKey": None, - "cognitoAuthenticationType": None, - "cognitoAuthenticationProvider": None, - "userArn": None, - "userAgent": "PostmanRuntime/7.49.1", - "user": None, + + +def base_aws_post_event(event_body: dict) -> dict: + return { + "resource": "/api/v1/subscriptions/{userId}", + "path": "/api/v1/subscriptions/dummy_user", + "httpMethod": "POST", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Host": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", + "Postman-Token": "fa1c9874-89e9-4277-a5b1-e65017947519", + "User-Agent": "PostmanRuntime/7.49.1", + "X-Amzn-Trace-Id": "Root=1-690d790f-52e7f1510ea0af3242a5d1d6", + "X-Forwarded-For": "189.179.129.75", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https", }, - "domainName": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", - "deploymentId": "w3dwr7", - "apiId": "0s2r8aurv7", - }, - "body": CREATED_SUBSCRIPTION_EVENT, - "isBase64Encoded": False, -} + "multiValueHeaders": { + "Accept": ["*/*"], + "Accept-Encoding": ["gzip, deflate, br"], + "Host": ["0s2r8aurv7.execute-api.us-east-1.amazonaws.com"], + "Postman-Token": ["fa1c9874-89e9-4277-a5b1-e65017947519"], + "User-Agent": ["PostmanRuntime/7.49.1"], + "X-Amzn-Trace-Id": ["Root=1-690d790f-52e7f1510ea0af3242a5d1d6"], + "X-Forwarded-For": ["189.179.129.75"], + "X-Forwarded-Port": ["443"], + "X-Forwarded-Proto": ["https"], + }, + "queryStringParameters": None, + "multiValueQueryStringParameters": None, + "pathParameters": None, + "stageVariables": None, + "requestContext": { + "resourceId": "xxxxyyy", + "resourcePath": "/api/v1/subscriptions/{userId}", + "httpMethod": "GET", + "extendedRequestId": "Tp_ajGsBoAMEdCw=", + "requestTime": "07/Nov/2025:04:43:59 +0000", + "path": "/dev/api/v1/subscriptions/dummy_user", + "accountId": "929676127859", + "protocol": "HTTP/1.1", + "stage": "dev", + "domainPrefix": "xxxxxyyy", + "requestTimeEpoch": 1762490639902, + "requestId": "fb9a86bd-d6cf-4812-b69d-21f3ce009155", + "identity": { + "cognitoIdentityPoolId": None, + "accountId": None, + "cognitoIdentityId": None, + "caller": None, + "sourceIp": "189.179.129.75", + "principalOrgId": None, + "accessKey": None, + "cognitoAuthenticationType": None, + "cognitoAuthenticationProvider": None, + "userArn": None, + "userAgent": "PostmanRuntime/7.49.1", + "user": None, + }, + "domainName": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", + "deploymentId": "w3dwr7", + "apiId": "0s2r8aurv7", + }, + "body": event_body, + "isBase64Encoded": False, + } -@pytest.mark.skip(reason="Skipping this test for now") -def test_handler_get_event(): - event = AWS_GET_EVENT_SUBSCRIPTION +AWS_POST_EVENT_CREATE_SUBSCRIPTION = base_aws_post_event(CREATED_SUBSCRIPTION_EVENT) +AWS_POST_EVENT_RENEW_SUBSCRIPTION = base_aws_post_event(RENEWED_SUBSCRIPTION_EVENT) +AWS_POST_EVENT_CANCEL_SUBSCRIPTION = base_aws_post_event(CANCELLED_SUBSCRIPTION_EVENT) + + +# @pytest.mark.skip(reason="Skipping this test for now") +def test_handler_post_event(): + event = AWS_POST_EVENT_CREATE_SUBSCRIPTION context = {} response = handler(event, context) print(response) -# @pytest.mark.skip(reason="Skipping this test for now") -def test_handler_post_event(): - event = AWS_POST_EVENT_SUBSCRIPTION +@pytest.mark.skip(reason="Skipping this test for now") +def test_handler_get_event(): + event = AWS_GET_EVENT_SUBSCRIPTION context = {} response = handler(event, context) @@ -219,7 +228,7 @@ def test_event_schema_get_method(): assert event.is_get is True assert event.is_post is False - event = EventSchema(**AWS_POST_EVENT_SUBSCRIPTION) + event = EventSchema(**AWS_POST_EVENT_CREATE_SUBSCRIPTION) assert event.is_get is False assert event.is_post is True From 52ee440847c44dd301ab4dc5656ab5ff62441d21 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Sun, 9 Nov 2025 23:39:21 -0600 Subject: [PATCH 40/46] fix: correct subscription status logic and improve error handling - Fix is_pending/is_cancelled comparison operators to use strict inequalities - Add validation_wrapper decorator to process_event method - Make paymentId optional in SubscriptionEventPayload schema - Expand test coverage to include GET, CREATE, RENEW, and CANCEL subscription flows --- app/python/src/models/models.py | 4 ++-- app/python/src/routes.py | 5 +++-- app/python/src/schemas/schemas.py | 2 +- app/python/src/tests/main.py | 19 +++++++++++++++---- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 465b30b..66d05f5 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -72,11 +72,11 @@ def parse_cancelled_at(self) -> str: @property def is_pending(self) -> bool: - return self.last_date_modified <= self.parse_cancelled_at() + return self.last_date_modified < self.parse_cancelled_at() @property def is_cancelled(self) -> bool: - return self.last_date_modified > self.parse_cancelled_at() + return self.last_date_modified >= self.parse_cancelled_at() def compute_status(self) -> SubscriptionStatus: if not self.cancelledAt: diff --git a/app/python/src/routes.py b/app/python/src/routes.py index 45fd790..fd9704b 100644 --- a/app/python/src/routes.py +++ b/app/python/src/routes.py @@ -6,12 +6,12 @@ # For local development from .models.models import process_subscription_and_plan, process_user_id from .schemas.schemas import EventSchema, SubscriptionEventPayload - from .utils.response import error_response + from .utils.response import error_response, validation_wrapper except: # For AWS Lambda deployment from models.models import process_subscription_and_plan, process_user_id from schemas.schemas import EventSchema, SubscriptionEventPayload - from utils.response import error_response + from utils.response import error_response, validation_wrapper # /api/v1/subscriptions/{userId} @@ -33,6 +33,7 @@ def router_post_user_subscription(body: SubscriptionEventPayload) -> dict: class Router(BaseModel): event: EventSchema + @validation_wrapper def process_event(self) -> dict: if self.event.is_get: diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index c7def70..a470265 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -47,7 +47,7 @@ class SubscriptionEventPayload(BaseModel): timestamp: str provider: str subscriptionId: str - paymentId: str + paymentId: Optional[str] = None userId: str customerId: str expiresAt: str diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 0baacfb..3c00164 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -205,12 +205,23 @@ def base_aws_post_event(event_body: dict) -> dict: # @pytest.mark.skip(reason="Skipping this test for now") -def test_handler_post_event(): - event = AWS_POST_EVENT_CREATE_SUBSCRIPTION +def test_handler_event(): + event_get = AWS_GET_EVENT_SUBSCRIPTION + event_created = AWS_POST_EVENT_CREATE_SUBSCRIPTION context = {} - response = handler(event, context) - print(response) + response_created = handler(event_created, context) + response_get = handler(event_get, context) + + event_renewed = AWS_POST_EVENT_RENEW_SUBSCRIPTION + response_renewed = handler(event_renewed, context) + response_get = handler(event_get, context) + + event_cancelled = AWS_POST_EVENT_CANCEL_SUBSCRIPTION + response_cancelled = handler(event_cancelled, context) + response_get = handler(event_get, context) + + print(response_get) @pytest.mark.skip(reason="Skipping this test for now") From 781cb45017f311c8fad0e4c5e055694e5ae002f6 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Mon, 10 Nov 2025 22:43:17 -0600 Subject: [PATCH 41/46] fix: change body from dict to string and add parse_body method - Update EventSchema.body type from dict to str to match AWS Lambda event format - Add parse_body() method to EventSchema for JSON deserialization - Update router to use parse_body() instead of direct body access - Update test fixture to serialize body as JSON string --- app/python/src/routes.py | 2 +- app/python/src/schemas/schemas.py | 8 +++++++- app/python/src/tests/main.py | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/python/src/routes.py b/app/python/src/routes.py index fd9704b..1521100 100644 --- a/app/python/src/routes.py +++ b/app/python/src/routes.py @@ -41,7 +41,7 @@ def process_event(self) -> dict: return router_get_user_subscription(user_id=user_id) elif self.event.is_post: - body = SubscriptionEventPayload(**self.event.body) + body = SubscriptionEventPayload(**self.event.parse_body()) return router_post_user_subscription(body=body) else: diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index a470265..e7441e2 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -1,3 +1,4 @@ +import json from enum import StrEnum from typing import Optional @@ -22,7 +23,7 @@ class UserParamsSchema(BaseModel): class EventSchema(BaseModel): httpMethod: str path: str - body: Optional[dict] = None + body: Optional[str] = None pathParameters: Optional[UserParamsSchema] = None @property @@ -33,6 +34,11 @@ def is_get(self) -> bool: def is_post(self) -> bool: return self.httpMethod == SupportedMethods.POST + def parse_body(self) -> dict: + if self.body: + return json.loads(self.body) + return {} + class MetadataSchema(BaseModel): planSku: str diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 3c00164..a22c51e 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -1,3 +1,5 @@ +import json + import pytest try: @@ -194,7 +196,7 @@ def base_aws_post_event(event_body: dict) -> dict: "deploymentId": "w3dwr7", "apiId": "0s2r8aurv7", }, - "body": event_body, + "body": json.dumps(event_body), "isBase64Encoded": False, } From 520526930ee21e1c44b6895e379f08518e281d14 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Mon, 10 Nov 2025 22:58:23 -0600 Subject: [PATCH 42/46] feat(db): add SubscriptionsAndPlansTable for combined data access Add new DynamoDB table handler class to support querying subscriptions and plans together, improving data access patterns for the fender digital code exercise use case. --- app/python/src/db/dynamo.py | 12 ++++++++++++ app/python/src/db/tables.py | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py index 3647149..c28b53e 100644 --- a/app/python/src/db/dynamo.py +++ b/app/python/src/db/dynamo.py @@ -149,3 +149,15 @@ class PlanTable(DynamoFender): def __init__(self, tablename: str = None) -> None: _tablename = tablename or self.__tablename__ super().__init__(_tablename) + + +class SubscriptionsAndPlansTable(DynamoFender): + """ + DynamoDB handler for Plan table. + """ + + __tablename__ = "fender_digital_code_exercise" + + def __init__(self, tablename: str = None) -> None: + _tablename = tablename or self.__tablename__ + super().__init__(_tablename) diff --git a/app/python/src/db/tables.py b/app/python/src/db/tables.py index 0b7a237..3c5052d 100644 --- a/app/python/src/db/tables.py +++ b/app/python/src/db/tables.py @@ -1,11 +1,12 @@ try: # For local development - from .dynamo import PlanTable, SubscriptionTable + from .dynamo import PlanTable, SubscriptionsAndPlansTable, SubscriptionTable except: # For AWS Lambda deployment - from dynamo import PlanTable, SubscriptionTable + from dynamo import PlanTable, SubscriptionsAndPlansTable, SubscriptionTable class DynamoFenderTables: SUBSCRIPTION = SubscriptionTable() PLAN = PlanTable() + SUBSCRIPTIONS_AND_PLANS = SubscriptionsAndPlansTable() From 5fece2196fd934f3e714a412c5611542ed88a0a4 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Mon, 10 Nov 2025 23:18:22 -0600 Subject: [PATCH 43/46] refactor(models): update SubscriptionAdapter to use new table and key structure - Change table reference from SUBSCRIPTION to SUBSCRIPTIONS_AND_PLANS - Make _get_by_pk() method public and use payload.pk instead of userId - Update _create() method to use payload.pk and payload.sk properties - Add pk and sk properties to SubscriptionEventPayload schema - Add new SubscriptionAndPlanAdapterPost model class --- app/python/src/models/models.py | 14 +++++--- app/python/src/schemas/schemas.py | 8 +++++ e2e/plansSample.json | 58 +++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 e2e/plansSample.json diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 66d05f5..df47396 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -106,8 +106,8 @@ def create(self) -> None: class SubscriptionAdapter(BaseModel): payload: SubscriptionEventPayload - def _get_by_pk(self) -> dict: - return DynamoFenderTables.SUBSCRIPTION.get_by_pk(self.payload.userId) + def get_by_pk(self) -> dict: + return DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.get_by_pk(self.payload.pk) def _create(self) -> None: """ @@ -115,8 +115,8 @@ def _create(self) -> None: """ DEFAULT_TYPE = "sub" data = { - "pk": self.payload.userId, - "sk": self.payload.subscriptionId, + "pk": self.payload.pk, + "sk": self.payload.sk, "type": DEFAULT_TYPE, "planSku": self.payload.metadata.planSku, "startDate": self.payload.timestamp, @@ -168,7 +168,7 @@ def process(self) -> None: """ Process subscription event payload. """ - if not self._get_by_pk(): + if not self.get_by_pk(): return self._create() if self.payload.is_renewal: @@ -216,6 +216,10 @@ def process(self) -> None: return self._create() +class SubscriptionAndPlanAdapterPost(BaseModel): + payload: SubscriptionEventPayload + + class SubscriptionAndPlanAdapter(BaseModel): user_id: str diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index e7441e2..d754b41 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -60,6 +60,14 @@ class SubscriptionEventPayload(BaseModel): cancelledAt: Optional[str] = None metadata: MetadataSchema + @property + def pk(self) -> str: + return f"user:{self.userId}" + + @property + def sk(self) -> str: + return f"sub:{self.subscriptionId}" + @property def plan_name(self) -> str: return self.metadata.planSku.replace("_", " ").title() diff --git a/e2e/plansSample.json b/e2e/plansSample.json new file mode 100644 index 0000000..2d4039a --- /dev/null +++ b/e2e/plansSample.json @@ -0,0 +1,58 @@ +[ + { + "pk": "plan:XYZ123", + "sk": "metadata", + "type": "plan", + "name": "Basic Monthly Plan", + "price": 9.99, + "currency": "USD", + "billingCycle": "monthly", + "features": ["Access to basic features", "Email support"], + "status": "active", + "lastDateModified": "2024-06-10T12:00:00Z" + }, + { + "pk": "plan:ABC456", + "sk": "metadata", + "type": "plan", + "name": "Pro Yearly Plan", + "price": 99.99, + "currency": "USD", + "billingCycle": "yearly", + "features": [ + "Access to all features", + "Priority support", + "Monthly webinars" + ], + "status": "inactive", + "lastDateModified": "2024-05-15T08:30:00Z" + }, + { + "pk": "plan:DEF789", + "sk": "metadata", + "type": "plan", + "name": "Enterprise Plan", + "price": 499.99, + "currency": "USD", + "billingCycle": "yearly", + "features": [ + "Custom integrations", + "Dedicated account manager", + "24/7 support" + ], + "status": "active", + "lastDateModified": "2024-06-01T14:45:00Z" + }, + { + "pk": "plan:GHI012", + "sk": "metadata", + "type": "plan", + "name": "Starter Plan", + "price": 4.99, + "currency": "USD", + "billingCycle": "monthly", + "features": ["Limited feature access", "Community support"], + "status": "active", + "lastDateModified": "2024-06-12T09:15:00Z" + } +] From ddb40411488923789844f90cceebf867c0e673b4 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Mon, 10 Nov 2025 23:58:57 -0600 Subject: [PATCH 44/46] refactor: update subscription and plan models with unified table structure - Remove unused timezone import from datetime - Add plan_pk property to SubscriptionModel for consistent key generation - Update table references from separate SUBSCRIPTION/PLAN to unified SUBSCRIPTIONS_AND_PLANS - Add is_active/is_inactive properties to PlanModel for status validation - Refactor SubscriptionAdapter methods to use sub_pk/plan_pk naming convention - Add plan validation in subscription processing to prevent inactive plan usage - Update key generation patterns across adapters for consistency --- app/python/src/models/models.py | 71 +++++++++++++++++++++---------- app/python/src/schemas/schemas.py | 8 +++- app/python/src/tests/main.py | 33 +++++++++++--- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index df47396..e636594 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -1,5 +1,5 @@ import random -from datetime import datetime, timezone +from datetime import datetime from enum import StrEnum from typing import Literal, Optional @@ -58,8 +58,14 @@ class SubscriptionModel(BaseModel): lastModified: str attributes: SubscriptionAttributes | None = None + @property + def plan_pk(self) -> str: + return f"plan:{self.planSku}" + def create(self) -> None: - DynamoFenderTables.SUBSCRIPTION.write(self.model_dump(exclude_none=True)) + DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.write( + self.model_dump(exclude_none=True) + ) @property def last_date_modified(self) -> datetime: @@ -102,21 +108,35 @@ class PlanModel(BaseModel): def create(self) -> None: DynamoFenderTables.PLAN.write(self.model_dump()) + @property + def is_active(self) -> bool: + return self.status == PlanStatus.ACTIVE + + @property + def is_inactive(self) -> bool: + return self.status == PlanStatus.INACTIVE + class SubscriptionAdapter(BaseModel): payload: SubscriptionEventPayload - def get_by_pk(self) -> dict: - return DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.get_by_pk(self.payload.pk) + def get_sub_by_pk(self) -> dict: + return DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.get_by_pk(self.payload.sub_pk) - def _create(self) -> None: + def get_plan_by_pk(self) -> PlanModel | None: + if plan := DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.get_by_pk( + self.payload.plan_pk + ): + return PlanModel(**plan[0]) + + def create_sub(self) -> None: """ Process subscription event payload and create subscription record. """ DEFAULT_TYPE = "sub" data = { - "pk": self.payload.pk, - "sk": self.payload.sk, + "pk": self.payload.sub_pk, + "sk": self.payload.sub_sk, "type": DEFAULT_TYPE, "planSku": self.payload.metadata.planSku, "startDate": self.payload.timestamp, @@ -142,34 +162,37 @@ def _update_renewal(self) -> None: Update plan on renewal if needed. """ update_dict = { - "pk": self.payload.userId, - "sk": self.payload.subscriptionId, + "pk": self.payload.sub_pk, + "sk": self.payload.sub_sk, "lastModified": self.payload.timestamp, "expiresAt": self.payload.expiresAt, "internalStatus": SubscriptionStatus.ACTIVE, } - DynamoFenderTables.SUBSCRIPTION.update(update_dict) + DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.update(update_dict) def _update_cancelled(self) -> None: """ Update plan on renewal if needed. """ update_dict = { - "pk": self.payload.userId, - "sk": self.payload.subscriptionId, + "pk": self.payload.sub_pk, + "sk": self.payload.sub_sk, "lastModified": self.payload.timestamp, "expiresAt": self.payload.expiresAt, "cancelledAt": self.payload.cancelledAt, "internalStatus": SubscriptionStatus.CANCELLED, } - DynamoFenderTables.SUBSCRIPTION.update(update_dict) + DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.update(update_dict) def process(self) -> None: """ Process subscription event payload. """ - if not self.get_by_pk(): - return self._create() + if not (plan := self.get_plan_by_pk()) or plan.is_inactive: + raise ValueError("Plan is inactive or does not exist") + + if not self.get_sub_by_pk(): + return self.create_sub() if self.payload.is_renewal: # Add logic to update plan if needed on renewal @@ -216,16 +239,18 @@ def process(self) -> None: return self._create() -class SubscriptionAndPlanAdapterPost(BaseModel): - payload: SubscriptionEventPayload - - class SubscriptionAndPlanAdapter(BaseModel): user_id: str + @property + def sub_pk(self) -> str: + return f"user:{self.user_id}" + def _get_sub_by_pk(self) -> SubscriptionModel: if not ( - subscription := DynamoFenderTables.SUBSCRIPTION.get_by_pk(self.user_id) + subscription := DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.get_by_pk( + self.sub_pk + ) ): raise ValueError("Subscription not found") @@ -239,7 +264,7 @@ def _get_plan_by_pk(self, plan_sku: str) -> PlanModel: def process(self) -> dict: subscription = self._get_sub_by_pk() - plan = self._get_plan_by_pk(subscription.planSku) + plan = self._get_plan_by_pk(subscription.plan_pk) data = { "userId": subscription.pk, @@ -272,8 +297,8 @@ def process_subscription_and_plan(payload: SubscriptionEventPayload) -> None: subscription_adapter = SubscriptionAdapter(payload=payload) subscription_adapter.process() - plan_adapter = PlanAdapter(payload=payload) - plan_adapter.process() + # plan_adapter = PlanAdapter(payload=payload) + # plan_adapter.process() return success_response("Subscription and Plan processed successfully") diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index d754b41..52d8e3c 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -61,13 +61,17 @@ class SubscriptionEventPayload(BaseModel): metadata: MetadataSchema @property - def pk(self) -> str: + def sub_pk(self) -> str: return f"user:{self.userId}" @property - def sk(self) -> str: + def sub_sk(self) -> str: return f"sub:{self.subscriptionId}" + @property + def plan_pk(self) -> str: + return f"plan:{self.metadata.planSku}" + @property def plan_name(self) -> str: return self.metadata.planSku.replace("_", " ").title() diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index a22c51e..3c86382 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -26,7 +26,23 @@ "customerId": "cus_789012", "expiresAt": "2024-04-20T10:00:00Z", "metadata": { - "planSku": "PREMIUM_MONTHLY", + "planSku": "plan:XYZ123", + "autoRenew": True, + "paymentMethod": "CREDIT_CARD", + }, +} +CREATED_SUBSCRIPTION_EVENT_INACTIVE_PLAN = { + "eventId": "evt_123456789", + "eventType": "subscription.created", + "timestamp": "2024-03-20T10:00:00Z", + "provider": "STRIPE", + "subscriptionId": "sub_456789", + "paymentId": "pm_123456", + "userId": "123", + "customerId": "cus_789012", + "expiresAt": "2024-04-20T10:00:00Z", + "metadata": { + "planSku": "plan:ABC456", "autoRenew": True, "paymentMethod": "CREDIT_CARD", }, @@ -42,7 +58,7 @@ "customerId": "cus_789012", "expiresAt": "2024-05-20T10:00:00Z", "metadata": { - "planSku": "PREMIUM_MONTHLY", + "planSku": "plan:DEF789", "autoRenew": True, "paymentMethod": "CREDIT_CARD", }, @@ -60,7 +76,7 @@ "expiresAt": "2024-05-20T10:00:00Z", "cancelledAt": "2024-05-20T10:00:00Z", "metadata": { - "planSku": "PREMIUM_MONTHLY", + "planSku": "plan:GHI012", "autoRenew": False, "paymentMethod": "CREDIT_CARD", "cancelReason": "USER_REQUESTED", @@ -202,19 +218,26 @@ def base_aws_post_event(event_body: dict) -> dict: AWS_POST_EVENT_CREATE_SUBSCRIPTION = base_aws_post_event(CREATED_SUBSCRIPTION_EVENT) +AWS_POST_EVENT_CREATE_SUBSCRIPTION_INACTIVE_PLAN = base_aws_post_event( + CREATED_SUBSCRIPTION_EVENT_INACTIVE_PLAN +) AWS_POST_EVENT_RENEW_SUBSCRIPTION = base_aws_post_event(RENEWED_SUBSCRIPTION_EVENT) AWS_POST_EVENT_CANCEL_SUBSCRIPTION = base_aws_post_event(CANCELLED_SUBSCRIPTION_EVENT) # @pytest.mark.skip(reason="Skipping this test for now") def test_handler_event(): - event_get = AWS_GET_EVENT_SUBSCRIPTION - event_created = AWS_POST_EVENT_CREATE_SUBSCRIPTION context = {} + event_get = AWS_GET_EVENT_SUBSCRIPTION + event_created = AWS_POST_EVENT_CREATE_SUBSCRIPTION response_created = handler(event_created, context) response_get = handler(event_get, context) + event_created = AWS_POST_EVENT_CREATE_SUBSCRIPTION_INACTIVE_PLAN + response_renewed = handler(event_created, context) + response_get = handler(event_get, context) + event_renewed = AWS_POST_EVENT_RENEW_SUBSCRIPTION response_renewed = handler(event_renewed, context) response_get = handler(event_get, context) From 957f8418d8e31f9ae942eb4db333d440173d3d78 Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Tue, 11 Nov 2025 00:16:41 -0600 Subject: [PATCH 45/46] feat: refactor plan primary key format and table references Remove "plan:" prefix from plan_pk properties to simplify key format. Update _get_plan_by_pk method to use SUBSCRIPTIONS_AND_PLANS table instead of PLAN table and rename parameter for clarity. Clean up commented code and add minor formatting improvements. --- app/python/src/models/models.py | 10 ++++------ app/python/src/schemas/schemas.py | 2 +- app/python/src/tests/main.py | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index e636594..0af53d4 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -60,7 +60,7 @@ class SubscriptionModel(BaseModel): @property def plan_pk(self) -> str: - return f"plan:{self.planSku}" + return f"{self.planSku}" def create(self) -> None: DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.write( @@ -256,8 +256,8 @@ def _get_sub_by_pk(self) -> SubscriptionModel: return SubscriptionModel(**subscription[0]) - def _get_plan_by_pk(self, plan_sku: str) -> PlanModel: - if not (plan := DynamoFenderTables.PLAN.get_by_pk(plan_sku)): + def _get_plan_by_pk(self, plan_pk: str) -> PlanModel: + if not (plan := DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.get_by_pk(plan_pk)): raise ValueError("Plan not found") return PlanModel(**plan[0]) @@ -297,9 +297,6 @@ def process_subscription_and_plan(payload: SubscriptionEventPayload) -> None: subscription_adapter = SubscriptionAdapter(payload=payload) subscription_adapter.process() - # plan_adapter = PlanAdapter(payload=payload) - # plan_adapter.process() - return success_response("Subscription and Plan processed successfully") @@ -310,4 +307,5 @@ def process_user_id(user_id: str) -> dict: """ subscription_adapter = SubscriptionAndPlanAdapter(user_id=user_id) data = subscription_adapter.process() + return success_response("User subscription retrieved successfully", data=data) diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py index 52d8e3c..2925664 100644 --- a/app/python/src/schemas/schemas.py +++ b/app/python/src/schemas/schemas.py @@ -70,7 +70,7 @@ def sub_sk(self) -> str: @property def plan_pk(self) -> str: - return f"plan:{self.metadata.planSku}" + return f"{self.metadata.planSku}" @property def plan_name(self) -> str: diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 3c86382..80aaf51 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -231,7 +231,7 @@ def test_handler_event(): event_get = AWS_GET_EVENT_SUBSCRIPTION event_created = AWS_POST_EVENT_CREATE_SUBSCRIPTION - response_created = handler(event_created, context) + # response_created = handler(event_created, context) response_get = handler(event_get, context) event_created = AWS_POST_EVENT_CREATE_SUBSCRIPTION_INACTIVE_PLAN From ab695a6bb39edd2e16fb052163c3a94e9b2d9b3e Mon Sep 17 00:00:00 2001 From: Victor Cortes Figueroa Date: Tue, 11 Nov 2025 00:39:31 -0600 Subject: [PATCH 46/46] feat: extract subscription data transformation into separate schema class Refactored SubscriptionAdapter by extracting subscription data transformation logic into a dedicated SubscriptionDetailsSchema class. This improves code organization, reusability, and maintainability while keeping the same functionality. Also uncommented a test assertion. --- app/python/src/models/models.py | 50 ++++++++++++++++++++------------- app/python/src/tests/main.py | 2 +- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py index 0af53d4..c56a3c3 100644 --- a/app/python/src/models/models.py +++ b/app/python/src/models/models.py @@ -117,6 +117,35 @@ def is_inactive(self) -> bool: return self.status == PlanStatus.INACTIVE +class SubscriptionDetailsSchema: + """ + Schema to transform SubscriptionEventPayload into SubscriptionModel data. + """ + + DEFAULT_TYPE = "sub" + + def __init__(self, payload: SubscriptionEventPayload) -> None: + self.pk = payload.sub_pk + self.sk = payload.sub_sk + self.type = self.DEFAULT_TYPE + self.planSku = payload.metadata.planSku + self.startDate = payload.timestamp + self.expiresAt = payload.expiresAt + self.lastModified = payload.timestamp + self.attributes = { + "provider": payload.provider, + "paymentId": payload.paymentId, + "customerId": payload.customerId, + "autoRenew": payload.metadata.autoRenew, + "paymentMethod": payload.metadata.paymentMethod, + } + if payload.is_cancelled: + self.cancelledAt = payload.cancelledAt + + def to_dict(self) -> dict: + return self.__dict__ + + class SubscriptionAdapter(BaseModel): payload: SubscriptionEventPayload @@ -133,26 +162,7 @@ def create_sub(self) -> None: """ Process subscription event payload and create subscription record. """ - DEFAULT_TYPE = "sub" - data = { - "pk": self.payload.sub_pk, - "sk": self.payload.sub_sk, - "type": DEFAULT_TYPE, - "planSku": self.payload.metadata.planSku, - "startDate": self.payload.timestamp, - "expiresAt": self.payload.expiresAt, - "lastModified": self.payload.timestamp, - "attributes": { - "provider": self.payload.provider, - "paymentId": self.payload.paymentId, - "customerId": self.payload.customerId, - "autoRenew": self.payload.metadata.autoRenew, - "paymentMethod": self.payload.metadata.paymentMethod, - }, - } - - if self.payload.is_cancelled: - data["cancelledAt"] = self.payload.cancelledAt + data = SubscriptionDetailsSchema(self.payload).to_dict() subscription_model = SubscriptionModel(**data) subscription_model.create() diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py index 80aaf51..3c86382 100644 --- a/app/python/src/tests/main.py +++ b/app/python/src/tests/main.py @@ -231,7 +231,7 @@ def test_handler_event(): event_get = AWS_GET_EVENT_SUBSCRIPTION event_created = AWS_POST_EVENT_CREATE_SUBSCRIPTION - # response_created = handler(event_created, context) + response_created = handler(event_created, context) response_get = handler(event_get, context) event_created = AWS_POST_EVENT_CREATE_SUBSCRIPTION_INACTIVE_PLAN