Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Serhii milestone 2 fix #41

Merged
merged 5 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apprunner.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions debug.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""it is entity for debugging"""

import uvicorn

from linguaphoto.main import app
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Audio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ currentImage, index }) => {
useEffect(() => {
if (audioRef.current) {
audioRef.current.load();
audioRef.current.playbackRate = playbackRate;
}
}, [currentImage, index]);

Expand Down
116 changes: 115 additions & 1 deletion linguaphoto/crud/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)

Expand All @@ -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__)


Expand All @@ -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(
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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()
110 changes: 110 additions & 0 deletions linguaphoto/db.py
Original file line number Diff line number Diff line change
@@ -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())
1 change: 1 addition & 0 deletions linguaphoto/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
12 changes: 12 additions & 0 deletions linguaphoto/utils/utils.py
Original file line number Diff line number Diff line change
@@ -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})
Loading