Skip to content

Commit

Permalink
some useful boilerplate for the application (#3)
Browse files Browse the repository at this point in the history
* boilerplate

* more boilerplate

* more boilerplate

* more boilerplate

* use built-in types

* docker stuff

* change tests

* small fixes

* stuff

* missing settings

* change env var location
  • Loading branch information
codekansas authored May 27, 2024
1 parent f504430 commit b1f0664
Show file tree
Hide file tree
Showing 19 changed files with 542 additions and 8 deletions.
20 changes: 19 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,28 @@ concurrency:
group: tests-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

env:
ROBOLIST_ENVIRONMENT: local
JWT_SECRET: test
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_REGION: us-east-1

jobs:
run-tests:
timeout-minutes: 10
runs-on: ubuntu-latest

# DynamoDB Local
services:
dynamodb-local:
image: amazon/dynamodb-local
options: >-
-p 0:8080
-v /tmp/dynamodb:/data
ports:
- 8080

steps:
- name: Check out repository
uses: actions/checkout@v3
Expand Down Expand Up @@ -51,7 +69,7 @@ jobs:
working-directory: frontend
run: |
npm test -- --watchAll=false
- name: Build frontend
run: |
npm run build
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@ Serve the FastAPI application in development mode:
fastapi dev 'store/app/main.py'
```

#### Configuration

Settings for the app backend live in the `store/settings/` directory. You can use the following environment variables:

- `ROBOLIST_ENVIRONMENT_SECRETS` should be the path to a local `.env` file containing any environment secrets
- `ROBOLIST_ENVIRONMENT` is the stem of one of the config files in the `store/settings/configs/` directory. When developing locally this should usually just be `local`

#### Database

When developing locally, use the `amazon/dynamodb-local` Docker image to run a local instance of DynamoDB:

```bash
docker pull amazon/dynamodb-local # If you haven't already
docker run -d -p 8080:8080 amazon/dynamodb-local # Start the container in the background
```

Initialize the test databases by running the creation script:

```bash
python -m store.app.api.db
```

### React

Automatically rebuild the React frontend code when a file is changed:
Expand Down
11 changes: 7 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@ warn_redundant_casts = true
incremental = true
namespace_packages = false

# Uncomment to exclude modules from Mypy.
# [[tool.mypy.overrides]]
# module = []
# ignore_missing_imports = true
[[tool.mypy.overrides]]

module = [
"boto3.*",
]

ignore_missing_imports = true

[tool.isort]

Expand Down
Empty file added store/app/api/__init__.py
Empty file.
Empty file added store/app/api/crud/__init__.py
Empty file.
79 changes: 79 additions & 0 deletions store/app/api/crud/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Defines CRUD interface for user API."""

import asyncio

from types_aiobotocore_dynamodb.service_resource import DynamoDBServiceResource

from store.app.api.db import get_aio_db
from store.app.api.model import Token, User


async def add_user(user: User, db: DynamoDBServiceResource) -> None:
"""Adds a user to the database.
Args:
user: The user to add.
db: The DynamoDB database.
"""
table = await db.Table("Users")
await table.put_item(Item=user.model_dump())


async def get_user(user_id: str, db: DynamoDBServiceResource) -> User:
"""Gets a user from the database.
Args:
user_id: The ID of the user to retrieve.
db: The DynamoDB database.
"""
table = await db.Table("Users")
user_dict = await table.get_item(Key={"user_id": user_id})
user = User.model_validate(user_dict["Item"])
return user


async def get_user_count(db: DynamoDBServiceResource) -> int:
"""Counts the users in the database.
Args:
db: The DynamoDB database.
"""
table = await db.Table("Users")
return await table.item_count


async def add_token(token: Token, db: DynamoDBServiceResource) -> None:
"""Adds a token to the database.
Args:
token: The token to add.
db: The DynamoDB database.
"""
table = await db.Table("UserTokens")
await table.put_item(Item=token.model_dump())


async def get_token(token_id: str, db: DynamoDBServiceResource) -> Token:
"""Gets a token from the database.
Args:
token_id: The ID of the token to retrieve.
db: The DynamoDB database.
"""
table = await db.Table("UserTokens")
token_dict = await table.get_item(Key={"token_id": token_id})
token = Token.model_validate(token_dict["Item"])
return token


async def test_adhoc() -> None:
async with get_aio_db() as db:
await add_user(User(user_id="ben", email="[email protected]"), db)
# print(await get_user("ben", db))
# print(await get_user_count(db))
# await get_token("ben", db)


if __name__ == "__main__":
# python -m store.app.api.crud.users
asyncio.run(test_adhoc())
73 changes: 73 additions & 0 deletions store/app/api/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# mypy: disable-error-code="empty-body"
"""Defines base tools for interacting with the database."""

import asyncio
from contextlib import asynccontextmanager
from typing import AsyncGenerator, Literal

import aioboto3
from types_aiobotocore_dynamodb.service_resource import DynamoDBServiceResource

from store.settings import settings


@asynccontextmanager
async def get_aio_db() -> AsyncGenerator[DynamoDBServiceResource, None]:
session = aioboto3.Session()
async with session.resource(
"dynamodb",
endpoint_url=settings.database.endpoint_url,
region_name=settings.database.region_name,
aws_access_key_id=settings.database.aws_access_key_id,
aws_secret_access_key=settings.database.aws_secret_access_key,
) as db:
yield db


async def _create_dynamodb_table(
db: DynamoDBServiceResource,
name: str,
columns: list[tuple[str, Literal["S", "N", "B"]]],
pks: list[tuple[str, Literal["HASH", "RANGE"]]],
deletion_protection: bool = False,
read_capacity_units: int = 2,
write_capacity_units: int = 2,
billing_mode: Literal["PROVISIONED", "PAY_PER_REQUEST"] = "PAY_PER_REQUEST",
) -> None:
table = await db.create_table(
AttributeDefinitions=[{"AttributeName": n, "AttributeType": t} for n, t in columns],
TableName=name,
KeySchema=[{"AttributeName": pk[0], "KeyType": pk[1]} for pk in pks],
ProvisionedThroughput={"ReadCapacityUnits": read_capacity_units, "WriteCapacityUnits": write_capacity_units},
OnDemandThroughput={"MaxReadRequestUnits": read_capacity_units, "MaxWriteRequestUnits": write_capacity_units},
DeletionProtectionEnabled=deletion_protection,
BillingMode=billing_mode,
)
await table.wait_until_exists()


async def create_tables(db: DynamoDBServiceResource | None = None) -> None:
"""Initializes all of the database tables.
Args:
db: The DynamoDB database.
"""
if db is None:
async with get_aio_db() as db:
await create_tables(db)
else:
await _create_dynamodb_table(
db=db,
name="Users",
columns=[
("user_id", "S"),
],
pks=[
("user_id", "HASH"),
],
)


if __name__ == "__main__":
# python -m store.app.api.db
asyncio.run(create_tables())
100 changes: 100 additions & 0 deletions store/app/api/email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Utility functions for sending emails to users."""

import argparse
import asyncio
import datetime
import logging
import textwrap
from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

import aiosmtplib

from store.app.api.token import create_token, load_token
from store.settings import settings

logger = logging.getLogger(__name__)


async def send_email(subject: str, body: str, to: str) -> None:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{settings.email.name} <{settings.email.email}>"
msg["To"] = to

msg.attach(MIMEText(body, "html"))

smtp_client = aiosmtplib.SMTP(hostname=settings.email.host, port=settings.email.port)

await smtp_client.connect()
await smtp_client.login(settings.email.email, settings.email.password)
await smtp_client.sendmail(settings.email.email, to, msg.as_string())
await smtp_client.quit()


@dataclass
class OneTimePassPayload:
email: str

def encode(self) -> str:
expire_minutes = settings.crypto.expire_otp_minutes
expire_after = datetime.timedelta(minutes=expire_minutes)
return create_token({"email": self.email}, expire_after=expire_after)

@classmethod
def decode(cls, payload: str) -> "OneTimePassPayload":
data = load_token(payload)
return cls(email=data["email"])


async def send_otp_email(payload: OneTimePassPayload, login_url: str) -> None:
url = f"{login_url}?otp={payload.encode()}"

body = textwrap.dedent(
f"""
<h1><code>don't panic</code><br/><code>stay human</code></h1>
<h2><code><a href="{url}">log in</a></code></h2>
<p>Or copy-paste this link: {url}</p>
"""
)

await send_email(subject="One-Time Password", body=body, to=payload.email)


async def send_delete_email(email: str) -> None:
body = textwrap.dedent(
"""
<h1><code>don't panic</code><br/><code>stay human</code></h1>
<h2><code>your account has been deleted</code></h2>
"""
)

await send_email(subject="Account Deleted", body=body, to=email)


async def send_waitlist_email(email: str) -> None:
body = textwrap.dedent(
"""
<h1><code>don't panic</code><br/><code>stay human</code></h1>
<h2><code>you're on the waitlist!</code></h2>
<p>Thanks for signing up! We'll let you know when you can log in.</p>
"""
)

await send_email(subject="Waitlist", body=body, to=email)


def test_email_adhoc() -> None:
parser = argparse.ArgumentParser(description="Test sending an email.")
parser.add_argument("subject", help="The subject of the email.")
parser.add_argument("body", help="The body of the email.")
parser.add_argument("to", help="The recipient of the email.")
args = parser.parse_args()

asyncio.run(send_email(args.subject, args.body, args.to))


if __name__ == "__main__":
# python -m bot.api.email
test_email_adhoc()
21 changes: 21 additions & 0 deletions store/app/api/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# mypy: disable-error-code="var-annotated"
"""Defines the table models for the API."""

import datetime
from dataclasses import field

from pydantic import BaseModel


class User(BaseModel):
user_id: str
email: str
banned: bool = field(default=False)
deleted: bool = field(default=False)


class Token(BaseModel):
token_id: str
user_id: str
issued: datetime.datetime = field(default_factory=datetime.datetime.now)
disabled: bool = field(default=False)
Empty file.
9 changes: 9 additions & 0 deletions store/app/api/routers/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Defines the API endpoint for creating, deleting and updating user information."""

import logging

from fastapi import APIRouter

logger = logging.getLogger(__name__)

api_router = APIRouter()
Loading

0 comments on commit b1f0664

Please sign in to comment.