Skip to content

Commit

Permalink
some changes
Browse files Browse the repository at this point in the history
  • Loading branch information
codekansas committed Jun 16, 2024
1 parent 9fbf796 commit 0add099
Show file tree
Hide file tree
Showing 13 changed files with 227 additions and 84 deletions.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,24 @@
# LinguaPhoto

## Getting Started

First, start localstack, which lets you run AWS locally.

Next, create tables using the command:

```bash
python -m linguaphoto.db
```

Run the backend using the command:

```bash
uvicorn linguaphoto.main:app --reload
```

Finally, run the frontend using the command:

```bash
cd frontend
npm start
```
83 changes: 77 additions & 6 deletions linguaphoto/crud/base.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""Defines the base CRUD interface."""

import asyncio
import itertools
import logging
from typing import Any, AsyncContextManager, Literal, Self

import aioboto3
from botocore.exceptions import ClientError
from redis.asyncio import Redis
from types_aiobotocore_cloudfront.client import CloudFrontClient
from types_aiobotocore_dynamodb.service_resource import DynamoDBServiceResource
from types_aiobotocore_s3.client import S3Client

from linguaphoto.settings import settings

Expand All @@ -20,30 +23,84 @@ def __init__(self) -> None:

self.__db: DynamoDBServiceResource | None = None
self.__kv: Redis | None = None
self.__s3: S3Client | None = None
self.__cf: CloudFrontClient | None = None

@property
def db(self) -> DynamoDBServiceResource:
if self.__db is None:
raise RuntimeError("Must call __aenter__ first!")
return self.__db

async def __aenter__(self) -> Self:
session = aioboto3.Session()
@property
def kv(self) -> Redis:
if self.__kv is None:
raise RuntimeError("Must call __aenter__ first!")
return self.__kv

@property
def s3(self) -> S3Client:
if self.__s3 is None:
raise RuntimeError("Must call __aenter__ first!")
return self.__s3

@property
def cf(self) -> CloudFrontClient:
if self.__cf is None:
raise RuntimeError("Must call __aenter__ first!")
return self.__cf

async def _init_dynamodb(self, session: aioboto3.Session) -> Self:
db = session.resource("dynamodb")
db = await db.__aenter__()
await db.__aenter__()
self.__db = db
return self

async def _init_cloudfront(self, session: aioboto3.Session) -> Self:
cf = session.client("cloudfront")
await cf.__aenter__()
self.__cf = cf
return self

async def _init_s3(self, session: aioboto3.Session) -> Self:
s3 = session.client("s3")
await s3.__aenter__()
self.__s3 = s3
return self

self.kv = Redis(
async def _init_redis(self) -> Self:
kv = Redis(
host=settings.redis.host,
password=settings.redis.password,
port=settings.redis.port,
db=settings.redis.db,
)
await kv.__aenter__()
self.__kv = kv
return self

async def __aenter__(self) -> Self:
session = aioboto3.Session()

await asyncio.gather(
self._init_dynamodb(session),
self._init_cloudfront(session),
self._init_s3(session),
self._init_redis(),
)

return self

async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: # noqa: ANN401
if self.__db is not None:
await self.__db.__aexit__(exc_type, exc_val, exc_tb)
await asyncio.gather(
self.db.__aexit__(exc_type, exc_val, exc_tb),
self.cf.__aexit__(exc_type, exc_val, exc_tb),
self.kv.__aexit__(exc_type, exc_val, exc_tb),
self.s3.__aexit__(exc_type, exc_val, exc_tb),
)
self.__db = None
self.__kv = None
self.__s3 = None

async def _create_dynamodb_table(
self,
Expand Down Expand Up @@ -87,3 +144,17 @@ async def _create_dynamodb_table(
BillingMode="PAY_PER_REQUEST",
)
await table.wait_until_exists()

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()
await table.wait_until_not_exists()
except ClientError:
logger.info("Table %s does not exist", name)
return
45 changes: 45 additions & 0 deletions linguaphoto/crud/images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Defines CRUD interface for images API."""

import uuid

from linguaphoto.crud.base import BaseCrud
from linguaphoto.settings import settings


class ImagesCrud(BaseCrud):
async def add_image(self, image_id: uuid.UUID, user_id: uuid.UUID, image: bytes) -> None:
# Adds the image to the database.
table = await self.db.Table("Images")
await table.put_item(Item={"image_id": str(image_id), "user_id": str(user_id)})

# Uploads the image to S3.
key = f"{user_id}/{image_id}"
await self.s3.put_object(Bucket=settings.aws.image_bucket_id, Key=key, Body=image)

async def delete_image(self, image_id: uuid.UUID, user_id: uuid.UUID) -> None:
# Deletes the image from the database.
table = await self.db.Table("Images")
await table.delete_item(Key={"image_id": str(image_id), "user_id": str(user_id)})

