diff --git a/apprunner.yaml b/apprunner.yaml index 0fd0c55..99c12f3 100644 --- a/apprunner.yaml +++ b/apprunner.yaml @@ -14,6 +14,8 @@ build: run: command: venv/bin/python3 linguaphoto/main.py # Adjust to the path to your application entry point env: + - name: HOMEPAGE_URL + value: "linguaphoto.com" - name: DYNAMODB_TABLE_NAME value: "linguaphoto" - name: S3_BUCKET_NAME diff --git a/debug.py b/debug.py index c404722..0b59d80 100644 --- a/debug.py +++ b/debug.py @@ -1,4 +1,5 @@ """it is entity for debugging""" + import uvicorn from linguaphoto.main import app diff --git a/frontend/src/components/Audio.tsx b/frontend/src/components/Audio.tsx index d48e484..5dea933 100644 --- a/frontend/src/components/Audio.tsx +++ b/frontend/src/components/Audio.tsx @@ -93,6 +93,7 @@ const AudioPlayer: React.FC = ({ currentImage, index }) => { useEffect(() => { if (audioRef.current) { audioRef.current.load(); + audioRef.current.playbackRate = playbackRate; } }, [currentImage, index]); diff --git a/linguaphoto/crud/base.py b/linguaphoto/crud/base.py index 4695614..c3cbdf0 100644 --- a/linguaphoto/crud/base.py +++ b/linguaphoto/crud/base.py @@ -1,8 +1,9 @@ """Defines the base CRUD interface.""" +import itertools import logging from types import TracebackType -from typing import Any, AsyncContextManager, BinaryIO, Self, TypeVar +from typing import Any, AsyncContextManager, BinaryIO, Literal, Self, TypeVar import aioboto3 from boto3.dynamodb.conditions import ComparisonCondition, Key @@ -13,6 +14,7 @@ from linguaphoto.errors import InternalError, ItemNotFoundError from linguaphoto.models import BaseModel, LinguaBaseModel from linguaphoto.settings import settings +from linguaphoto.utils.utils import get_cors_origins T = TypeVar("T", bound=BaseModel) @@ -21,6 +23,9 @@ DEFAULT_SCAN_LIMIT = 1000 ITEMS_PER_PAGE = 12 +TableKey = tuple[str, Literal["S", "N", "B"], Literal["HASH", "RANGE"]] +GlobalSecondaryIndex = tuple[str, str, Literal["S", "N", "B"], Literal["HASH", "RANGE"]] + logger = logging.getLogger(__name__) @@ -45,6 +50,10 @@ def s3(self) -> S3ServiceResource: def get_gsi_index_name(cls, colname: str) -> str: return f"{colname}-index" + @classmethod + def get_gsis(cls) -> set[str]: + return {"type"} + async def __aenter__(self) -> Self: session = aioboto3.Session() db = await session.resource( @@ -174,6 +183,66 @@ async def _delete_item(self, item: LinguaBaseModel | str) -> None: table = await self.db.Table(TABLE_NAME) await table.delete_item(Key={"id": item if isinstance(item, str) else item.id}) + async def _create_dynamodb_table( + self, + name: str, + keys: list[TableKey], + gsis: list[GlobalSecondaryIndex] | None = None, + deletion_protection: bool = False, + ) -> None: + """Creates a table in the Dynamo database if a table of that name does not already exist. + + Args: + name: Name of the table. + keys: Primary and secondary keys. Do not include non-key attributes. + gsis: Making an attribute a GSI is required in order to query + against it. Note HASH on a GSI does not actually enforce + uniqueness. Instead, the difference is: you cannot query + RANGE fields alone, but you may query HASH fields. + deletion_protection: Whether the table is protected from being + deleted. + """ + try: + await self.db.meta.client.describe_table(TableName=name) + logger.info("Found existing table %s", name) + except ClientError: + logger.info("Creating %s table", name) + + if gsis: + table = await self.db.create_table( + AttributeDefinitions=[ + {"AttributeName": n, "AttributeType": t} + for n, t in itertools.chain(((n, t) for (n, t, _) in keys), ((n, t) for _, n, t, _ in gsis)) + ], + TableName=name, + KeySchema=[{"AttributeName": n, "KeyType": t} for n, _, t in keys], + GlobalSecondaryIndexes=( + [ + { + "IndexName": i, + "KeySchema": [{"AttributeName": n, "KeyType": t}], + "Projection": {"ProjectionType": "ALL"}, + } + for i, n, _, t in gsis + ] + ), + DeletionProtectionEnabled=deletion_protection, + BillingMode="PAY_PER_REQUEST", + ) + + else: + table = await self.db.create_table( + AttributeDefinitions=[ + {"AttributeName": n, "AttributeType": t} for n, t in ((n, t) for (n, t, _) in keys) + ], + TableName=name, + KeySchema=[{"AttributeName": n, "KeyType": t} for n, _, t in keys], + DeletionProtectionEnabled=deletion_protection, + BillingMode="PAY_PER_REQUEST", + ) + + await table.wait_until_exists() + async def _list_items( self, item_class: type[T], @@ -206,3 +275,48 @@ async def _list_items( async def _upload_to_s3(self, file: BinaryIO, unique_filename: str) -> None: bucket = await self.s3.Bucket(settings.bucket_name) await bucket.upload_fileobj(file, f"uploads/{unique_filename}") + + async def _create_s3_bucket(self) -> None: + """Creates an S3 bucket if it does not already exist.""" + try: + await self.s3.meta.client.head_bucket(Bucket=settings.bucket_name) + logger.info("Found existing bucket %s", settings.bucket_name) + except ClientError: + logger.info("Creating %s bucket", settings.bucket_name) + await self.s3.create_bucket(Bucket=settings.bucket_name) + + logger.info("Updating %s CORS configuration", settings.bucket_name) + s3_cors = await self.s3.BucketCors(settings.bucket_name) + await s3_cors.put( + CORSConfiguration={ + "CORSRules": [ + { + "AllowedHeaders": ["*"], + "AllowedMethods": ["GET"], + "AllowedOrigins": get_cors_origins(), + "ExposeHeaders": ["ETag"], + } + ] + }, + ) + + async def _delete_dynamodb_table(self, name: str) -> None: + """Deletes a table in the Dynamo database. + + Args: + name: Name of the table. + """ + try: + table = await self.db.Table(name) + await table.delete() + logger.info("Deleted table %s", name) + except ClientError: + logger.info("Table %s does not exist", name) + + async def _delete_s3_bucket(self) -> None: + """Deletes an S3 bucket.""" + bucket = await self.s3.Bucket(settings.bucket_name) + logger.info("Deleting bucket %s", settings.bucket_name) + async for obj in bucket.objects.all(): + await obj.delete() + await bucket.delete() diff --git a/linguaphoto/db.py b/linguaphoto/db.py new file mode 100644 index 0000000..c354baf --- /dev/null +++ b/linguaphoto/db.py @@ -0,0 +1,110 @@ +"""Defines base tools for interacting with the database.""" + +import argparse +import asyncio +import logging +from typing import AsyncGenerator, Literal, Self + +from linguaphoto.crud.base import TABLE_NAME, BaseCrud +from linguaphoto.crud.collection import CollectionCrud +from linguaphoto.crud.image import ImageCrud +from linguaphoto.crud.user import UserCrud + + +class Crud( + CollectionCrud, + ImageCrud, + UserCrud, + BaseCrud, +): + """Composes the various CRUD classes into a single class.""" + + @classmethod + async def get(cls) -> AsyncGenerator[Self, None]: + async with cls() as crud: + yield crud + + +async def create_tables(crud: Crud | None = None, deletion_protection: bool = False) -> None: + """Initializes all of the database tables. + + Args: + crud: The top-level CRUD class. + deletion_protection: Whether to enable deletion protection on the tables. + """ + logging.basicConfig(level=logging.INFO) + + if crud is None: + async with Crud() as new_crud: + await create_tables(new_crud) + + else: + gsis_set = crud.get_gsis() + gsis: list[tuple[str, str, Literal["S", "N", "B"], Literal["HASH", "RANGE"]]] = [ + (Crud.get_gsi_index_name(g), g, "S", "HASH") for g in gsis_set + ] + + await asyncio.gather( + crud._create_dynamodb_table( + name=TABLE_NAME, + keys=[ + ("id", "S", "HASH"), + ], + gsis=gsis, + deletion_protection=deletion_protection, + ), + crud._create_s3_bucket(), + ) + + +async def delete_tables(crud: Crud | None = None) -> None: + """Deletes all of the database tables. + + Args: + crud: The top-level CRUD class. + """ + logging.basicConfig(level=logging.INFO) + + if crud is None: + async with Crud() as new_crud: + await delete_tables(new_crud) + + else: + await crud._delete_dynamodb_table(TABLE_NAME) + await crud._delete_s3_bucket() + + +async def populate_with_dummy_data(crud: Crud | None = None) -> None: + """Populates the database with dummy data. + + Args: + crud: The top-level CRUD class. + """ + if crud is None: + async with Crud() as new_crud: + await populate_with_dummy_data(new_crud) + + else: + raise NotImplementedError("This function is not yet implemented.") + + +async def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=["create", "delete", "populate"]) + args = parser.parse_args() + + async with Crud() as crud: + match args.action: + case "create": + await create_tables(crud) + case "delete": + await delete_tables(crud) + case "populate": + await populate_with_dummy_data(crud) + case _: + raise ValueError(f"Invalid action: {args.action}") + + +if __name__ == "__main__": + # python -m store.app.db + asyncio.run(main()) diff --git a/linguaphoto/settings.py b/linguaphoto/settings.py index f775b3c..6e4fd95 100644 --- a/linguaphoto/settings.py +++ b/linguaphoto/settings.py @@ -28,6 +28,7 @@ class Settings: openai_key = os.getenv("OPENAI_API_KEY") stripe_key = os.getenv("STRIPE_API_KEY") stripe_price_id = os.getenv("STRIPE_PRODUCT_PRICE_ID", "price_1Q0ZaMKeTo38dsfeSWRDGCEf") + homepage_url = os.getenv("HOMEPAGE_URL", "") settings = Settings() diff --git a/linguaphoto/utils/utils.py b/linguaphoto/utils/utils.py new file mode 100644 index 0000000..9b9f65c --- /dev/null +++ b/linguaphoto/utils/utils.py @@ -0,0 +1,12 @@ +"""Defines package-wide utility functions.""" + +from linguaphoto.settings import settings + +LOCALHOST_URLS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", +] + + +def get_cors_origins() -> list[str]: + return list({settings.homepage_url, *LOCALHOST_URLS})