From 55f20bfdd35ac2818551eb836bd926e5be0905a8 Mon Sep 17 00:00:00 2001 From: Michael duPont Date: Mon, 15 Jul 2024 02:02:42 -0400 Subject: [PATCH] Switch to hatch manager --- README.md | 32 +++++----------- account/{__init_.py => __init__.py} | 0 account/app.py | 15 ++++---- account/jwt.py | 4 +- account/main.py | 9 ++--- account/models/addon.py | 3 +- account/models/helpers.py | 8 +++- account/models/plan.py | 6 +-- account/models/user.py | 24 ++++-------- account/routes/addon.py | 9 ++--- account/routes/admin/__init__.py | 3 +- account/routes/admin/token.py | 4 +- account/routes/auth.py | 1 - account/routes/mail.py | 3 +- account/routes/notification.py | 4 +- account/routes/plan.py | 4 +- account/routes/register.py | 2 +- account/routes/stripe.py | 13 ++----- account/routes/token.py | 18 +++------ account/routes/user.py | 20 +++++----- account/util/current_user.py | 2 +- account/util/mail.py | 14 +++---- account/util/mailing.py | 13 ++++--- account/util/recaptcha.py | 4 +- account/util/stripe.py | 57 +++++++++-------------------- account/util/token.py | 2 +- main.py | 2 - pyproject.toml | 54 +++++++++++++++++++++------ tests/conftest.py | 15 +++----- tests/data.py | 5 +-- tests/util.py | 8 +--- tests/views/test_notification.py | 2 +- tests/views/test_register.py | 11 +++--- tests/views/test_token.py | 2 +- util/add_token.py | 3 +- util/add_usage.py | 1 + util/change_plan.py | 18 +++++---- util/gen_salt.py | 1 + util/loader.py | 4 +- util/validate_email.py | 11 ++---- 40 files changed, 187 insertions(+), 224 deletions(-) rename account/{__init_.py => __init__.py} (100%) diff --git a/README.md b/README.md index eb88d2c..3971bc1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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" @@ -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" \ No newline at end of file +[hatch]: https://hatch.pypa.io/latest/ "Hatch project and tooling manager" \ No newline at end of file diff --git a/account/__init_.py b/account/__init__.py similarity index 100% rename from account/__init_.py rename to account/__init__.py diff --git a/account/app.py b/account/app.py index 8edbf34..b4a5d14 100644 --- a/account/app.py +++ b/account/app.py @@ -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 @@ -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") diff --git a/account/jwt.py b/account/jwt.py index 47bf5f0..dd378fb 100644 --- a/account/jwt.py +++ b/account/jwt.py @@ -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 @@ -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"]) diff --git a/account/main.py b/account/main.py index a6ce4ba..7fb7f40 100644 --- a/account/main.py +++ b/account/main.py @@ -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) diff --git a/account/models/addon.py b/account/models/addon.py index d39db8a..5781e47 100644 --- a/account/models/addon.py +++ b/account/models/addon.py @@ -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, diff --git a/account/models/helpers.py b/account/models/helpers.py index 062f4ad..884f85f 100644 --- a/account/models/helpers.py +++ b/account/models/helpers.py @@ -3,7 +3,8 @@ 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 @@ -11,6 +12,8 @@ class ObjectIdStr(str): """Represents an ObjectId not managed by Beanie.""" + __slots__ = () + @classmethod def __get_validators__(cls) -> Iterator[Callable[[Any, Any], str]]: yield cls.validate @@ -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 diff --git a/account/models/plan.py b/account/models/plan.py index 4afe4ac..63265ea 100644 --- a/account/models/plan.py +++ b/account/models/plan.py @@ -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): @@ -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) diff --git a/account/models/user.py b/account/models/user.py index 1bc8181..0d701ab 100644 --- a/account/models/user.py +++ b/account/models/user.py @@ -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 @@ -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") @@ -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: @@ -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() diff --git a/account/routes/addon.py b/account/routes/addon.py index bbe9d2c..6e277cc 100644 --- a/account/routes/addon.py +++ b/account/routes/addon.py @@ -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, ) @@ -21,9 +21,7 @@ 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: @@ -31,7 +29,8 @@ async def new_addon( # type: ignore[no-untyped-def] 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) diff --git a/account/routes/admin/__init__.py b/account/routes/admin/__init__.py index 1cc85b8..b47301e 100644 --- a/account/routes/admin/__init__.py +++ b/account/routes/admin/__init__.py @@ -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) diff --git a/account/routes/admin/token.py b/account/routes/admin/token.py index 81565a6..4ebcd4b 100644 --- a/account/routes/admin/token.py +++ b/account/routes/admin/token.py @@ -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) diff --git a/account/routes/auth.py b/account/routes/auth.py index 6f10fa9..e7625ed 100644 --- a/account/routes/auth.py +++ b/account/routes/auth.py @@ -8,7 +8,6 @@ from account.models.user import User, UserAuth from account.util.password import hash_password - router = APIRouter(prefix="/auth", tags=["Auth"]) diff --git a/account/routes/mail.py b/account/routes/mail.py index f862781..abe7412 100644 --- a/account/routes/mail.py +++ b/account/routes/mail.py @@ -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"]) diff --git a/account/routes/notification.py b/account/routes/notification.py index e3855c4..8b101e3 100644 --- a/account/routes/notification.py +++ b/account/routes/notification.py @@ -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: diff --git a/account/routes/plan.py b/account/routes/plan.py index 105b86f..e9ed67a 100644 --- a/account/routes/plan.py +++ b/account/routes/plan.py @@ -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"]) @@ -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.""" diff --git a/account/routes/register.py b/account/routes/register.py index b79a3de..594aa02 100644 --- a/account/routes/register.py +++ b/account/routes/register.py @@ -3,8 +3,8 @@ from fastapi import APIRouter, Body, HTTPException, Response from pydantic import EmailStr -from account.models.user import User, UserRegister, UserOut from account.jwt import access_security, user_from_token +from account.models.user import User, UserOut, UserRegister from account.util.mail import send_password_reset_email from account.util.password import hash_password from account.util.recaptcha import verify diff --git a/account/routes/stripe.py b/account/routes/stripe.py index 041c329..a07905e 100644 --- a/account/routes/stripe.py +++ b/account/routes/stripe.py @@ -15,25 +15,20 @@ new_subscription, ) - router = APIRouter(prefix="/stripe", tags=["Stripe"]) @router.get("/success", response_model=UserOut) async def stripe_success(user: User = Depends(current_user)) -> User: """Add success notification after sign-up.""" - await user.add_notification( - "success", "Your sign-up was successful. Thank you for supporting AVWX!" - ) + await user.add_notification("success", "Your sign-up was successful. Thank you for supporting AVWX!") return user @router.get("/cancel", response_model=UserOut) async def stripe_cancel(user: User = Depends(current_user)) -> User: """Add cancelled notification after sign-up.""" - await user.add_notification( - "info", "It looks like you cancelled sign-up. No changes have been made" - ) + await user.add_notification("info", "It looks like you cancelled sign-up. No changes have been made") return user @@ -53,9 +48,7 @@ async def stripe_cancel(user: User = Depends(current_user)) -> User: @router.post("/fulfill") -async def stripe_fulfill( - request: Request, stripe_signature: str = Header(None) -) -> Response: +async def stripe_fulfill(request: Request, stripe_signature: str = Header(None)) -> Response: """Stripe event handler.""" try: event = get_event(await request.body(), stripe_signature) diff --git a/account/routes/token.py b/account/routes/token.py index f588a1a..7c1ea4e 100644 --- a/account/routes/token.py +++ b/account/routes/token.py @@ -1,6 +1,6 @@ """Token management router.""" -from datetime import datetime, timedelta, UTC +from datetime import UTC, datetime, timedelta from bson.objectid import ObjectId from fastapi import APIRouter, Depends, HTTPException, Response @@ -35,9 +35,7 @@ async def new_token(user: User = Depends(current_user)) -> UserToken: @router.get("/history") -async def get_all_history( - days: int = 30, user: User = Depends(current_user) -) -> list[AllTokenUsageOut]: +async def get_all_history(days: int = 30, user: User = Depends(current_user)) -> list[AllTokenUsageOut]: """Return all recent token history.""" return await token_usage_for(user, days) @@ -52,9 +50,7 @@ async def get_token(value: str, user: User = Depends(current_user)) -> UserToken @router.patch("/{value}", response_model=Token) -async def update_token( - value: str, update: TokenUpdate, user: User = Depends(current_user) -) -> UserToken: +async def update_token(value: str, update: TokenUpdate, user: User = Depends(current_user)) -> UserToken: """Update token details by string value.""" i, token = user.get_token(value) if token is None: @@ -88,14 +84,10 @@ async def refresh_token(value: str, user: User = Depends(current_user)) -> UserT @router.get("/{value}/history", response_model=list[TokenUsageOut]) -async def get_token_history( - value: str, days: int = 30, user: User = Depends(current_user) -) -> list[TokenUsage]: +async def get_token_history(value: str, days: int = 30, user: User = Depends(current_user)) -> list[TokenUsage]: """Return a token's usage history.""" _, token = user.get_token(value) if token is None: raise HTTPException(404, f"Token with value {value} does not exist") days_since = datetime.now(tz=UTC) - timedelta(days=days) - return await TokenUsage.find( - TokenUsage.token_id == ObjectId(token.id), TokenUsage.date >= days_since - ).to_list() + return await TokenUsage.find(TokenUsage.token_id == ObjectId(token.id), TokenUsage.date >= days_since).to_list() diff --git a/account/routes/user.py b/account/routes/user.py index b39ce9c..b5871d2 100644 --- a/account/routes/user.py +++ b/account/routes/user.py @@ -3,8 +3,8 @@ from fastapi import APIRouter, Depends, HTTPException, Response, Security from fastapi_jwt import JwtAuthorizationCredentials -from account.models.user import User, UserOut, UserUpdate from account.jwt import access_security +from account.models.user import User, UserOut, UserUpdate from account.util.current_user import current_user from account.util.mail import send_email_change from account.util.mailing import update_mailing @@ -23,15 +23,15 @@ async def get_user(user: User = Depends(current_user)) -> User: async def update_user(update: UserUpdate, user: User = Depends(current_user)) -> User: """Update allowed user fields.""" fields = update.model_dump(exclude_unset=True) - if new_email := fields.pop("email", None): - if new_email != user.email: - if await User.by_email(new_email) is not None: - raise HTTPException(400, "Email already exists") - if user.subscribed: - await update_mailing(user.email, new_email) - update_stripe_email(user, new_email) - await send_email_change(user.email, new_email) - user.update_email(new_email) + new_email = fields.pop("email", None) + if new_email and new_email != user.email: + if await User.by_email(new_email) is not None: + raise HTTPException(400, "Email already exists") + if user.subscribed: + await update_mailing(user.email, new_email) + update_stripe_email(user, new_email) + await send_email_change(user.email, new_email) + user.update_email(new_email) user = user.model_copy(update=fields) await user.save() return user diff --git a/account/util/current_user.py b/account/util/current_user.py index 17864e5..ccd4391 100644 --- a/account/util/current_user.py +++ b/account/util/current_user.py @@ -3,8 +3,8 @@ from fastapi import Body, HTTPException, Security from fastapi_jwt import JwtAuthorizationCredentials -from account.models.user import User from account.jwt import access_security, user_from_credentials +from account.models.user import User async def current_user( diff --git a/account/util/mail.py b/account/util/mail.py index 2b81800..0e2857e 100644 --- a/account/util/mail.py +++ b/account/util/mail.py @@ -1,6 +1,6 @@ """Mail server config.""" -from fastapi_mail import FastMail, ConnectionConfig, MessageSchema, MessageType +from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType from account.config import CONFIG @@ -52,15 +52,14 @@ ACCOUNT_ENABLE = "Just letting you know that your account has been re-enabled and your API tokens activated. No further action is required." CHANGE_EMAIL_OLD = "Your AVWX account email has been changed to {}. If you did not make this change, please contact avwx@dupont.dev immediately." -CHANGE_EMAIL_NEW = ( - "Your AVWX account email has been changed. No further action is needed." -) +CHANGE_EMAIL_NEW = "Your AVWX account email has been changed. No further action is needed." async def _send(email: str, title: str, msg: str) -> None: """Send to email or print to console.""" if CONFIG.mail_console: - return print(msg) + print(msg) + return message = MessageSchema( recipients=[email], subject=title, @@ -68,6 +67,7 @@ async def _send(email: str, title: str, msg: str) -> None: subtype=MessageType.plain, ) await mail.send_message(message) + return None async def send_verification_email(email: str, token: str) -> None: @@ -84,9 +84,7 @@ async def send_password_reset_email(email: str, token: str) -> None: await _send(email, "AVWX Password Reset", RESET_TEMPLATE.format(url)) -async def send_disable_email( - email: str, portal_url: str, warning: bool = False -) -> None: +async def send_disable_email(email: str, portal_url: str, *, warning: bool = False) -> None: """Send missed payment email with portal link.""" title = "AVWX Account " if warning: diff --git a/account/util/mailing.py b/account/util/mailing.py index 7d46d96..8420168 100644 --- a/account/util/mailing.py +++ b/account/util/mailing.py @@ -3,9 +3,9 @@ import hashlib import rollbar +from kewkew import Kew from mailchimp3 import MailChimp from mailchimp3.mailchimpclient import MailChimpError -from kewkew import Kew from account.config import CONFIG from account.models.user import User @@ -33,6 +33,9 @@ async def worker(self, data: tuple[User, bool]) -> bool: chimp = MailChimp(mc_api=CONFIG.mc_key, mc_user=CONFIG.mc_username) +NOT_FOUND = 404 + + async def add_to_mailing(user: User) -> None: """Add an email to the mailing list.""" if not CONFIG.testing: @@ -67,11 +70,11 @@ async def remove_from_mailing(user: User) -> None: def _remove_from_mailing(email: str) -> bool: try: - target = hashlib.md5(email.encode("utf-8")).hexdigest() + target = hashlib.md5(email.encode("utf-8")).hexdigest() # noqa S324 chimp.lists.members.delete(CONFIG.mc_list_id, target) except MailChimpError as exc: data = dict(exc.args[0]) - if data.get("status") != 404: + if data.get("status") != NOT_FOUND: rollbar.report_message(data) return True @@ -84,7 +87,7 @@ async def update_mailing(old: str, new: str) -> None: def _update_mailing(old: str, new: str) -> None: try: - target = hashlib.md5(old.encode("utf-8")).hexdigest() + target = hashlib.md5(old.encode("utf-8")).hexdigest() # noqa S324 chimp.lists.members.update( CONFIG.mc_list_id, target, @@ -92,5 +95,5 @@ def _update_mailing(old: str, new: str) -> None: ) except MailChimpError as exc: data = dict(exc.args[0]) - if data.get("status") != 404: + if data.get("status") != NOT_FOUND: rollbar.report_message(data) diff --git a/account/util/recaptcha.py b/account/util/recaptcha.py index a325dca..9822156 100644 --- a/account/util/recaptcha.py +++ b/account/util/recaptcha.py @@ -1,8 +1,8 @@ """reCaptcha verification.""" from httpx import AsyncClient -from account.config import CONFIG +from account.config import CONFIG THRESHOLD = 0.6 TIMEOUT = 10 @@ -21,7 +21,7 @@ async def verify(token: str) -> bool: "secret": CONFIG.recaptcha_secret_key, }, ) - data = resp.json() + data: dict = resp.json() if data.get("success") is not True: return False score = data.get("score") diff --git a/account/util/stripe.py b/account/util/stripe.py index c8150c9..33aafd9 100644 --- a/account/util/stripe.py +++ b/account/util/stripe.py @@ -9,15 +9,12 @@ from account.models.user import Stripe, User, UserToken from account.util import mail - stripe.api_key = CONFIG.stripe_secret_key _SUCCESS_URL = f"{CONFIG.root_url}/stripe/success" _CANCEL_URL = f"{CONFIG.root_url}/stripe/cancel" -def get_session( - user: User, price_id: str, metered: bool = False -) -> stripe.checkout.Session: +def get_session(user: User, price_id: str, *, metered: bool = False) -> stripe.checkout.Session: """Create a Stripe Session object to start a Checkout.""" if metered: item = stripe.checkout.Session.CreateParamsLineItem(price=price_id) @@ -53,7 +50,8 @@ def get_event(payload: str | bytes, sig: str) -> Event: def get_portal_session(user: User) -> stripe.billing_portal.Session: """Create a Stripe billing portal session.""" if user.stripe is None: - raise ValueError("Cannot create billing session without stripe info") + msg = "Cannot create billing session without stripe info" + raise ValueError(msg) return stripe.billing_portal.Session.create( customer=user.stripe.customer_id, return_url=f"{CONFIG.root_url}/plans", @@ -63,7 +61,8 @@ def get_portal_session(user: User) -> stripe.billing_portal.Session: def get_subscription(session: stripe.checkout.Session) -> Subscription: """Load Stripe subscription from checkout session.""" if not session.subscription: - raise ValueError("No subscription found after checkout session.") + msg = "No subscription found after checkout session." + raise ValueError(msg) if isinstance(session.subscription, Subscription): return session.subscription return Subscription.retrieve(session.subscription) @@ -72,10 +71,9 @@ def get_subscription(session: stripe.checkout.Session) -> Subscription: def get_customer_id(session: stripe.checkout.Session | Invoice) -> str: """Load customer ID from Stripe objects.""" if not session.customer: - raise ValueError("No customer ID found after checkout session.") - return ( - session.customer if isinstance(session.customer, str) else session.customer.id - ) + msg = "No customer ID found after checkout session." + raise ValueError(msg) + return session.customer if isinstance(session.customer, str) else session.customer.id async def new_subscription(session: stripe.checkout.Session) -> bool: @@ -90,9 +88,7 @@ async def new_subscription(session: stripe.checkout.Session) -> bool: user.plan = plan token = await UserToken.new(type="dev") user.tokens.append(token) - elif addon := await Addon.by_product_id( - price.product if isinstance(price.product, str) else price.product.id - ): + elif addon := await Addon.by_product_id(price.product if isinstance(price.product, str) else price.product.id): user.addons.append(addon.to_user(user.plan.key)) else: return False @@ -111,23 +107,18 @@ async def change_subscription(user: User, plan: Plan) -> bool: sub = Subscription.retrieve(sub_id) # Update existing subscription items for item in sub["items"].data: - addon_id = ( - item.price.product - if isinstance(item.price.product, str) - else item.price.product.id - ) + addon_id = item.price.product if isinstance(item.price.product, str) else item.price.product.id if addon := await Addon.by_product_id(addon_id): user_addon = addon.to_user(plan.key) if user_addon.price_id != item.price.id: user.replace_addon(user_addon) - items.append( - Subscription.ModifyParamsItem(id=item.id, price=user_addon.price_id) - ) + items.append(Subscription.ModifyParamsItem(id=item.id, price=user_addon.price_id)) elif plan.stripe_id: # This updates an existing paid plan items.append(Subscription.ModifyParamsItem(id=item.id, plan=plan.stripe_id)) else: - raise ValueError("Unable to find a stripe product ID to modify") + msg = "Unable to find a stripe product ID to modify" + raise ValueError(msg) sub.modify( sub_id, cancel_at_period_end=False, @@ -142,7 +133,7 @@ async def change_subscription(user: User, plan: Plan) -> bool: return True -async def cancel_subscription(user: User, keep_addons: bool = False) -> bool: +async def cancel_subscription(user: User, *, keep_addons: bool = False) -> bool: """Cancel a subscription.""" if user.stripe is None or user.plan is None: return False @@ -160,34 +151,22 @@ async def cancel_subscription(user: User, keep_addons: bool = False) -> bool: def add_to_subscription(user: User, price_id: str) -> bool: """Add an addon to an existing subscription.""" - if ( - not user.has_subscription - or user.stripe is None - or user.stripe.subscription_id is None - ): + if not user.has_subscription or user.stripe is None or user.stripe.subscription_id is None: return False - stripe.SubscriptionItem.create( - subscription=user.stripe.subscription_id, price=price_id - ) + stripe.SubscriptionItem.create(subscription=user.stripe.subscription_id, price=price_id) return True def remove_from_subscription(user: User, price_id: str | None = None) -> bool: """Remove an addon from a subscription.""" - if ( - not user.has_subscription - or user.stripe is None - or user.stripe.subscription_id is None - ): + if not user.has_subscription or user.stripe is None or user.stripe.subscription_id is None: return False sub = Subscription.retrieve(user.stripe.subscription_id) for item in sub["items"].data: if item.price.id == price_id: if len(sub["items"].data) != 1: return ( - stripe.SubscriptionItem.delete( - item.id, clear_usage=item.plan.usage_type == "metered" - ).deleted + stripe.SubscriptionItem.delete(item.id, clear_usage=item.plan.usage_type == "metered").deleted is True ) # If nothing left in subscription diff --git a/account/util/token.py b/account/util/token.py index a543372..c5ba3cc 100644 --- a/account/util/token.py +++ b/account/util/token.py @@ -1,6 +1,6 @@ """Shared token utilities.""" -from datetime import datetime, timedelta, UTC +from datetime import UTC, datetime, timedelta from bson.objectid import ObjectId diff --git a/main.py b/main.py index c056767..577823a 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,6 @@ import logfire from opentelemetry.instrumentation.pymongo import PymongoInstrumentor - IGNORE = {"account.config", "account.util.mail"} logfire.configure(pydantic_plugin=logfire.PydanticPlugin(record="all", exclude=IGNORE)) @@ -12,5 +11,4 @@ from account.main import app # noqa: E402 - logfire.instrument_fastapi(app) diff --git a/pyproject.toml b/pyproject.toml index 247f395..64755ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,15 +23,48 @@ dependencies = [ "uvicorn==0.29.0", ] -[project.optional-dependencies] -dev = ["mypy", "ruff"] -test = [ - "asgi-lifespan~=2.1", - "pytest-asyncio~=0.23", +[project.urls] +Issues = "https://github.com/avwx-rest/account-backend/issues" +Source = "https://github.com/avwx-rest/account-backend" + +[tool.hatch.envs.types] +extra-dependencies = [ + "mypy>=1.0.0" ] +[tool.hatch.envs.types.scripts] +check = "mypy --install-types --non-interactive {args:account tests}" -[project.urls] -"Source" = "https://github.com/avwx-rest/account-backend" +[tool.hatch.envs.hatch-test] +extra-dependencies = [ + "asgi-lifespan>=2.1", + "pytest-asyncio>=0.23", + "pytest-cov", +] + +[tool.hatch.envs.serve.scripts] +main = "uvicorn account.main:app --port {args:8080}" +reload = "uvicorn account.main:app --reload --port {args:8080}" + +[tool.pytest.ini_options] +addopts = """\ + --cov account \ + --cov tests \ + --cov-report term-missing \ + --no-cov-on-fail \ +""" + +[tool.coverage.run] +source_pkgs = ["account", "tests"] +branch = true +parallel = true + +[tool.coverage.report] +fail_under = 65 # increase over time +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] [tool.mypy] check_untyped_defs = true @@ -47,12 +80,11 @@ warn_return_any = true warn_unused_ignores = true [tool.ruff] -lint.extend-select = [ - "UP", - "D", -] lint.ignore = [ "D105", "D203", "D213", + "B008", # Depends in func def + "T201", + "INP001", ] \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index aecb198..ebc7bb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,11 +6,10 @@ from asgi_lifespan import LifespanManager from decouple import config from fastapi import FastAPI -from httpx import AsyncClient, ASGITransport +from httpx import ASGITransport, AsyncClient from account.config import CONFIG - # Override config settings before loading the app CONFIG.testing = True CONFIG.mongo_uri = config("TEST_MONGO_URI", default="mongodb://localhost:27017") @@ -21,20 +20,16 @@ async def clear_database(server: FastAPI) -> None: """Empty the test database.""" - async for collection in await server.state.db.list_collections(): # type: ignore[attr-defined] - await server.state.db[collection["name"]].delete_many({}) # type: ignore[attr-defined] + async for collection in await server.state.db.list_collections(): + await server.state.db[collection["name"]].delete_many({}) @pytest_asyncio.fixture() async def client() -> AsyncIterator[AsyncClient]: """Async server client that handles lifespan and teardown.""" - async with LifespanManager(app): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as _client: + async with LifespanManager(app): # noqa SIM117 + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as _client: # type: ignore try: yield _client - except Exception as exc: - print(exc) finally: await clear_database(app) diff --git a/tests/data.py b/tests/data.py index d46208a..f441b8e 100644 --- a/tests/data.py +++ b/tests/data.py @@ -3,7 +3,7 @@ import asyncio as aio import json import random -from datetime import datetime, timedelta, UTC +from datetime import UTC, datetime, timedelta from pathlib import Path from account.models.plan import Plan @@ -11,7 +11,6 @@ from account.models.user import Notification, User, UserToken from account.util.password import hash_password - DATA = Path(__file__).parent / "data" PLANS = json.load(DATA.joinpath("plans.json").open()) @@ -56,7 +55,7 @@ async def add_empty_user() -> str: return user.email -async def add_token_user(history: bool = False) -> str: +async def add_token_user(*, history: bool = False) -> str: """Add user with an app token to user collection.""" user = make_user("token@test.io", offset=7) token = await UserToken.new() diff --git a/tests/util.py b/tests/util.py index 79fbc24..916ff8b 100644 --- a/tests/util.py +++ b/tests/util.py @@ -10,18 +10,14 @@ def auth_header_token(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} -async def auth_payload( - client: AsyncClient, email: str, password: str | None = None -) -> RefreshToken: +async def auth_payload(client: AsyncClient, email: str, password: str | None = None) -> RefreshToken: """Return the login auth payload for an email.""" data = {"email": email, "password": password or email} resp = await client.post("/auth/login", json=data) return RefreshToken(**resp.json()) -async def auth_headers( - client: AsyncClient, email: str, password: str | None = None -) -> dict[str, str]: +async def auth_headers(client: AsyncClient, email: str, password: str | None = None) -> dict[str, str]: """Return the authorization headers for an email.""" auth = await auth_payload(client, email, password) return auth_header_token(auth.access_token) diff --git a/tests/views/test_notification.py b/tests/views/test_notification.py index 5981f3b..35b5db1 100644 --- a/tests/views/test_notification.py +++ b/tests/views/test_notification.py @@ -17,7 +17,7 @@ async def test_get_notifications(client: AsyncClient) -> None: assert resp.status_code == 200 notifications = resp.json() assert len(notifications) == len(text) - for value, notification in zip(text, notifications): + for value, notification in zip(text, notifications, strict=True): assert notification["type"] == "app" assert notification["text"] == value assert "timestamp" in notification diff --git a/tests/views/test_register.py b/tests/views/test_register.py index 0bd120d..f508525 100644 --- a/tests/views/test_register.py +++ b/tests/views/test_register.py @@ -4,11 +4,10 @@ from httpx import AsyncClient from account.models.user import User - from tests.data import add_empty_user, add_plans -async def assert_user_count(client: AsyncClient, count: int) -> None: +async def assert_user_count(count: int) -> None: """Assert the number of user documents matches the expected count.""" assert count == await User.count() @@ -17,7 +16,7 @@ async def assert_user_count(client: AsyncClient, count: int) -> None: async def test_new_user(client: AsyncClient) -> None: """Test registering a new user.""" await add_plans("free") - await assert_user_count(client, 0) + await assert_user_count(0) email, password = "new@test.io", "testing1" auth = {"email": email, "password": password, "token": "test"} resp = await client.post("/register", json=auth) @@ -28,7 +27,7 @@ async def test_new_user(client: AsyncClient) -> None: assert user["plan"]["key"] == "free" assert len(user["tokens"]) == 0 assert len(user["addons"]) == 0 - await assert_user_count(client, 1) + await assert_user_count(1) db_user = await User.by_email(email) assert db_user is not None assert db_user.password != password @@ -39,11 +38,11 @@ async def test_new_user(client: AsyncClient) -> None: async def test_existing_user(client: AsyncClient) -> None: """Test registering an existing user errors.""" email = await add_empty_user() - await assert_user_count(client, 1) + await assert_user_count(1) auth = {"email": email, "password": "testing1", "token": "test"} resp = await client.post("/register", json=auth) assert resp.status_code == 409 - await assert_user_count(client, 1) + await assert_user_count(1) @pytest.mark.asyncio diff --git a/tests/views/test_token.py b/tests/views/test_token.py index 1581083..57a40f9 100644 --- a/tests/views/test_token.py +++ b/tests/views/test_token.py @@ -9,7 +9,7 @@ from tests.util import auth_headers -def assert_app_token(token: dict, name: str = "Token", active: bool = True) -> None: +def assert_app_token(token: dict, name: str = "Token", *, active: bool = True) -> None: """Check for default token values.""" assert token["name"] == name assert token["type"] == "app" diff --git a/util/add_token.py b/util/add_token.py index a183efd..d7f9ed9 100644 --- a/util/add_token.py +++ b/util/add_token.py @@ -1,9 +1,10 @@ """Create a new API token for a user.""" import asyncio as aio -import typer +import typer from loader import load_models + from account.models.user import Plan, User, UserToken diff --git a/util/add_usage.py b/util/add_usage.py index a045a0f..133ed1e 100644 --- a/util/add_usage.py +++ b/util/add_usage.py @@ -3,6 +3,7 @@ import asyncio as aio from loader import load_models + from account.models.addon import Addon from account.models.plan import Plan from account.models.token import TokenUsage diff --git a/util/change_plan.py b/util/change_plan.py index 23ab404..12920c2 100644 --- a/util/change_plan.py +++ b/util/change_plan.py @@ -1,22 +1,24 @@ """Update a user's plan information.""" import asyncio as aio -import typer +import typer from loader import load_models + from account.models.addon import Addon from account.models.user import Plan, User -from account.util.stripe import change_subscription, cancel_subscription +from account.util.stripe import cancel_subscription, change_subscription -async def _change_plan(user: User, plan: Plan, remove_addons: bool) -> int: +async def _change_plan(user: User, plan: Plan, *, remove_addons: bool) -> int: if user.plan and user.plan.key == plan.key: print(f"User is already subscribed to the {plan.name} plan") return 2 if plan.stripe_id: print("Move to paid") if not user.has_subscription: - raise NotImplementedError("Free to paid flow requires Dashboard access") + msg = "Free to paid flow requires Dashboard access" + raise NotImplementedError(msg) if not await change_subscription(user, plan): print("Unable to update your subscription") return 3 @@ -26,7 +28,7 @@ async def _change_plan(user: User, plan: Plan, remove_addons: bool) -> int: return 0 -async def main(email: str, plan: str, remove_addons: bool) -> int: +async def main(email: str, plan: str, *, remove_addons: bool) -> int: """Update a user's plan information.""" await load_models(Addon, Plan, User) @@ -40,12 +42,12 @@ async def main(email: str, plan: str, remove_addons: bool) -> int: print(f"Plan with key {plan} does not exist") return 1 - return await _change_plan(user, plan_obj, remove_addons) + return await _change_plan(user, plan_obj, remove_addons=remove_addons) -def change_plan(email: str, plan: str, remove_addons: bool = True) -> int: +def change_plan(email: str, plan: str, remove_addons: bool = True) -> int: # noqa FBT001 """Change a user's plan details.""" - return aio.run(main(email, plan, remove_addons)) + return aio.run(main(email, plan, remove_addons=remove_addons)) if __name__ == "__main__": diff --git a/util/gen_salt.py b/util/gen_salt.py index b46d077..7603d02 100644 --- a/util/gen_salt.py +++ b/util/gen_salt.py @@ -1,6 +1,7 @@ """Generate a new bcrypt password salt and updates to local .env file.""" from pathlib import Path + import bcrypt path = Path.cwd() / ".env" diff --git a/util/loader.py b/util/loader.py index 9a998d1..c12d975 100644 --- a/util/loader.py +++ b/util/loader.py @@ -5,13 +5,13 @@ from pathlib import Path # library -from beanie import init_beanie, Document +from beanie import Document, init_beanie from motor.motor_asyncio import AsyncIOMotorClient sys.path.insert(0, str(Path(__file__).parent.parent.absolute())) # module -from account.config import CONFIG # noqa: E402 +from account.config import CONFIG async def load_models(*model: Document) -> None: diff --git a/util/validate_email.py b/util/validate_email.py index 3d9b4dd..0abfebb 100644 --- a/util/validate_email.py +++ b/util/validate_email.py @@ -1,9 +1,10 @@ """Verify a user's email.""" import asyncio as aio -import typer +import typer from loader import load_models + from account.models.user import Plan, User @@ -14,12 +15,8 @@ async def main(email: str) -> int: if not user: print(f"User with email {email} does not exist") return 1 - try: - user.validate_email() - await user.save() - except Exception as exc: - print(exc) - return 2 + user.validate_email() + await user.save() print(f"{email} has been verified") return 0