# Deletes the image from S3.
key = f"{user_id}/{image_id}.webp"
await self.s3.delete_object(Bucket=settings.aws.image_bucket_id, Key=key)

async def get_image_url(self, image_id: uuid.UUID, user_id: uuid.UUID) -> str | None:
key = f"{user_id}/{image_id}.webp"

# If CloudFront is enabled, gets the image URL from CloudFront.
if settings.aws.cloudfront_url is not None:
return await self.cf.generate_presigned_url(
"get_object",
Params={"Bucket": settings.aws.image_bucket_id, "Key": key},
ExpiresIn=3600,
)

# Gets the image URL from S3.
url = await self.s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.aws.image_bucket_id, "Key": key},
ExpiresIn=3600,
)
return url
48 changes: 0 additions & 48 deletions linguaphoto/crud/robots.py

This file was deleted.

5 changes: 4 additions & 1 deletion linguaphoto/crud/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
class UserCrud(BaseCrud):
async def add_user(self, user: User) -> None:
table = await self.db.Table("Users")
await table.put_item(Item=user.model_dump())
await table.put_item(
Item=user.model_dump(),
ConditionExpression="attribute_not_exists(email) AND attribute_not_exists(username)",
)

async def get_user(self, user_id: uuid.UUID) -> User | None:
table = await self.db.Table("Users")
Expand Down
51 changes: 40 additions & 11 deletions linguaphoto/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
import logging
from typing import AsyncGenerator, Self

import argparse
from linguaphoto.crud.base import BaseCrud
from linguaphoto.crud.robots import RobotCrud
from linguaphoto.crud.images import ImagesCrud
from linguaphoto.crud.users import UserCrud


class Crud(
UserCrud,
RobotCrud,
ImagesCrud,
BaseCrud,
):
"""Composes the various CRUD classes into a single class."""
Expand All @@ -22,11 +23,12 @@ async def get(cls) -> AsyncGenerator[Self, None]:
yield crud


async def create_tables(crud: Crud | None = None) -> None:
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)

Expand All @@ -35,17 +37,44 @@ async def create_tables(crud: Crud | None = None) -> None:
await create_tables(crud)

else:
await crud._create_dynamodb_table(
name="Users",
keys=[
("user_id", "S", "HASH"),
],
gsis=[
("emailIndex", "email", "S", "HASH"),
],
await asyncio.gather(
crud._create_dynamodb_table(
name="Users",
keys=[
("user_id", "S", "HASH"),
],
gsis=[
("emailIndex", "email", "S", "HASH"),
("usernameIndex", "username", "S", "HASH"),
],
deletion_protection=deletion_protection,
),
crud._create_dynamodb_table(
name="Images",
keys=[
("image_id", "S", "HASH"),
],
),
)


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 linguaphoto.db
asyncio.run(create_tables())
3 changes: 3 additions & 0 deletions linguaphoto/requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ s3fs

# Types
types-requests

# AWS
localstack
2 changes: 1 addition & 1 deletion linguaphoto/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ python-multipart
uvicorn[standard]

# Types
types-aioboto3[dynamodb]
types-aioboto3[dynamodb,cloudfront,s3]

# AI dependencies
numpy
Expand Down
7 changes: 5 additions & 2 deletions linguaphoto/routers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from linguaphoto.crypto import get_new_api_key, get_new_user_id
from linguaphoto.db import Crud
from linguaphoto.model import User
from linguaphoto.settings import settings

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -117,7 +118,9 @@ async def google_login_endpoint(
data: GoogleLogin,
crud: Annotated[Crud, Depends(Crud.get)],
) -> UserLoginResponse:
"""Uses Google OAuth to create an API token that lasts for a week (i.e. 604800 seconds)."""
if (test_user := settings.user.test_user) is not None and data.token == test_user.google_token:
return await get_login_response(test_user.email, settings.user.auth_lifetime_seconds, crud)

try:
idinfo = await get_google_user_info(data.token)
email = idinfo["email"]
Expand All @@ -126,7 +129,7 @@ async def google_login_endpoint(
if idinfo.get("email_verified") is not True:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Google email not verified")

return await get_login_response(email, 604800, crud)
return await get_login_response(email, settings.user.auth_lifetime_seconds, crud)


class UserInfoResponse(BaseModel):
Expand Down
5 changes: 5 additions & 0 deletions linguaphoto/settings/configs/local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ crypto:
jwt_secret: fakeJwtSecret
site:
homepage: http://127.0.0.1:3000
image_bucket_id: linguaphoto-images
user:
test_user:
email: [email protected]
google_token: fakeGoogleToken
Loading

0 comments on commit 0add099

Please sign in to comment.