Skip to content

Commit

Permalink
Switch to hatch manager
Browse files Browse the repository at this point in the history
  • Loading branch information
devdupont committed Jul 15, 2024
1 parent 2ef4fcd commit 55f20bf
Show file tree
Hide file tree
Showing 40 changed files with 187 additions and 224 deletions.
32 changes: 10 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
# account-backend

[![python](https://img.shields.io/badge/Python-3.12-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org)
[![Nox](https://github.com/avwx-rest/account-backend/actions/workflows/nox.yml/badge.svg)](https://github.com/avwx-rest/account-backend/actions/workflows/nox.yml)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.110-009688.svg?style=flat&logo=FastAPI&logoColor=white)](https://fastapi.tiangolo.com)
[![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch)
[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

AVWX account management backend service
Expand All @@ -31,15 +30,15 @@ It's built on top of these libraries to provide those features:

## Setup

This codebase was written for Python 3.11 and above. Don't forget about a venv as well. The `python` commands below assume you're pointing to your desired Python3 target.
This codebase was written for Python 3.12 and above. Don't forget about a venv as well. The `python` commands below assume you're pointing to your desired Python3 target.

First we'll need to install our requirements.
We use [hatch]() to manage all of our tooling and sub-environments, but you may still wish to install the requirements if you want things like autocomplete to work in your code editor of choice.

```bash
python -m pip install -e .
```

Before we run the server, there is one config variable you'll need to generate the password salt. To do this, just run the script in this repo.
Before we run the server, there is one config variable you'll need to generate: the password salt. To do this, just run the script in this repo.

```bash
cp sample.env .env
Expand All @@ -53,7 +52,7 @@ There are other settings in `config.py` and the included `.env` file. Assuming y
The API uses [uvicorn]() as our ASGI web server. This allows us to run our server code in a much more robust and configurable environment than the development server. For example, ASGI servers let you run multiple workers that recycle themselves after a set amount of time or number of requests.

```bash
uvicorn account.main:app --reload --port 8080
hatch run serve:reload
```

Your API should now be available at http://localhost:8080
Expand All @@ -67,33 +66,22 @@ docker run -p 8080:8080 avwx-account

## Develop

This codebase is uses [mypy]() for type checking and [ruff]() for everything else. Install both with the dev tag.

```bash
python -m pip install -e .[dev]
```
This codebase is uses [mypy]() for type checking and [hatch]() for everything else.

To run the type checker:

```bash
mypy account
hatch run types:check
```

To run the linter and code formatter:

```bash
ruff check account
ruff format account
hatch fmt
```

## Test

Make sure to install the requirements found in the test folder before trying to run the tests.

```bash
python -m pip install -e .[test]
```

The tests need access to a [MongoDB]() store that is emptied at the end of each test. The easiest way to do this is to run a Mongo container in the background.

```bash
Expand All @@ -105,7 +93,7 @@ You can also connect to a remote server if you're running tests in a CI/CD pipel
Then just run the test suite.

```bash
pytest
hatch test
```

[MongoDB]: https://www.mongodb.com "MongoDB NoSQL homepage"
Expand All @@ -117,4 +105,4 @@ pytest
[fastapi-mail]: https://github.com/sabuhish/fastapi-mail "FastAPI mail server"
[uvicorn]: https://www.uvicorn.org "Uvicorn ASGI web server"
[mypy]: https://www.mypy-lang.org "mypy Python type checker"
[ruff]: https://docs.astral.sh/ruff/ "Ruff code linter and formatter"
[hatch]: https://hatch.pypa.io/latest/ "Hatch project and tooling manager"
File renamed without changes.
15 changes: 7 additions & 8 deletions account/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,17 @@

import logfire
import rollbar
from rollbar.contrib.fastapi import ReporterMiddleware
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from beanie import init_beanie
from fastapi import FastAPI
from motor.motor_asyncio import AsyncIOMotorClient
from rollbar.contrib.fastapi import ReporterMiddleware
from starlette.middleware.cors import CORSMiddleware

from account.config import CONFIG
from account.models.addon import Addon
from account.models.plan import Plan
from account.models.token import TokenUsage
from account.models.user import User
from account.config import CONFIG


DESCRIPTION = """
This API powers the account management portal
Expand All @@ -33,10 +32,10 @@
async def lifespan(app: FastAPI): # type: ignore
"""Initialize application services."""
# Init Database
client = AsyncIOMotorClient(CONFIG.mongo_uri.strip('"'))
app.state.db = client[CONFIG.database] # type: ignore[attr-defined]
client: AsyncIOMotorClient = AsyncIOMotorClient(CONFIG.mongo_uri.strip('"'))
app.state.db = client[CONFIG.database]
documents = [Addon, Plan, TokenUsage, User]
await init_beanie(app.state.db, document_models=documents) # type: ignore[arg-type,attr-defined]
await init_beanie(app.state.db, document_models=documents) # type: ignore
print("Startup complete")
yield
print("Shutdown complete")
Expand Down
4 changes: 2 additions & 2 deletions account/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from datetime import timedelta

from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearer, JwtRefreshBearer
from fastapi_jwt import JwtAccessBearer, JwtAuthorizationCredentials, JwtRefreshBearer

from account.config import CONFIG
from account.models.user import User
Expand Down Expand Up @@ -30,5 +30,5 @@ async def user_from_credentials(auth: JwtAuthorizationCredentials) -> User | Non

async def user_from_token(token: str) -> User | None:
"""Return the user associated with a token value."""
payload = access_security._decode(token)
payload = access_security._decode(token) # noqa SLF001
return await User.by_email(payload["subject"]["username"])
9 changes: 4 additions & 5 deletions account/main.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
"""Server main runtime."""

from . import routes
from .app import app
from .config import CONFIG

from account import routes
from account.app import app
from account.config import CONFIG

for router in routes.ROUTERS:
app.include_router(router)

if CONFIG.admin_root:
from .routes import admin
from account.routes import admin

app.include_router(admin.router)
3 changes: 2 additions & 1 deletion account/models/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ def to_user(self, plan: str) -> UserAddon:
key = "yearly" if plan.endswith("-year") else "monthly"
price = self.price_ids.get(key)
if not price:
raise ValueError(f"Unknown addon price for {plan} and {key}")
msg = f"Unknown addon price for {plan} and {key}"
raise ValueError(msg)
return UserAddon(
key=self.key,
name=self.name,
Expand Down
8 changes: 6 additions & 2 deletions account/models/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
from collections.abc import Callable, Iterator
from typing import Any

from bson.objectid import ObjectId, InvalidId
from bson.objectid import InvalidId, ObjectId

# from pydantic_core import core_schema
# from pydantic import GetCoreSchemaHandler


class ObjectIdStr(str):
"""Represents an ObjectId not managed by Beanie."""

__slots__ = ()

@classmethod
def __get_validators__(cls) -> Iterator[Callable[[Any, Any], str]]:
yield cls.validate
Expand All @@ -21,7 +24,8 @@ def validate(cls, value: Any, _: Any) -> str:
try:
ObjectId(str(value))
except InvalidId as exc:
raise ValueError("Not a valid ObjectId") from exc
msg = "Not a valid ObjectId"
raise ValueError(msg) from exc
return str(value)

# Pydantic v2 -> v3 requirement
Expand Down
6 changes: 3 additions & 3 deletions account/models/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def __str__(self) -> str:
def __hash__(self) -> int:
return hash(self.key)

def __eq__(self, other: Any) -> bool:
def __eq__(self, other: object) -> bool:
if not other:
return False
if isinstance(other, str):
Expand Down Expand Up @@ -71,6 +71,6 @@ async def by_key(cls, key: str) -> Self | None:
return await cls.find_one(cls.key == key)

@classmethod
async def by_stripe_id(cls, id: str) -> Self | None:
async def by_stripe_id(cls, plan_id: str) -> Self | None:
"""Get a plan by Stripe product ID."""
return await cls.find_one(cls.stripe_id == id)
return await cls.find_one(cls.stripe_id == plan_id)
24 changes: 8 additions & 16 deletions account/models/user.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""User models."""

from datetime import datetime, UTC
from datetime import UTC, datetime
from secrets import token_urlsafe
from typing import Annotated, Any, Self

Expand Down Expand Up @@ -47,14 +47,14 @@ def _gen(self) -> None:
self.value = value

@classmethod
async def new(cls, name: str = "Token", type: str = "app") -> "UserToken":
async def new(cls, name: str = "Token", type: str = "app") -> Self: # noqa A002
"""Generate a new unique token."""
token = cls(_id=ObjectId(), name=name, type=type, value="") # type: ignore[arg-type]
await token.refresh()
return token

@classmethod
async def dev(cls) -> "UserToken":
async def dev(cls) -> Self:
"""Generate a new development token."""
return await cls.new("Development", "dev")

Expand Down Expand Up @@ -155,9 +155,9 @@ async def by_email(cls, email: str) -> Self | None:
return await cls.find_one(cls.email == email)

@classmethod
async def by_customer_id(cls, id: str) -> Self | None:
async def by_customer_id(cls, user_id: str) -> Self | None:
"""Get a user by Stripe customer ID."""
return await cls.find_one(cls.stripe.customer_id == id) # type: ignore
return await cls.find_one(cls.stripe.customer_id == user_id) # type: ignore

@classmethod
async def from_stripe_session(cls, session: Session) -> Self | None:
Expand All @@ -171,26 +171,18 @@ async def add_default_documents(self) -> None:
def get_token(self, value: str) -> tuple[int, UserToken | None]:
"""Return a token and index by its id."""
return next(
(
(i, token)
for i, token in enumerate(self.tokens)
if str(token.id) == value
),
((i, token) for i, token in enumerate(self.tokens) if str(token.id) == value),
(-1, None),
)

def get_notification(self, value: str) -> tuple[int, Notification | None]:
"""Return a notification and index by its string value."""
return next(
(
(i, notification)
for i, notification in enumerate(self.notifications)
if notification.id == value
),
((i, notification) for i, notification in enumerate(self.notifications) if notification.id == value),
(-1, None),
)

async def add_notification(self, type: str, text: str) -> None:
async def add_notification(self, type: str, text: str) -> None: # noqa A002
"""Add a new notification to the user's list."""
self.notifications.append(Notification(type=type, text=text))
await self.save()
Expand Down
9 changes: 4 additions & 5 deletions account/routes/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from account.models.user import User
from account.util.current_user import current_user
from account.util.stripe import (
get_session,
add_to_subscription,
get_session,
remove_from_subscription,
)

Expand All @@ -21,17 +21,16 @@ async def get_user_addons(user: User = Depends(current_user)) -> list[UserAddon]


@router.post("/{key}")
async def new_addon( # type: ignore[no-untyped-def]
key: str, user: User = Depends(current_user)
):
async def new_addon(key: str, user: User = Depends(current_user)): # type: ignore
"""Add a new addon to user by key. Returns a Stripe session if Checkout is required."""
addon = await Addon.by_key(key)
if addon is None:
raise HTTPException(404, f"Addon with key {key} does not exist")
if user.has_addon(addon.key):
raise HTTPException(400, f"User already has the {addon.key} addon")
if user.plan is None:
raise ValueError("Cannot add addon to user with no plan")
msg = "Cannot add addon to user with no plan"
raise ValueError(msg)
user_addon = addon.to_user(user.plan.key)
if not user.has_subscription:
return get_session(user, user_addon.price_id, metered=addon.metered)
Expand Down
3 changes: 2 additions & 1 deletion account/routes/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Admin routes."""

from fastapi import APIRouter

from account.config import CONFIG
from . import stripe, token, user
from account.routes.admin import stripe, token, user

router = APIRouter(prefix=f"/{CONFIG.admin_root}", include_in_schema=False)

Expand Down
4 changes: 1 addition & 3 deletions account/routes/admin/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@


@router.post("/history", dependencies=[Depends(admin_user)])
async def get_all_history(
days: int = 30, user: User = Depends(embedded_user)
) -> list[AllTokenUsageOut]:
async def get_all_history(days: int = 30, user: User = Depends(embedded_user)) -> list[AllTokenUsageOut]:
"""Return all recent token history for another user."""
return await token_usage_for(user, days)
1 change: 0 additions & 1 deletion account/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from account.models.user import User, UserAuth
from account.util.password import hash_password


router = APIRouter(prefix="/auth", tags=["Auth"])


Expand Down
3 changes: 1 addition & 2 deletions account/routes/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@
from fastapi import APIRouter, Body, Depends, HTTPException, Response
from pydantic import EmailStr

from account.models.user import User
from account.jwt import access_security, user_from_token
from account.models.user import User
from account.util.current_user import current_user
from account.util.mail import send_verification_email
from account.util.mailing import add_to_mailing, remove_from_mailing


router = APIRouter(prefix="/mail", tags=["Mail"])


Expand Down
4 changes: 1 addition & 3 deletions account/routes/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ async def delete_notifications(user: User = Depends(current_user)) -> Response:


@router.delete("/{value}")
async def delete_notification(
value: str, user: User = Depends(current_user)
) -> Response:
async def delete_notification(value: str, user: User = Depends(current_user)) -> Response:
"""Delete a single notification."""
i, _ = user.get_notification(value)
if i < 0:
Expand Down
4 changes: 2 additions & 2 deletions account/routes/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from account.models.plan import Plan, PlanOut
from account.models.user import User
from account.util.current_user import current_user
from account.util.stripe import get_session, change_subscription, cancel_subscription
from account.util.stripe import cancel_subscription, change_subscription, get_session

router = APIRouter(prefix="/plan", tags=["Plan"])

Expand All @@ -21,7 +21,7 @@ async def get_user_plan(user: User = Depends(current_user)) -> Plan:
@router.post("")
async def change_plan( # type: ignore[no-untyped-def]
key: str = Body(..., embed=True),
remove_addons: bool = Body(True, embed=True),
remove_addons: bool = Body(True, embed=True), # noqa FBT001
user: User = Depends(current_user),
):
"""Change the user's current plan. Returns Stripe session if Checkout is required."""
Expand Down
Loading

0 comments on commit 55f20bf

Please sign in to comment.