From 31a4254eaa811647d0b210a89f0457c93880b5ac Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Tue, 18 Feb 2025 20:34:56 -0500 Subject: [PATCH 01/17] Partial refactor --- main.py | 188 ++++++------------------------------ routers/about.py | 18 ++++ routers/authentication.py | 71 +++++++++++++- routers/dashboard.py | 22 +++++ routers/organization.py | 25 ++++- routers/privacy_policy.py | 18 ++++ routers/role.py | 2 +- routers/terms_of_service.py | 18 ++++ routers/user.py | 37 +++++-- templates/base.html | 7 -- tests/test_main.py | 21 ---- tests/test_user.py | 17 ++++ utils/auth.py | 2 +- utils/db.py | 1 - 14 files changed, 246 insertions(+), 201 deletions(-) create mode 100644 routers/about.py create mode 100644 routers/dashboard.py create mode 100644 routers/privacy_policy.py create mode 100644 routers/terms_of_service.py diff --git a/main.py b/main.py index 78211cf..019b1d1 100644 --- a/main.py +++ b/main.py @@ -5,21 +5,16 @@ from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.exceptions import RequestValidationError, HTTPException, StarletteHTTPException -from sqlmodel import Session -from routers import authentication, organization, role, user +from fastapi.exceptions import RequestValidationError, StarletteHTTPException +from routers import authentication, organization, role, user, dashboard, terms_of_service, privacy_policy, about from utils.auth import ( - HTML_PASSWORD_PATTERN, - get_user_with_relations, - get_optional_user, NeedsNewTokens, - get_user_from_reset_token, PasswordValidationError, - AuthenticationError + AuthenticationError, + get_optional_user ) +from utils.db import set_up_db from utils.models import User -from utils.db import get_session, set_up_db -from utils.images import MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES logger = logging.getLogger("uvicorn.error") logger.setLevel(logging.DEBUG) @@ -33,15 +28,27 @@ async def lifespan(app: FastAPI): # Optional shutdown logic +# Initialize the FastAPI app app: FastAPI = FastAPI(lifespan=lifespan) -# Mount static files (e.g., CSS, JS) +# Mount static files (e.g., CSS, JS) and initialize Jinja2 templates app.mount("/static", StaticFiles(directory="static"), name="static") - -# Initialize Jinja2 templates templates = Jinja2Templates(directory="templates") +# --- Include Routers --- + + +app.include_router(authentication.router) +app.include_router(organization.router) +app.include_router(role.router) +app.include_router(user.router) +app.include_router(dashboard.router) +app.include_router(terms_of_service.router) +app.include_router(privacy_policy.router) +app.include_router(about.router) + + # --- Exception Handling Middlewares --- @@ -49,7 +56,7 @@ async def lifespan(app: FastAPI): @app.exception_handler(AuthenticationError) async def authentication_error_handler(request: Request, exc: AuthenticationError): return RedirectResponse( - url="/login", + url=app.url_path_for("read_login"), status_code=status.HTTP_303_SEE_OTHER ) @@ -146,160 +153,21 @@ async def general_exception_handler(request: Request, exc: Exception): ) -# --- Unauthenticated Routes --- - - -# Define a dependency for common parameters -async def common_unauthenticated_parameters( - request: Request, - user: Optional[User] = Depends(get_optional_user), - error_message: Optional[str] = None, -) -> dict: - return {"request": request, "user": user, "error_message": error_message} +# --- Home Page --- @app.get("/") async def read_home( - params: dict = Depends(common_unauthenticated_parameters) -): - if params["user"]: - return RedirectResponse(url="/dashboard", status_code=302) - return templates.TemplateResponse(params["request"], "index.html", params) - - -@app.get("/login") -async def read_login( - params: dict = Depends(common_unauthenticated_parameters), - email_updated: Optional[str] = "false" -): - if params["user"]: - return RedirectResponse(url="/dashboard", status_code=302) - params["email_updated"] = email_updated - return templates.TemplateResponse(params["request"], "authentication/login.html", params) - - -@app.get("/register") -async def read_register( - params: dict = Depends(common_unauthenticated_parameters) -): - if params["user"]: - return RedirectResponse(url="/dashboard", status_code=302) - - params["password_pattern"] = HTML_PASSWORD_PATTERN - return templates.TemplateResponse(params["request"], "authentication/register.html", params) - - -@app.get("/forgot_password") -async def read_forgot_password( - params: dict = Depends(common_unauthenticated_parameters), - show_form: Optional[str] = "true", -): - params["show_form"] = show_form == "true" - - return templates.TemplateResponse(params["request"], "authentication/forgot_password.html", params) - - -@app.get("/about") -async def read_about(params: dict = Depends(common_unauthenticated_parameters)): - return templates.TemplateResponse(params["request"], "about.html", params) - - -@app.get("/privacy_policy") -async def read_privacy_policy(params: dict = Depends(common_unauthenticated_parameters)): - return templates.TemplateResponse(params["request"], "privacy_policy.html", params) - - -@app.get("/terms_of_service") -async def read_terms_of_service(params: dict = Depends(common_unauthenticated_parameters)): - return templates.TemplateResponse(params["request"], "terms_of_service.html", params) - - -@app.get("/auth/reset_password") -async def read_reset_password( - email: str, - token: str, - params: dict = Depends(common_unauthenticated_parameters), - session: Session = Depends(get_session) -): - authorized_user, _ = get_user_from_reset_token(email, token, session) - - # Raise informative error to let user know the token is invalid and may have expired - if not authorized_user: - raise HTTPException(status_code=400, detail="Invalid or expired token") - - params["email"] = email - params["token"] = token - params["password_pattern"] = HTML_PASSWORD_PATTERN - - return templates.TemplateResponse(params["request"], "authentication/reset_password.html", params) - - -# --- Authenticated Routes --- - - -# Define a dependency for common parameters -async def common_authenticated_parameters( request: Request, - user: User = Depends(get_user_with_relations), - error_message: Optional[str] = None -) -> dict: - return {"request": request, "user": user, "error_message": error_message} - - -# Redirect to home if user is not authenticated -@app.get("/dashboard") -async def read_dashboard( - params: dict = Depends(common_authenticated_parameters) + user: Optional[User] = Depends(get_optional_user) ): - return templates.TemplateResponse(params["request"], "dashboard/index.html", params) - - -@app.get("/profile") -async def read_profile( - params: dict = Depends(common_authenticated_parameters), - email_update_requested: Optional[str] = "false", - email_updated: Optional[str] = "false" -): - # Add image constraints to the template context - params.update({ - "max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024), # Convert bytes to MB - "min_dimension": MIN_DIMENSION, - "max_dimension": MAX_DIMENSION, - "allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()), - "email_update_requested": email_update_requested, - "email_updated": email_updated - }) - return templates.TemplateResponse(params["request"], "users/profile.html", params) - - -@app.get("/organizations/{org_id}") -async def read_organization( - org_id: int, - params: dict = Depends(common_authenticated_parameters) -): - # Get the organization only if the user is a member of it - org = next( - (org for org in params["user"].organizations if org.id == org_id), - None + if user: + return RedirectResponse(url="/dashboard", status_code=302) + return templates.TemplateResponse( + "index.html", + {"request": request, "user": user} ) - if not org: - raise organization.OrganizationNotFoundError() - - # Eagerly load roles and users - org.roles - org.users - params["organization"] = org - - return templates.TemplateResponse(params["request"], "users/organization.html", params) - -# --- Include Routers --- - - -app.include_router(authentication.router) -app.include_router(organization.router) -app.include_router(role.router) -app.include_router(user.router) if __name__ == "__main__": import uvicorn diff --git a/routers/about.py b/routers/about.py new file mode 100644 index 0000000..e6b5050 --- /dev/null +++ b/routers/about.py @@ -0,0 +1,18 @@ +from typing import Optional +from fastapi import APIRouter, Depends, Request +from fastapi.templating import Jinja2Templates +from utils.auth import get_optional_user +from utils.models import User + +router = APIRouter(prefix="/about", tags=["about"]) +templates = Jinja2Templates(directory="templates") + +@router.get("/") +async def read_about( + request: Request, + user: Optional[User] = Depends(get_optional_user) +): + return templates.TemplateResponse( + "about.html", + {"request": request, "user": user} + ) \ No newline at end of file diff --git a/routers/authentication.py b/routers/authentication.py index 5880e11..be0a875 100644 --- a/routers/authentication.py +++ b/routers/authentication.py @@ -5,11 +5,13 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Form, Request from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates from pydantic import BaseModel, EmailStr, ConfigDict from sqlmodel import Session, select +from utils.db import get_session from utils.models import User, UserPassword, DataIntegrityError from utils.auth import ( - get_session, + HTML_PASSWORD_PATTERN, get_user_from_reset_token, create_password_validator, create_passwords_match_validator, @@ -22,12 +24,14 @@ send_reset_email, send_email_update_confirmation, get_user_from_email_update_token, - get_authenticated_user + get_authenticated_user, + get_optional_user ) logger = getLogger("uvicorn.error") router = APIRouter(prefix="/auth", tags=["auth"]) +templates = Jinja2Templates(directory="templates") # --- Custom Exceptions --- @@ -168,6 +172,69 @@ class UserRead(BaseModel): # --- Routes --- +@router.get("/login") +async def read_login( + request: Request, + user: Optional[User] = Depends(get_optional_user), + email_updated: Optional[str] = "false" +): + if user: + return RedirectResponse(url="/dashboard", status_code=302) + return templates.TemplateResponse( + "authentication/login.html", + {"request": request, "user": user, "email_updated": email_updated} + ) + + +@router.get("/register") +async def read_register( + request: Request, + user: Optional[User] = Depends(get_optional_user) +): + if user: + return RedirectResponse(url="/dashboard", status_code=302) + + return templates.TemplateResponse( + "authentication/register.html", + {"request": request, "user": user} + ) + + +@router.get("/forgot_password") +async def read_forgot_password( + request: Request, + user: Optional[User] = Depends(get_optional_user), + show_form: Optional[str] = "true", +): + if user: + return RedirectResponse(url="/dashboard", status_code=302) + + return templates.TemplateResponse( + "authentication/forgot_password.html", + {"request": request, "user": user, "show_form": show_form == "true"} + ) + + +@router.get("/reset_password") +async def read_reset_password( + request: Request, + email: str, + token: str, + user: Optional[User] = Depends(get_optional_user), + session: Session = Depends(get_session) +): + authorized_user, _ = get_user_from_reset_token(email, token, session) + + # Raise informative error to let user know the token is invalid and may have expired + if not authorized_user: + raise HTTPException(status_code=400, detail="Invalid or expired token") + + return templates.TemplateResponse( + "authentication/reset_password.html", + {"request": request, "user": user, "email": email, "token": token, "password_pattern": HTML_PASSWORD_PATTERN} + ) + + # TODO: Use custom error message in the case where the user is already registered @router.post("/register", response_class=RedirectResponse) async def register( diff --git a/routers/dashboard.py b/routers/dashboard.py new file mode 100644 index 0000000..2f0d032 --- /dev/null +++ b/routers/dashboard.py @@ -0,0 +1,22 @@ +from typing import Optional +from fastapi import APIRouter, Depends, Request +from fastapi.templating import Jinja2Templates +from utils.auth import get_user_with_relations +from utils.models import User + +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) +templates = Jinja2Templates(directory="templates") + + +# --- Authenticated Routes --- + + +@router.get("/") +async def read_dashboard( + request: Request, + user: Optional[User] = Depends(get_user_with_relations) +): + return templates.TemplateResponse( + "dashboard/index.html", + {"request": request, "user": user} + ) \ No newline at end of file diff --git a/routers/organization.py b/routers/organization.py index afa6e5f..162a998 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -1,6 +1,7 @@ from logging import getLogger -from fastapi import APIRouter, Depends, HTTPException, Form +from fastapi import APIRouter, Depends, HTTPException, Form, Request from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates from pydantic import BaseModel, ConfigDict, field_validator from sqlmodel import Session, select from utils.db import get_session @@ -11,6 +12,8 @@ logger = getLogger("uvicorn.error") router = APIRouter(prefix="/organizations", tags=["organizations"]) +templates = Jinja2Templates(directory="templates") + # --- Custom Exceptions --- @@ -84,6 +87,26 @@ async def as_form(cls, id: int = Form(...), name: str = Form(...)): # --- Routes --- + +@router.get("/{org_id}") +async def read_organization( + org_id: int, + request: Request, + user: User = Depends(get_user_with_relations) +): + # Get the organization only if the user is a member of it + org = next( + (org for org in user.organizations if org.id == org_id), + None + ) + if not org: + raise OrganizationNotFoundError() + + return templates.TemplateResponse( + request, "users/organization.html", {"organization": org} + ) + + @router.post("/create", response_class=RedirectResponse) def create_organization( org: OrganizationCreate = Depends(OrganizationCreate.as_form), diff --git a/routers/privacy_policy.py b/routers/privacy_policy.py new file mode 100644 index 0000000..ef84b96 --- /dev/null +++ b/routers/privacy_policy.py @@ -0,0 +1,18 @@ +from typing import Optional +from fastapi import APIRouter, Depends, Request +from fastapi.templating import Jinja2Templates +from utils.auth import get_optional_user +from utils.models import User + +router = APIRouter(prefix="/privacy_policy", tags=["privacy_policy"]) +templates = Jinja2Templates(directory="templates") + +@router.get("/") +async def read_privacy_policy( + request: Request, + user: Optional[User] = Depends(get_optional_user) +): + return templates.TemplateResponse( + "privacy_policy.html", + {"request": request, "user": user} + ) diff --git a/routers/role.py b/routers/role.py index c79c24b..1e939c4 100644 --- a/routers/role.py +++ b/routers/role.py @@ -4,7 +4,7 @@ from logging import getLogger from fastapi import APIRouter, Depends, Form, HTTPException from fastapi.responses import RedirectResponse -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict from sqlmodel import Session, select, col from sqlalchemy.orm import selectinload from utils.db import get_session diff --git a/routers/terms_of_service.py b/routers/terms_of_service.py new file mode 100644 index 0000000..9fc7ef7 --- /dev/null +++ b/routers/terms_of_service.py @@ -0,0 +1,18 @@ +from typing import Optional +from fastapi import APIRouter, Depends, Request +from fastapi.templating import Jinja2Templates +from utils.auth import get_optional_user +from utils.models import User + +router = APIRouter(prefix="/terms_of_service", tags=["terms_of_service"]) +templates = Jinja2Templates(directory="templates") + +@router.get("/") +async def read_terms_of_service( + request: Request, + user: Optional[User] = Depends(get_optional_user) +): + return templates.TemplateResponse( + "terms_of_service.html", + {"request": request, "user": user} + ) diff --git a/routers/user.py b/routers/user.py index 135f355..19c4d10 100644 --- a/routers/user.py +++ b/routers/user.py @@ -1,13 +1,16 @@ -from fastapi import APIRouter, Depends, Form, UploadFile, File +from fastapi import APIRouter, Depends, Form, UploadFile, File, Request from fastapi.responses import RedirectResponse, Response -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel from sqlmodel import Session from typing import Optional +from fastapi.templating import Jinja2Templates +from routers import authentication from utils.models import User, DataIntegrityError from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError -from utils.images import validate_and_process_image +from utils.images import validate_and_process_image, MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES router = APIRouter(prefix="/user", tags=["user"]) +templates = Jinja2Templates(directory="templates") # --- Server Request and Response Models --- @@ -53,6 +56,27 @@ async def as_form( # --- Routes --- +@router.get("/profile") +async def read_profile( + request: Request, + user: User = Depends(get_authenticated_user), + email_update_requested: Optional[str] = "false", + email_updated: Optional[str] = "false" +): + # Add image constraints to the template context + return templates.TemplateResponse( + request, "users/profile.html", { + "max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024), # Convert bytes to MB + "min_dimension": MIN_DIMENSION, + "max_dimension": MAX_DIMENSION, + "allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()), + "email_update_requested": email_update_requested, + "email_updated": email_updated, + "user": user + } + ) + + @router.post("/update_profile", response_class=RedirectResponse) async def update_profile( user_profile: UpdateProfile = Depends(UpdateProfile.as_form), @@ -77,7 +101,7 @@ async def update_profile( session.commit() session.refresh(user) - return RedirectResponse(url="/profile", status_code=303) + return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303) @router.post("/delete_account", response_class=RedirectResponse) @@ -106,13 +130,12 @@ async def delete_account( session.commit() # Log out the user - return RedirectResponse(url="/auth/logout", status_code=303) + return RedirectResponse(url=authentication.router.url_path_for("logout"), status_code=303) @router.get("/avatar") async def get_avatar( - user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) + user: User = Depends(get_authenticated_user) ): """Serve avatar image from database""" if not user.avatar_data: diff --git a/templates/base.html b/templates/base.html index 08604a5..efa4af3 100644 --- a/templates/base.html +++ b/templates/base.html @@ -21,13 +21,6 @@ {% include 'components/header.html' %} - - {% if error_message %} - - {% endif %}
{% block content %} diff --git a/tests/test_main.py b/tests/test_main.py index a8dd554..e69de29 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,21 +0,0 @@ -from fastapi.testclient import TestClient - -from utils.models import User -from main import app - - -def test_read_profile_unauthorized(unauth_client: TestClient): - """Test that unauthorized users cannot view profile""" - response = unauth_client.get(app.url_path_for( - "read_profile"), follow_redirects=False) - assert response.status_code == 303 # Redirect to login - assert response.headers["location"] == app.url_path_for("read_login") - - -def test_read_profile_authorized(auth_client: TestClient, test_user: User): - """Test that authorized users can view their profile""" - response = auth_client.get(app.url_path_for("read_profile")) - assert response.status_code == 200 - # Check that the response contains the expected HTML content - assert test_user.email in response.text - assert test_user.name in response.text diff --git a/tests/test_user.py b/tests/test_user.py index 30696d6..e74ea76 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -12,6 +12,23 @@ MOCK_CONTENT_TYPE = "image/png" +def test_read_profile_unauthorized(unauth_client: TestClient): + """Test that unauthorized users cannot view profile""" + response = unauth_client.get(app.url_path_for( + "read_profile"), follow_redirects=False) + assert response.status_code == 303 # Redirect to login + assert response.headers["location"] == app.url_path_for("read_login") + + +def test_read_profile_authorized(auth_client: TestClient, test_user: User): + """Test that authorized users can view their profile""" + response = auth_client.get(app.url_path_for("read_profile")) + assert response.status_code == 200 + # Check that the response contains the expected HTML content + assert test_user.email in response.text + assert test_user.name in response.text + + def test_update_profile_unauthorized(unauth_client: TestClient): """Test that unauthorized users cannot edit profile""" response: Response = unauth_client.post( diff --git a/utils/auth.py b/utils/auth.py index fd4badf..33ffa83 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -14,7 +14,7 @@ from typing import Optional from jinja2.environment import Template from fastapi.templating import Jinja2Templates -from fastapi import Depends, Cookie, HTTPException, status +from fastapi import Depends, Cookie, HTTPException, status, Request from utils.db import get_session from utils.models import User, Role, PasswordResetToken, EmailUpdateToken diff --git a/utils/db.py b/utils/db.py index b65af2f..98e3cc8 100644 --- a/utils/db.py +++ b/utils/db.py @@ -2,7 +2,6 @@ import logging from typing import Generator, Union, Sequence from dotenv import load_dotenv -from fastapi import HTTPException from sqlalchemy.engine import URL from sqlmodel import create_engine, Session, SQLModel, select from utils.models import Role, Permission, RolePermissionLink, default_roles, ValidPermissions From 3b840f144c7b5ead238d00833aa79b3acd987b7a Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Wed, 12 Mar 2025 16:14:54 -0400 Subject: [PATCH 02/17] Partial refactor --- .gitignore | 1 + .python-version | 1 + pyproject.toml | 6 +- routers/account.py | 47 ++++++++++++ routers/user.py | 69 ++++-------------- utils/models.py | 36 +++++++--- uv.lock | 173 +++++---------------------------------------- 7 files changed, 108 insertions(+), 225 deletions(-) create mode 100644 .python-version create mode 100644 routers/account.py diff --git a/.gitignore b/.gitignore index 62a605a..8086427 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ package-lock.json package.json .specstory .cursorrules +.cursor \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/pyproject.toml b/pyproject.toml index d91deab..f7fdb5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ package-mode = false authors = [ {name = "Christopher Carroll Smith", email = "chriscarrollsmith@gmail.com"}, ] -requires-python = "<4.0,>=3.12" +requires-python = "<4.0,>=3.13" dependencies = [ "sqlmodel<1.0.0,>=0.0.22", "pyjwt<3.0.0,>=2.10.1", @@ -32,4 +32,8 @@ dev = [ "notebook<8.0.0,>=7.2.2", "pytest<9.0.0,>=8.3.3", "sqlalchemy-schemadisplay<3.0,>=2.0", + "perplexity-cli", ] + +[tool.uv.sources] +perplexity-cli = { git = "https://github.com/chriscarrollsmith/perplexity-cli.git" } diff --git a/routers/account.py b/routers/account.py new file mode 100644 index 0000000..05bb7ab --- /dev/null +++ b/routers/account.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Depends, Form +from fastapi.responses import RedirectResponse +from sqlmodel import Session +from utils.models import User, AccountBase, DataIntegrityError +from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError, get_password_hash + +router = APIRouter(prefix="/account", tags=["account"]) + +class DeleteAccount(AccountBase): + @classmethod + async def as_form( + cls, + email: str = Form(...), + password: str = Form(...), + ): + hashed_password = get_password_hash(password) + + return cls(email=email, hashed_password=hashed_password) + + +@router.post("/delete", response_class=RedirectResponse) +async def delete_account( + user_delete_account: DeleteAccount = Depends( + DeleteAccount.as_form), + user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session) +): + if not user.password: + raise DataIntegrityError( + resource="User password" + ) + + if not verify_password( + user_delete_account.confirm_delete_password, + user.password.hashed_password + ): + raise PasswordValidationError( + field="confirm_delete_password", + message="Password is incorrect" + ) + + # Delete the user + session.delete(user) + session.commit() + + # Log out the user + return RedirectResponse(url="/auth/logout", status_code=303) \ No newline at end of file diff --git a/routers/user.py b/routers/user.py index 135f355..a613f67 100644 --- a/routers/user.py +++ b/routers/user.py @@ -1,10 +1,9 @@ from fastapi import APIRouter, Depends, Form, UploadFile, File from fastapi.responses import RedirectResponse, Response -from pydantic import BaseModel, EmailStr from sqlmodel import Session from typing import Optional -from utils.models import User, DataIntegrityError -from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError +from utils.models import User, UserBase, DataIntegrityError +from utils.auth import get_session, get_authenticated_user from utils.images import validate_and_process_image router = APIRouter(prefix="/user", tags=["user"]) @@ -13,16 +12,12 @@ # --- Server Request and Response Models --- -class UpdateProfile(BaseModel): +class UpdateUser(UserBase): """Request model for updating user profile information""" - name: str - avatar_file: Optional[bytes] = None - avatar_content_type: Optional[str] = None - @classmethod async def as_form( cls, - name: str = Form(...), + name: Optional[str] = Form(None), avatar_file: Optional[UploadFile] = File(None), ): avatar_data = None @@ -34,45 +29,34 @@ async def as_form( return cls( name=name, - avatar_file=avatar_data, + avatar_data=avatar_data, avatar_content_type=avatar_content_type ) -class UserDeleteAccount(BaseModel): - confirm_delete_password: str - - @classmethod - async def as_form( - cls, - confirm_delete_password: str = Form(...), - ): - return cls(confirm_delete_password=confirm_delete_password) - - # --- Routes --- -@router.post("/update_profile", response_class=RedirectResponse) +@router.post("/update", response_class=RedirectResponse) async def update_profile( - user_profile: UpdateProfile = Depends(UpdateProfile.as_form), + user_profile: UpdateUser = Depends(UpdateUser.as_form), user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): # Handle avatar update - if user_profile.avatar_file: + if user_profile.avatar_data: processed_image, content_type = validate_and_process_image( - user_profile.avatar_file, + user_profile.avatar_data, user_profile.avatar_content_type ) - user_profile.avatar_file = processed_image + user_profile.avatar_data = processed_image user_profile.avatar_content_type = content_type # Update user details user.name = user_profile.name - if user_profile.avatar_file: - user.avatar_data = user_profile.avatar_file + if user_profile.avatar_data: + user.avatar_data = user_profile.avatar_data user.avatar_content_type = user_profile.avatar_content_type session.commit() @@ -80,35 +64,6 @@ async def update_profile( return RedirectResponse(url="/profile", status_code=303) -@router.post("/delete_account", response_class=RedirectResponse) -async def delete_account( - user_delete_account: UserDeleteAccount = Depends( - UserDeleteAccount.as_form), - user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) -): - if not user.password: - raise DataIntegrityError( - resource="User password" - ) - - if not verify_password( - user_delete_account.confirm_delete_password, - user.password.hashed_password - ): - raise PasswordValidationError( - field="confirm_delete_password", - message="Password is incorrect" - ) - - # Delete the user - session.delete(user) - session.commit() - - # Log out the user - return RedirectResponse(url="/auth/logout", status_code=303) - - @router.get("/avatar") async def get_avatar( user: User = Depends(get_authenticated_user), diff --git a/utils/models.py b/utils/models.py index be07c8f..09d904e 100644 --- a/utils/models.py +++ b/utils/models.py @@ -4,6 +4,7 @@ from datetime import datetime, UTC, timedelta from typing import Optional, List, Union from fastapi import HTTPException +from pydantic import EmailStr from sqlmodel import SQLModel, Field, Relationship from sqlalchemy import Column, Enum as SQLAlchemyEnum, LargeBinary from sqlalchemy.orm import Mapped @@ -35,6 +36,23 @@ def __init__( ) +# ---- Base Models ---- + +class AccountBase(SQLModel): + email: EmailStr = Field(index=True, unique=True) + hashed_password: str + + +class UserBase(SQLModel): + name: Optional[str] = None + avatar_data: Optional[bytes] = Field( + default=None, sa_column=Column(LargeBinary) + ) + avatar_content_type: Optional[str] = Field( + default=None + ) + + # --- Database models --- @@ -180,13 +198,15 @@ def is_expired(self) -> bool: return datetime.now(UTC) > self.expires_at.replace(tzinfo=UTC) -class UserPassword(SQLModel, table=True): +class Account(AccountBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) + created_at: datetime = Field(default_factory=utc_time) + updated_at: datetime = Field(default_factory=utc_time) + user_id: Optional[int] = Field(foreign_key="user.id", unique=True) - hashed_password: str user: Mapped[Optional["User"]] = Relationship( - back_populates="password", + back_populates="account", sa_relationship_kwargs={ "cascade": "all, delete-orphan", "single_parent": True @@ -195,13 +215,9 @@ class UserPassword(SQLModel, table=True): # TODO: Prevent deleting a user who is sole owner of an organization -class User(SQLModel, table=True): +# TODO: Automate change of updated_at when user is updated +class User(UserBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) - name: str - email: str = Field(index=True, unique=True) - avatar_data: Optional[bytes] = Field( - default=None, sa_column=Column(LargeBinary)) - avatar_content_type: Optional[str] = None created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) @@ -221,7 +237,7 @@ class User(SQLModel, table=True): "cascade": "all, delete-orphan" } ) - password: Mapped[Optional[UserPassword]] = Relationship( + account: Mapped[Optional[Account]] = Relationship( back_populates="user" ) diff --git a/uv.lock b/uv.lock index e749a85..0b1fdcd 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -requires-python = ">=3.12, <4.0" +requires-python = ">=3.13, <4.0" [[package]] name = "annotated-types" @@ -17,7 +17,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 } wheels = [ @@ -187,17 +186,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, @@ -217,21 +205,6 @@ version = "3.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, - { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, - { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, - { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, - { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, - { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, - { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, - { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, - { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, - { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, - { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, - { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, - { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, - { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, - { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, @@ -252,14 +225,14 @@ wheels = [ [[package]] name = "click" -version = "8.1.7" +version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "platform_system == 'Windows'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, ] [[package]] @@ -289,10 +262,6 @@ version = "1.8.11" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bc/e7/666f4c9b0e24796af50aadc28d36d21c2e01e831a934535f956e09b3650c/debugpy-1.8.11.tar.gz", hash = "sha256:6ad2688b69235c43b020e04fecccdf6a96c8943ca9c2fb340b8adc103c655e57", size = 1640124 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/ae/2cf26f3111e9d94384d9c01e9d6170188b0aeda15b60a4ac6457f7c8a26f/debugpy-1.8.11-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:84e511a7545d11683d32cdb8f809ef63fc17ea2a00455cc62d0a4dbb4ed1c308", size = 2498756 }, - { url = "https://files.pythonhosted.org/packages/b0/16/ec551789d547541a46831a19aa15c147741133da188e7e6acf77510545a7/debugpy-1.8.11-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce291a5aca4985d82875d6779f61375e959208cdf09fcec40001e65fb0a54768", size = 4219136 }, - { url = "https://files.pythonhosted.org/packages/72/6f/b2b3ce673c55f882d27a6eb04a5f0c68bcad6b742ac08a86d8392ae58030/debugpy-1.8.11-cp312-cp312-win32.whl", hash = "sha256:28e45b3f827d3bf2592f3cf7ae63282e859f3259db44ed2b129093ca0ac7940b", size = 5224440 }, - { url = "https://files.pythonhosted.org/packages/77/09/b1f05be802c1caef5b3efc042fc6a7cadd13d8118b072afd04a9b9e91e06/debugpy-1.8.11-cp312-cp312-win_amd64.whl", hash = "sha256:44b1b8e6253bceada11f714acf4309ffb98bfa9ac55e4fce14f9e5d4484287a1", size = 5264578 }, { url = "https://files.pythonhosted.org/packages/2e/66/931dc2479aa8fbf362dc6dcee707d895a84b0b2d7b64020135f20b8db1ed/debugpy-1.8.11-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:8988f7163e4381b0da7696f37eec7aca19deb02e500245df68a7159739bbd0d3", size = 2483651 }, { url = "https://files.pythonhosted.org/packages/10/07/6c171d0fe6b8d237e35598b742f20ba062511b3a4631938cc78eefbbf847/debugpy-1.8.11-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c1f6a173d1140e557347419767d2b14ac1c9cd847e0b4c5444c7f3144697e4e", size = 4213770 }, { url = "https://files.pythonhosted.org/packages/89/f1/0711da6ac250d4fe3bf7b3e9b14b4a86e82a98b7825075c07e19bab8da3d/debugpy-1.8.11-cp313-cp313-win32.whl", hash = "sha256:bb3b15e25891f38da3ca0740271e63ab9db61f41d4d8541745cfc1824252cb28", size = 5223911 }, @@ -388,6 +357,7 @@ dev = [ { name = "jupyter" }, { name = "mypy" }, { name = "notebook" }, + { name = "perplexity-cli" }, { name = "pytest" }, { name = "quarto" }, { name = "sqlalchemy-schemadisplay" }, @@ -415,6 +385,7 @@ dev = [ { name = "jupyter", specifier = ">=1.1.1,<2.0.0" }, { name = "mypy", specifier = ">=1.11.2,<2.0.0" }, { name = "notebook", specifier = ">=7.2.2,<8.0.0" }, + { name = "perplexity-cli", git = "https://github.com/chriscarrollsmith/perplexity-cli.git" }, { name = "pytest", specifier = ">=8.3.3,<9.0.0" }, { name = "quarto", specifier = ">=0.1.0,<1.0.0" }, { name = "sqlalchemy-schemadisplay", specifier = ">=2.0,<3.0" }, @@ -447,39 +418,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/be/d59db2d1d52697c6adc9eacaf50e8965b6345cc143f671e1ed068818d5cf/graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5", size = 47126 }, ] -[[package]] -name = "greenlet" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260 }, - { url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064 }, - { url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420 }, - { url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035 }, - { url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105 }, - { url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077 }, - { url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975 }, - { url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955 }, - { url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655 }, - { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990 }, - { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175 }, - { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425 }, - { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736 }, - { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347 }, - { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583 }, - { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039 }, - { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716 }, - { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490 }, - { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731 }, - { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304 }, - { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537 }, - { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506 }, - { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753 }, - { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731 }, - { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 }, -] - [[package]] name = "h11" version = "0.14.0" @@ -893,16 +831,6 @@ version = "3.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, @@ -956,11 +884,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, - { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, - { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, - { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, - { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, @@ -1107,6 +1030,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, ] +[[package]] +name = "perplexity-cli" +version = "0.1.0" +source = { git = "https://github.com/chriscarrollsmith/perplexity-cli.git#9368a123026f31d351466640c31b2da6336f6a9f" } +dependencies = [ + { name = "click" }, + { name = "requests" }, +] + [[package]] name = "pexpect" version = "4.9.0" @@ -1125,17 +1057,6 @@ version = "11.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a5/26/0d95c04c868f6bdb0c447e3ee2de5564411845e36a858cfd63766bc7b563/pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739", size = 46737780 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/a3/26e606ff0b2daaf120543e537311fa3ae2eb6bf061490e4fea51771540be/pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923", size = 3147642 }, - { url = "https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903", size = 2978999 }, - { url = "https://files.pythonhosted.org/packages/d9/ff/5a45000826a1aa1ac6874b3ec5a856474821a1b59d838c4f6ce2ee518fe9/pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4", size = 4196794 }, - { url = "https://files.pythonhosted.org/packages/9d/21/84c9f287d17180f26263b5f5c8fb201de0f88b1afddf8a2597a5c9fe787f/pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f", size = 4300762 }, - { url = "https://files.pythonhosted.org/packages/84/39/63fb87cd07cc541438b448b1fed467c4d687ad18aa786a7f8e67b255d1aa/pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9", size = 4210468 }, - { url = "https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7", size = 4381824 }, - { url = "https://files.pythonhosted.org/packages/31/69/1ef0fb9d2f8d2d114db982b78ca4eeb9db9a29f7477821e160b8c1253f67/pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6", size = 4296436 }, - { url = "https://files.pythonhosted.org/packages/44/ea/dad2818c675c44f6012289a7c4f46068c548768bc6c7f4e8c4ae5bbbc811/pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc", size = 4429714 }, - { url = "https://files.pythonhosted.org/packages/af/3a/da80224a6eb15bba7a0dcb2346e2b686bb9bf98378c0b4353cd88e62b171/pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6", size = 2249631 }, - { url = "https://files.pythonhosted.org/packages/57/97/73f756c338c1d86bb802ee88c3cab015ad7ce4b838f8a24f16b676b1ac7c/pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47", size = 2567533 }, - { url = "https://files.pythonhosted.org/packages/0b/30/2b61876e2722374558b871dfbfcbe4e406626d63f4f6ed92e9c8e24cac37/pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25", size = 2254890 }, { url = "https://files.pythonhosted.org/packages/63/24/e2e15e392d00fcf4215907465d8ec2a2f23bcec1481a8ebe4ae760459995/pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699", size = 3147300 }, { url = "https://files.pythonhosted.org/packages/43/72/92ad4afaa2afc233dc44184adff289c2e77e8cd916b3ddb72ac69495bda3/pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38", size = 2978742 }, { url = "https://files.pythonhosted.org/packages/9e/da/c8d69c5bc85d72a8523fe862f05ababdc52c0a755cfe3d362656bb86552b/pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2", size = 4194349 }, @@ -1217,8 +1138,7 @@ version = "2.9.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/62/51/2007ea29e605957a17ac6357115d0c1a1b60c8c984951c19419b3474cdfd/psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11", size = 385672 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/16/4623fad6076448df21c1a870c93a9774ad8a7b4dd1660223b59082dd8fec/psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067", size = 1025113 }, - { url = "https://files.pythonhosted.org/packages/66/de/baed128ae0fc07460d9399d82e631ea31a1f171c0c4ae18f9808ac6759e3/psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e", size = 1163951 }, + { url = "https://files.pythonhosted.org/packages/ae/49/a6cfc94a9c483b1fa401fbcb23aca7892f60c7269c5ffa2ac408364f80dc/psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2", size = 2569060 }, ] [[package]] @@ -1276,20 +1196,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785 } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239 }, - { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070 }, - { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096 }, - { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708 }, - { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751 }, - { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863 }, - { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161 }, - { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294 }, - { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468 }, - { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413 }, - { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735 }, - { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633 }, - { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973 }, - { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215 }, { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033 }, { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542 }, { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854 }, @@ -1404,9 +1310,6 @@ name = "pywin32" version = "308" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/7c/d00d6bdd96de4344e06c4afbf218bc86b54436a94c01c71a8701f613aa56/pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897", size = 5939729 }, - { url = "https://files.pythonhosted.org/packages/21/27/0c8811fbc3ca188f93b5354e7c286eb91f80a53afa4e11007ef661afa746/pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47", size = 6543015 }, - { url = "https://files.pythonhosted.org/packages/9d/0f/d40f8373608caed2255781a3ad9a51d03a594a1248cd632d6a298daca693/pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091", size = 7976033 }, { url = "https://files.pythonhosted.org/packages/a9/a4/aa562d8935e3df5e49c161b427a3a2efad2ed4e9cf81c3de636f1fdddfd0/pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed", size = 5938579 }, { url = "https://files.pythonhosted.org/packages/c7/50/b0efb8bb66210da67a53ab95fd7a98826a97ee21f1d22949863e6d588b22/pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4", size = 6542056 }, { url = "https://files.pythonhosted.org/packages/26/df/2b63e3e4f2df0224f8aaf6d131f54fe4e8c96400eb9df563e2aae2e1a1f9/pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd", size = 7974986 }, @@ -1418,7 +1321,6 @@ version = "2.0.14" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f1/82/90f8750423cba4b9b6c842df227609fb60704482d7abf6dd47e2babc055a/pywinpty-2.0.14.tar.gz", hash = "sha256:18bd9529e4a5daf2d9719aa17788ba6013e594ae94c5a0c27e83df3278b0660e", size = 27769 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/79/759ae767a3b78d340446efd54dd1fe4f7dafa4fc7be96ed757e44bcdba54/pywinpty-2.0.14-cp312-none-win_amd64.whl", hash = "sha256:55dad362ef3e9408ade68fd173e4f9032b3ce08f68cfe7eacb2c263ea1179737", size = 1397207 }, { url = "https://files.pythonhosted.org/packages/7d/34/b77b3c209bf2eaa6455390c8d5449241637f5957f41636a2204065d52bfa/pywinpty-2.0.14-cp313-none-win_amd64.whl", hash = "sha256:074fb988a56ec79ca90ed03a896d40707131897cefb8f76f926e3834227f2819", size = 1396698 }, ] @@ -1428,15 +1330,6 @@ version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, @@ -1457,18 +1350,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fd/05/bed626b9f7bb2322cdbbf7b4bd8f54b1b617b0d2ab2d3547d6e39428a48e/pyzmq-26.2.0.tar.gz", hash = "sha256:070672c258581c8e4f640b5159297580a9974b026043bd4ab0470be9ed324f1f", size = 271975 } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/2f/78a766c8913ad62b28581777ac4ede50c6d9f249d39c2963e279524a1bbe/pyzmq-26.2.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:ded0fc7d90fe93ae0b18059930086c51e640cdd3baebdc783a695c77f123dcd9", size = 1343105 }, - { url = "https://files.pythonhosted.org/packages/b7/9c/4b1e2d3d4065be715e007fe063ec7885978fad285f87eae1436e6c3201f4/pyzmq-26.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:17bf5a931c7f6618023cdacc7081f3f266aecb68ca692adac015c383a134ca52", size = 1008365 }, - { url = "https://files.pythonhosted.org/packages/4f/ef/5a23ec689ff36d7625b38d121ef15abfc3631a9aecb417baf7a4245e4124/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55cf66647e49d4621a7e20c8d13511ef1fe1efbbccf670811864452487007e08", size = 665923 }, - { url = "https://files.pythonhosted.org/packages/ae/61/d436461a47437d63c6302c90724cf0981883ec57ceb6073873f32172d676/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4661c88db4a9e0f958c8abc2b97472e23061f0bc737f6f6179d7a27024e1faa5", size = 903400 }, - { url = "https://files.pythonhosted.org/packages/47/42/fc6d35ecefe1739a819afaf6f8e686f7f02a4dd241c78972d316f403474c/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea7f69de383cb47522c9c208aec6dd17697db7875a4674c4af3f8cfdac0bdeae", size = 860034 }, - { url = "https://files.pythonhosted.org/packages/07/3b/44ea6266a6761e9eefaa37d98fabefa112328808ac41aa87b4bbb668af30/pyzmq-26.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7f98f6dfa8b8ccaf39163ce872bddacca38f6a67289116c8937a02e30bbe9711", size = 860579 }, - { url = "https://files.pythonhosted.org/packages/38/6f/4df2014ab553a6052b0e551b37da55166991510f9e1002c89cab7ce3b3f2/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e3e0210287329272539eea617830a6a28161fbbd8a3271bf4150ae3e58c5d0e6", size = 1196246 }, - { url = "https://files.pythonhosted.org/packages/38/9d/ee240fc0c9fe9817f0c9127a43238a3e28048795483c403cc10720ddef22/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6b274e0762c33c7471f1a7471d1a2085b1a35eba5cdc48d2ae319f28b6fc4de3", size = 1507441 }, - { url = "https://files.pythonhosted.org/packages/85/4f/01711edaa58d535eac4a26c294c617c9a01f09857c0ce191fd574d06f359/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:29c6a4635eef69d68a00321e12a7d2559fe2dfccfa8efae3ffb8e91cd0b36a8b", size = 1406498 }, - { url = "https://files.pythonhosted.org/packages/07/18/907134c85c7152f679ed744e73e645b365f3ad571f38bdb62e36f347699a/pyzmq-26.2.0-cp312-cp312-win32.whl", hash = "sha256:989d842dc06dc59feea09e58c74ca3e1678c812a4a8a2a419046d711031f69c7", size = 575533 }, - { url = "https://files.pythonhosted.org/packages/ce/2c/a6f4a20202a4d3c582ad93f95ee78d79bbdc26803495aec2912b17dbbb6c/pyzmq-26.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:2a50625acdc7801bc6f74698c5c583a491c61d73c6b7ea4dee3901bb99adb27a", size = 637768 }, - { url = "https://files.pythonhosted.org/packages/5f/0e/eb16ff731632d30554bf5af4dbba3ffcd04518219d82028aea4ae1b02ca5/pyzmq-26.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d29ab8592b6ad12ebbf92ac2ed2bedcfd1cec192d8e559e2e099f648570e19b", size = 540675 }, { url = "https://files.pythonhosted.org/packages/04/a7/0f7e2f6c126fe6e62dbae0bc93b1bd3f1099cf7fea47a5468defebe3f39d/pyzmq-26.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9dd8cd1aeb00775f527ec60022004d030ddc51d783d056e3e23e74e623e33726", size = 1006564 }, { url = "https://files.pythonhosted.org/packages/31/b6/a187165c852c5d49f826a690857684333a6a4a065af0a6015572d2284f6a/pyzmq-26.2.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:28c812d9757fe8acecc910c9ac9dafd2ce968c00f9e619db09e9f8f54c3a68a3", size = 1340447 }, { url = "https://files.pythonhosted.org/packages/68/ba/f4280c58ff71f321602a6e24fd19879b7e79793fb8ab14027027c0fb58ef/pyzmq-26.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d80b1dd99c1942f74ed608ddb38b181b87476c6a966a88a950c7dee118fdf50", size = 665485 }, @@ -1576,19 +1457,6 @@ version = "0.22.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/01/80/cce854d0921ff2f0a9fa831ba3ad3c65cee3a46711addf39a2af52df2cfd/rpds_py-0.22.3.tar.gz", hash = "sha256:e32fee8ab45d3c2db6da19a5323bc3362237c8b653c70194414b892fd06a080d", size = 26771 } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/47/3383ee3bd787a2a5e65a9b9edc37ccf8505c0a00170e3a5e6ea5fbcd97f7/rpds_py-0.22.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:27e98004595899949bd7a7b34e91fa7c44d7a97c40fcaf1d874168bb652ec67e", size = 352334 }, - { url = "https://files.pythonhosted.org/packages/40/14/aa6400fa8158b90a5a250a77f2077c0d0cd8a76fce31d9f2b289f04c6dec/rpds_py-0.22.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1978d0021e943aae58b9b0b196fb4895a25cc53d3956b8e35e0b7682eefb6d56", size = 342111 }, - { url = "https://files.pythonhosted.org/packages/7d/06/395a13bfaa8a28b302fb433fb285a67ce0ea2004959a027aea8f9c52bad4/rpds_py-0.22.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:655ca44a831ecb238d124e0402d98f6212ac527a0ba6c55ca26f616604e60a45", size = 384286 }, - { url = "https://files.pythonhosted.org/packages/43/52/d8eeaffab047e6b7b7ef7f00d5ead074a07973968ffa2d5820fa131d7852/rpds_py-0.22.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feea821ee2a9273771bae61194004ee2fc33f8ec7db08117ef9147d4bbcbca8e", size = 391739 }, - { url = "https://files.pythonhosted.org/packages/83/31/52dc4bde85c60b63719610ed6f6d61877effdb5113a72007679b786377b8/rpds_py-0.22.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22bebe05a9ffc70ebfa127efbc429bc26ec9e9b4ee4d15a740033efda515cf3d", size = 427306 }, - { url = "https://files.pythonhosted.org/packages/70/d5/1bab8e389c2261dba1764e9e793ed6830a63f830fdbec581a242c7c46bda/rpds_py-0.22.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3af6e48651c4e0d2d166dc1b033b7042ea3f871504b6805ba5f4fe31581d8d38", size = 442717 }, - { url = "https://files.pythonhosted.org/packages/82/a1/a45f3e30835b553379b3a56ea6c4eb622cf11e72008229af840e4596a8ea/rpds_py-0.22.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67ba3c290821343c192f7eae1d8fd5999ca2dc99994114643e2f2d3e6138b15", size = 385721 }, - { url = "https://files.pythonhosted.org/packages/a6/27/780c942de3120bdd4d0e69583f9c96e179dfff082f6ecbb46b8d6488841f/rpds_py-0.22.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:02fbb9c288ae08bcb34fb41d516d5eeb0455ac35b5512d03181d755d80810059", size = 415824 }, - { url = "https://files.pythonhosted.org/packages/94/0b/aa0542ca88ad20ea719b06520f925bae348ea5c1fdf201b7e7202d20871d/rpds_py-0.22.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f56a6b404f74ab372da986d240e2e002769a7d7102cc73eb238a4f72eec5284e", size = 561227 }, - { url = "https://files.pythonhosted.org/packages/0d/92/3ed77d215f82c8f844d7f98929d56cc321bb0bcfaf8f166559b8ec56e5f1/rpds_py-0.22.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0a0461200769ab3b9ab7e513f6013b7a97fdeee41c29b9db343f3c5a8e2b9e61", size = 587424 }, - { url = "https://files.pythonhosted.org/packages/09/42/cacaeb047a22cab6241f107644f230e2935d4efecf6488859a7dd82fc47d/rpds_py-0.22.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8633e471c6207a039eff6aa116e35f69f3156b3989ea3e2d755f7bc41754a4a7", size = 555953 }, - { url = "https://files.pythonhosted.org/packages/e6/52/c921dc6d5f5d45b212a456c1f5b17df1a471127e8037eb0972379e39dff4/rpds_py-0.22.3-cp312-cp312-win32.whl", hash = "sha256:593eba61ba0c3baae5bc9be2f5232430453fb4432048de28399ca7376de9c627", size = 221339 }, - { url = "https://files.pythonhosted.org/packages/f2/c7/f82b5be1e8456600395366f86104d1bd8d0faed3802ad511ef6d60c30d98/rpds_py-0.22.3-cp312-cp312-win_amd64.whl", hash = "sha256:d115bffdd417c6d806ea9069237a4ae02f513b778e3789a359bc5856e0404cc4", size = 235786 }, { url = "https://files.pythonhosted.org/packages/d0/bf/36d5cc1f2c609ae6e8bf0fc35949355ca9d8790eceb66e6385680c951e60/rpds_py-0.22.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ea7433ce7e4bfc3a85654aeb6747babe3f66eaf9a1d0c1e7a4435bbdf27fea84", size = 351657 }, { url = "https://files.pythonhosted.org/packages/24/2a/f1e0fa124e300c26ea9382e59b2d582cba71cedd340f32d1447f4f29fa4e/rpds_py-0.22.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6dd9412824c4ce1aca56c47b0991e65bebb7ac3f4edccfd3f156150c96a7bf25", size = 341829 }, { url = "https://files.pythonhosted.org/packages/cf/c2/0da1231dd16953845bed60d1a586fcd6b15ceaeb965f4d35cdc71f70f606/rpds_py-0.22.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20070c65396f7373f5df4005862fa162db5d25d56150bddd0b3e8214e8ef45b4", size = 384220 }, @@ -1667,19 +1535,10 @@ name = "sqlalchemy" version = "2.0.36" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "(python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64')" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/65/9cbc9c4c3287bed2499e05033e207473504dc4df999ce49385fb1f8b058a/sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5", size = 9574485 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/bf/005dc47f0e57556e14512d5542f3f183b94fde46e15ff1588ec58ca89555/SQLAlchemy-2.0.36-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7b64e6ec3f02c35647be6b4851008b26cff592a95ecb13b6788a54ef80bbdd4", size = 2092378 }, - { url = "https://files.pythonhosted.org/packages/94/65/f109d5720779a08e6e324ec89a744f5f92c48bd8005edc814bf72fbb24e5/SQLAlchemy-2.0.36-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46331b00096a6db1fdc052d55b101dbbfc99155a548e20a0e4a8e5e4d1362855", size = 2082778 }, - { url = "https://files.pythonhosted.org/packages/60/f6/d9aa8c49c44f9b8c9b9dada1f12fa78df3d4c42aa2de437164b83ee1123c/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdf3386a801ea5aba17c6410dd1dc8d39cf454ca2565541b5ac42a84e1e28f53", size = 3232191 }, - { url = "https://files.pythonhosted.org/packages/8a/ab/81d4514527c068670cb1d7ab62a81a185df53a7c379bd2a5636e83d09ede/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9dfa18ff2a67b09b372d5db8743c27966abf0e5344c555d86cc7199f7ad83a", size = 3243044 }, - { url = "https://files.pythonhosted.org/packages/35/b4/f87c014ecf5167dc669199cafdb20a7358ff4b1d49ce3622cc48571f811c/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:90812a8933df713fdf748b355527e3af257a11e415b613dd794512461eb8a686", size = 3178511 }, - { url = "https://files.pythonhosted.org/packages/ea/09/badfc9293bc3ccba6ede05e5f2b44a760aa47d84da1fc5a326e963e3d4d9/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1bc330d9d29c7f06f003ab10e1eaced295e87940405afe1b110f2eb93a233588", size = 3205147 }, - { url = "https://files.pythonhosted.org/packages/c8/60/70e681de02a13c4b27979b7b78da3058c49bacc9858c89ba672e030f03f2/SQLAlchemy-2.0.36-cp312-cp312-win32.whl", hash = "sha256:79d2e78abc26d871875b419e1fd3c0bca31a1cb0043277d0d850014599626c2e", size = 2062709 }, - { url = "https://files.pythonhosted.org/packages/b7/ed/f6cd9395e41bfe47dd253d74d2dfc3cab34980d4e20c8878cb1117306085/SQLAlchemy-2.0.36-cp312-cp312-win_amd64.whl", hash = "sha256:b544ad1935a8541d177cb402948b94e871067656b3a0b9e91dbec136b06a2ff5", size = 2088433 }, { url = "https://files.pythonhosted.org/packages/78/5c/236398ae3678b3237726819b484f15f5c038a9549da01703a771f05a00d6/SQLAlchemy-2.0.36-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef", size = 2087651 }, { url = "https://files.pythonhosted.org/packages/a8/14/55c47420c0d23fb67a35af8be4719199b81c59f3084c28d131a7767b0b0b/SQLAlchemy-2.0.36-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8", size = 2078132 }, { url = "https://files.pythonhosted.org/packages/3d/97/1e843b36abff8c4a7aa2e37f9bea364f90d021754c2de94d792c2d91405b/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b", size = 3164559 }, From d114948e09bb9603e8eefae6679686a0304c559d Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Thu, 13 Mar 2025 08:31:36 -0400 Subject: [PATCH 03/17] Move authentication routes to account.py, resequence models --- routers/account.py | 431 ++++++++++++++++++++++++++++++++++++- routers/authentication.py | 437 -------------------------------------- routers/user.py | 20 +- utils/models.py | 249 +++++++++++----------- 4 files changed, 551 insertions(+), 586 deletions(-) delete mode 100644 routers/authentication.py diff --git a/routers/account.py b/routers/account.py index 05bb7ab..379ec71 100644 --- a/routers/account.py +++ b/routers/account.py @@ -1,23 +1,165 @@ -from fastapi import APIRouter, Depends, Form +# auth.py +from logging import getLogger +from typing import Optional +from urllib.parse import urlparse +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Form, Request from fastapi.responses import RedirectResponse -from sqlmodel import Session -from utils.models import User, AccountBase, DataIntegrityError -from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError, get_password_hash +from pydantic import BaseModel, EmailStr, ConfigDict +from sqlmodel import Session, select +from utils.models import AccountBase, Account, DataIntegrityError, User +from utils.auth import ( + get_session, + get_user_from_reset_token, + create_password_validator, + create_passwords_match_validator, + oauth2_scheme_cookie, + get_password_hash, + verify_password, + create_access_token, + create_refresh_token, + validate_token, + send_reset_email, + send_email_update_confirmation, + get_user_from_email_update_token, + get_authenticated_user, + PasswordValidationError +) + +logger = getLogger("uvicorn.error") router = APIRouter(prefix="/account", tags=["account"]) +# --- Custom Exceptions --- + + +class EmailAlreadyRegisteredError(HTTPException): + def __init__(self): + super().__init__( + status_code=409, + detail="This email is already registered" + ) + + +class AuthenticationError(HTTPException): + def __init__(self, message: str = "Invalid credentials"): + super().__init__( + status_code=401, + detail=message + ) + + +# --- Server Request and Response Models --- + + class DeleteAccount(AccountBase): @classmethod async def as_form( cls, - email: str = Form(...), + email: EmailStr = Form(...), password: str = Form(...), ): - hashed_password = get_password_hash(password) + return cls(email=email, password=password) + + +class CreateAccount(AccountBase): + name: str + password: str + confirm_password: str + + validate_password_strength = create_password_validator("password") + validate_passwords_match = create_passwords_match_validator( + "password", "confirm_password") + + @classmethod + async def as_form( + cls, + name: str = Form(...), + email: EmailStr = Form(...), + password: str = Form(...), + confirm_password: str = Form(...) + ): + return cls( + name=name, + email=email, + password=password, + confirm_password=confirm_password + ) + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + @classmethod + async def as_form( + cls, + email: EmailStr = Form(...), + password: str = Form(...) + ): + return cls(email=email, password=password) + + +class UserForgotPassword(BaseModel): + email: EmailStr + + @classmethod + async def as_form( + cls, + email: EmailStr = Form(...) + ): + return cls(email=email) - return cls(email=email, hashed_password=hashed_password) +class UserResetPassword(BaseModel): + email: EmailStr + token: str + new_password: str + confirm_new_password: str + # Use the factory with a different field name + validate_password_strength = create_password_validator("new_password") + validate_passwords_match = create_passwords_match_validator( + "new_password", "confirm_new_password") + + @classmethod + async def as_form( + cls, + email: EmailStr = Form(...), + token: str = Form(...), + new_password: str = Form(...), + confirm_new_password: str = Form(...) + ): + return cls(email=email, token=token, + new_password=new_password, confirm_new_password=confirm_new_password) + + +class UpdateEmail(BaseModel): + new_email: EmailStr + + @classmethod + async def as_form( + cls, + new_email: EmailStr = Form(...) + ): + return cls(new_email=new_email) + + +class UserRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + email: EmailStr + organization_id: Optional[int] + created_at: datetime + updated_at: datetime + + +# --- Routes --- + + +# TODO: Check the email too @router.post("/delete", response_class=RedirectResponse) async def delete_account( user_delete_account: DeleteAccount = Depends( @@ -31,11 +173,11 @@ async def delete_account( ) if not verify_password( - user_delete_account.confirm_delete_password, + user_delete_account.password, user.password.hashed_password ): raise PasswordValidationError( - field="confirm_delete_password", + field="password", message="Password is incorrect" ) @@ -44,4 +186,273 @@ async def delete_account( session.commit() # Log out the user - return RedirectResponse(url="/auth/logout", status_code=303) \ No newline at end of file + return RedirectResponse(url="/auth/logout", status_code=303) + + +# TODO: Use custom error message in the case where the user is already registered +@router.post("/register", response_class=RedirectResponse) +async def register( + user: UserRegister = Depends(UserRegister.as_form), + session: Session = Depends(get_session), +) -> RedirectResponse: + # Check if the email is already registered + db_user = session.exec(select(User).where( + User.email == user.email)).first() + + if db_user: + raise EmailAlreadyRegisteredError() + + # Hash the password + hashed_password = get_password_hash(user.password) + + # Create the user + db_user = User(name=user.name, email=user.email, + password=UserPassword(hashed_password=hashed_password)) + session.add(db_user) + session.commit() + session.refresh(db_user) + + # Create access token + access_token = create_access_token(data={"sub": db_user.email}) + refresh_token = create_refresh_token(data={"sub": db_user.email}) + # Set cookie + response = RedirectResponse(url="/", status_code=303) + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=True, + samesite="strict" + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=True, + samesite="strict" + ) + + return response + + +@router.post("/login", response_class=RedirectResponse) +async def login( + user: UserLogin = Depends(UserLogin.as_form), + session: Session = Depends(get_session), +) -> RedirectResponse: + # Check if the email is registered + db_user = session.exec(select(User).where( + User.email == user.email)).first() + + if not db_user or not db_user.password or not verify_password(user.password, db_user.password.hashed_password): + raise AuthenticationError() + + # Create access token + access_token = create_access_token( + data={"sub": db_user.email, "fresh": True}) + refresh_token = create_refresh_token(data={"sub": db_user.email}) + + # Set cookie + response = RedirectResponse(url="/", status_code=303) + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=True, + samesite="strict", + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=True, + samesite="strict", + ) + + return response + + +# Updated refresh_token endpoint +@router.post("/refresh", response_class=RedirectResponse) +async def refresh_token( + tokens: tuple[Optional[str], Optional[str] + ] = Depends(oauth2_scheme_cookie), + session: Session = Depends(get_session), +) -> RedirectResponse: + _, refresh_token = tokens + if not refresh_token: + return RedirectResponse(url="/login", status_code=303) + + decoded_token = validate_token(refresh_token, token_type="refresh") + if not decoded_token: + response = RedirectResponse(url="/login", status_code=303) + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + return response + + user_email = decoded_token.get("sub") + db_user = session.exec(select(User).where( + User.email == user_email)).first() + if not db_user: + return RedirectResponse(url="/login", status_code=303) + + new_access_token = create_access_token( + data={"sub": db_user.email, "fresh": False}) + new_refresh_token = create_refresh_token(data={"sub": db_user.email}) + + response = RedirectResponse(url="/", status_code=303) + response.set_cookie( + key="access_token", + value=new_access_token, + httponly=True, + secure=True, + samesite="strict", + ) + response.set_cookie( + key="refresh_token", + value=new_refresh_token, + httponly=True, + secure=True, + samesite="strict", + ) + + return response + + +@router.post("/forgot_password") +async def forgot_password( + background_tasks: BackgroundTasks, + request: Request, + user: UserForgotPassword = Depends(UserForgotPassword.as_form), + session: Session = Depends(get_session) +): + db_user = session.exec(select(User).where( + User.email == user.email)).first() + + if db_user: + background_tasks.add_task(send_reset_email, user.email, session) + + # Get the referer header, default to /forgot_password if not present + referer = request.headers.get("referer", "/forgot_password") + + # Extract the path from the full URL + redirect_path = urlparse(referer).path + + # Add the query parameter to the redirect path + return RedirectResponse(url=f"{redirect_path}?show_form=false", status_code=303) + + +@router.post("/reset_password") +async def reset_password( + user: UserResetPassword = Depends(UserResetPassword.as_form), + session: Session = Depends(get_session) +): + authorized_user, reset_token = get_user_from_reset_token( + user.email, user.token, session) + + if not authorized_user or not reset_token: + raise AuthenticationError("Invalid or expired password reset token; please request a new one") + + # Update password and mark token as used + if authorized_user.password: + authorized_user.password.hashed_password = get_password_hash( + user.new_password + ) + else: + logger.warning( + "User password not found during password reset; creating new password for user") + authorized_user.password = UserPassword( + hashed_password=get_password_hash(user.new_password) + ) + + reset_token.used = True + session.commit() + session.refresh(authorized_user) + + return RedirectResponse(url="/login", status_code=303) + + +@router.get("/logout", response_class=RedirectResponse) +def logout(): + response = RedirectResponse(url="/", status_code=303) + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + return response + + +@router.post("/update_email") +async def request_email_update( + update: UpdateEmail = Depends(UpdateEmail.as_form), + user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session) +): + # Check if the new email is already registered + existing_user = session.exec( + select(User).where(User.email == update.new_email) + ).first() + + if existing_user: + raise EmailAlreadyRegisteredError() + + if not user.id: + raise DataIntegrityError(resource="User id") + + # Send confirmation email + send_email_update_confirmation( + current_email=user.email, + new_email=update.new_email, + user_id=user.id, + session=session + ) + + return RedirectResponse( + url="/profile?email_update_requested=true", + status_code=303 + ) + + +@router.get("/confirm_email_update") +async def confirm_email_update( + user_id: int, + token: str, + new_email: str, + session: Session = Depends(get_session) +): + user, update_token = get_user_from_email_update_token( + user_id, token, session + ) + + if not user or not update_token: + raise AuthenticationError("Invalid or expired email update token; please request a new one") + + # Update email and mark token as used + user.email = new_email + update_token.used = True + session.commit() + + # Create new tokens with the updated email + access_token = create_access_token(data={"sub": new_email, "fresh": True}) + refresh_token = create_refresh_token(data={"sub": new_email}) + + # Set cookies before redirecting + response = RedirectResponse( + url="/profile?email_updated=true", + status_code=303 + ) + + # Add secure cookie attributes + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=True, + samesite="lax" + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=True, + samesite="lax" + ) + return response diff --git a/routers/authentication.py b/routers/authentication.py deleted file mode 100644 index 5880e11..0000000 --- a/routers/authentication.py +++ /dev/null @@ -1,437 +0,0 @@ -# auth.py -from logging import getLogger -from typing import Optional -from urllib.parse import urlparse -from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Form, Request -from fastapi.responses import RedirectResponse -from pydantic import BaseModel, EmailStr, ConfigDict -from sqlmodel import Session, select -from utils.models import User, UserPassword, DataIntegrityError -from utils.auth import ( - get_session, - get_user_from_reset_token, - create_password_validator, - create_passwords_match_validator, - oauth2_scheme_cookie, - get_password_hash, - verify_password, - create_access_token, - create_refresh_token, - validate_token, - send_reset_email, - send_email_update_confirmation, - get_user_from_email_update_token, - get_authenticated_user -) - -logger = getLogger("uvicorn.error") - -router = APIRouter(prefix="/auth", tags=["auth"]) - -# --- Custom Exceptions --- - - -class EmailAlreadyRegisteredError(HTTPException): - def __init__(self): - super().__init__( - status_code=409, - detail="This email is already registered" - ) - - -class InvalidCredentialsError(HTTPException): - def __init__(self): - super().__init__( - status_code=401, - detail="Invalid credentials" - ) - - -class InvalidResetTokenError(HTTPException): - def __init__(self): - super().__init__( - status_code=401, - detail="Invalid or expired password reset token; please request a new one" - ) - - -class InvalidEmailUpdateTokenError(HTTPException): - def __init__(self): - super().__init__( - status_code=401, - detail="Invalid or expired email update token; please request a new one" - ) - - -# --- Server Request and Response Models --- - - -class UserRegister(BaseModel): - name: str - email: EmailStr - password: str - confirm_password: str - - validate_password_strength = create_password_validator("password") - validate_passwords_match = create_passwords_match_validator( - "password", "confirm_password") - - @classmethod - async def as_form( - cls, - name: str = Form(...), - email: EmailStr = Form(...), - password: str = Form(...), - confirm_password: str = Form(...) - ): - return cls( - name=name, - email=email, - password=password, - confirm_password=confirm_password - ) - - -class UserLogin(BaseModel): - email: EmailStr - password: str - - @classmethod - async def as_form( - cls, - email: EmailStr = Form(...), - password: str = Form(...) - ): - return cls(email=email, password=password) - - -class UserForgotPassword(BaseModel): - email: EmailStr - - @classmethod - async def as_form( - cls, - email: EmailStr = Form(...) - ): - return cls(email=email) - - -class UserResetPassword(BaseModel): - email: EmailStr - token: str - new_password: str - confirm_new_password: str - - # Use the factory with a different field name - validate_password_strength = create_password_validator("new_password") - validate_passwords_match = create_passwords_match_validator( - "new_password", "confirm_new_password") - - @classmethod - async def as_form( - cls, - email: EmailStr = Form(...), - token: str = Form(...), - new_password: str = Form(...), - confirm_new_password: str = Form(...) - ): - return cls(email=email, token=token, - new_password=new_password, confirm_new_password=confirm_new_password) - - -class UpdateEmail(BaseModel): - new_email: EmailStr - - @classmethod - async def as_form( - cls, - new_email: EmailStr = Form(...) - ): - return cls(new_email=new_email) - - -# --- DB Request and Response Models --- - - -class UserRead(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - name: str - email: EmailStr - organization_id: Optional[int] - created_at: datetime - updated_at: datetime - - -# --- Routes --- - - -# TODO: Use custom error message in the case where the user is already registered -@router.post("/register", response_class=RedirectResponse) -async def register( - user: UserRegister = Depends(UserRegister.as_form), - session: Session = Depends(get_session), -) -> RedirectResponse: - # Check if the email is already registered - db_user = session.exec(select(User).where( - User.email == user.email)).first() - - if db_user: - raise EmailAlreadyRegisteredError() - - # Hash the password - hashed_password = get_password_hash(user.password) - - # Create the user - db_user = User(name=user.name, email=user.email, - password=UserPassword(hashed_password=hashed_password)) - session.add(db_user) - session.commit() - session.refresh(db_user) - - # Create access token - access_token = create_access_token(data={"sub": db_user.email}) - refresh_token = create_refresh_token(data={"sub": db_user.email}) - # Set cookie - response = RedirectResponse(url="/", status_code=303) - response.set_cookie( - key="access_token", - value=access_token, - httponly=True, - secure=True, - samesite="strict" - ) - response.set_cookie( - key="refresh_token", - value=refresh_token, - httponly=True, - secure=True, - samesite="strict" - ) - - return response - - -@router.post("/login", response_class=RedirectResponse) -async def login( - user: UserLogin = Depends(UserLogin.as_form), - session: Session = Depends(get_session), -) -> RedirectResponse: - # Check if the email is registered - db_user = session.exec(select(User).where( - User.email == user.email)).first() - - if not db_user or not db_user.password or not verify_password(user.password, db_user.password.hashed_password): - raise InvalidCredentialsError() - - # Create access token - access_token = create_access_token( - data={"sub": db_user.email, "fresh": True}) - refresh_token = create_refresh_token(data={"sub": db_user.email}) - - # Set cookie - response = RedirectResponse(url="/", status_code=303) - response.set_cookie( - key="access_token", - value=access_token, - httponly=True, - secure=True, - samesite="strict", - ) - response.set_cookie( - key="refresh_token", - value=refresh_token, - httponly=True, - secure=True, - samesite="strict", - ) - - return response - - -# Updated refresh_token endpoint -@router.post("/refresh", response_class=RedirectResponse) -async def refresh_token( - tokens: tuple[Optional[str], Optional[str] - ] = Depends(oauth2_scheme_cookie), - session: Session = Depends(get_session), -) -> RedirectResponse: - _, refresh_token = tokens - if not refresh_token: - return RedirectResponse(url="/login", status_code=303) - - decoded_token = validate_token(refresh_token, token_type="refresh") - if not decoded_token: - response = RedirectResponse(url="/login", status_code=303) - response.delete_cookie("access_token") - response.delete_cookie("refresh_token") - return response - - user_email = decoded_token.get("sub") - db_user = session.exec(select(User).where( - User.email == user_email)).first() - if not db_user: - return RedirectResponse(url="/login", status_code=303) - - new_access_token = create_access_token( - data={"sub": db_user.email, "fresh": False}) - new_refresh_token = create_refresh_token(data={"sub": db_user.email}) - - response = RedirectResponse(url="/", status_code=303) - response.set_cookie( - key="access_token", - value=new_access_token, - httponly=True, - secure=True, - samesite="strict", - ) - response.set_cookie( - key="refresh_token", - value=new_refresh_token, - httponly=True, - secure=True, - samesite="strict", - ) - - return response - - -@router.post("/forgot_password") -async def forgot_password( - background_tasks: BackgroundTasks, - request: Request, - user: UserForgotPassword = Depends(UserForgotPassword.as_form), - session: Session = Depends(get_session) -): - db_user = session.exec(select(User).where( - User.email == user.email)).first() - - if db_user: - background_tasks.add_task(send_reset_email, user.email, session) - - # Get the referer header, default to /forgot_password if not present - referer = request.headers.get("referer", "/forgot_password") - - # Extract the path from the full URL - redirect_path = urlparse(referer).path - - # Add the query parameter to the redirect path - return RedirectResponse(url=f"{redirect_path}?show_form=false", status_code=303) - - -@router.post("/reset_password") -async def reset_password( - user: UserResetPassword = Depends(UserResetPassword.as_form), - session: Session = Depends(get_session) -): - authorized_user, reset_token = get_user_from_reset_token( - user.email, user.token, session) - - if not authorized_user or not reset_token: - raise InvalidResetTokenError() - - # Update password and mark token as used - if authorized_user.password: - authorized_user.password.hashed_password = get_password_hash( - user.new_password - ) - else: - logger.warning( - "User password not found during password reset; creating new password for user") - authorized_user.password = UserPassword( - hashed_password=get_password_hash(user.new_password) - ) - - reset_token.used = True - session.commit() - session.refresh(authorized_user) - - return RedirectResponse(url="/login", status_code=303) - - -@router.get("/logout", response_class=RedirectResponse) -def logout(): - response = RedirectResponse(url="/", status_code=303) - response.delete_cookie("access_token") - response.delete_cookie("refresh_token") - return response - - -@router.post("/update_email") -async def request_email_update( - update: UpdateEmail = Depends(UpdateEmail.as_form), - user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) -): - # Check if the new email is already registered - existing_user = session.exec( - select(User).where(User.email == update.new_email) - ).first() - - if existing_user: - raise EmailAlreadyRegisteredError() - - if not user.id: - raise DataIntegrityError(resource="User id") - - # Send confirmation email - send_email_update_confirmation( - current_email=user.email, - new_email=update.new_email, - user_id=user.id, - session=session - ) - - return RedirectResponse( - url="/profile?email_update_requested=true", - status_code=303 - ) - - -@router.get("/confirm_email_update") -async def confirm_email_update( - user_id: int, - token: str, - new_email: str, - session: Session = Depends(get_session) -): - user, update_token = get_user_from_email_update_token( - user_id, token, session - ) - - if not user or not update_token: - raise InvalidResetTokenError() - - # Update email and mark token as used - user.email = new_email - update_token.used = True - session.commit() - - # Create new tokens with the updated email - access_token = create_access_token(data={"sub": new_email, "fresh": True}) - refresh_token = create_refresh_token(data={"sub": new_email}) - - # Set cookies before redirecting - response = RedirectResponse( - url="/profile?email_updated=true", - status_code=303 - ) - - # Add secure cookie attributes - response.set_cookie( - key="access_token", - value=access_token, - httponly=True, - secure=True, - samesite="lax" - ) - response.set_cookie( - key="refresh_token", - value=refresh_token, - httponly=True, - secure=True, - samesite="lax" - ) - return response diff --git a/routers/user.py b/routers/user.py index a613f67..0aa5db2 100644 --- a/routers/user.py +++ b/routers/user.py @@ -2,7 +2,7 @@ from fastapi.responses import RedirectResponse, Response from sqlmodel import Session from typing import Optional -from utils.models import User, UserBase, DataIntegrityError +from utils.models import UserBase, User, DataIntegrityError from utils.auth import get_session, get_authenticated_user from utils.images import validate_and_process_image @@ -39,25 +39,21 @@ async def as_form( @router.post("/update", response_class=RedirectResponse) async def update_profile( - user_profile: UpdateUser = Depends(UpdateUser.as_form), + user_update: UpdateUser = Depends(UpdateUser.as_form), user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): # Handle avatar update - if user_profile.avatar_data: + if user_update.avatar_data: processed_image, content_type = validate_and_process_image( - user_profile.avatar_data, - user_profile.avatar_content_type + user_update.avatar_data, + user_update.avatar_content_type ) - user_profile.avatar_data = processed_image - user_profile.avatar_content_type = content_type + user.avatar_data = processed_image + user.avatar_content_type = content_type # Update user details - user.name = user_profile.name - - if user_profile.avatar_data: - user.avatar_data = user_profile.avatar_data - user.avatar_content_type = user_profile.avatar_content_type + user.name = user_update.name session.commit() session.refresh(user) diff --git a/utils/models.py b/utils/models.py index 09d904e..6f8e654 100644 --- a/utils/models.py +++ b/utils/models.py @@ -36,24 +36,75 @@ def __init__( ) -# ---- Base Models ---- +# --- Private database models --- -class AccountBase(SQLModel): + +# TODO: Handle password hashing and checking on the data model? +class Account(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) email: EmailStr = Field(index=True, unique=True) hashed_password: str + created_at: datetime = Field(default_factory=utc_time) + updated_at: datetime = Field(default_factory=utc_time) - -class UserBase(SQLModel): - name: Optional[str] = None - avatar_data: Optional[bytes] = Field( - default=None, sa_column=Column(LargeBinary) + user: Mapped[Optional["User"]] = Relationship( + back_populates="account", + sa_relationship_kwargs={ + "cascade": "all, delete-orphan" + } ) - avatar_content_type: Optional[str] = Field( - default=None + password_reset_tokens: Mapped[List["PasswordResetToken"]] = Relationship( + back_populates="account", + sa_relationship_kwargs={ + "cascade": "all, delete-orphan" + } + ) + email_update_tokens: Mapped[List["EmailUpdateToken"]] = Relationship( + back_populates="account", + sa_relationship_kwargs={ + "cascade": "all, delete-orphan" + } ) +class PasswordResetToken(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user_id: Optional[int] = Field(foreign_key="user.id") + token: str = Field(default_factory=lambda: str( + uuid4()), index=True, unique=True) + expires_at: datetime = Field( + default_factory=lambda: datetime.now(UTC) + timedelta(hours=1)) + used: bool = Field(default=False) + + account: Mapped[Optional[Account]] = Relationship( + back_populates="password_reset_tokens") + + def is_expired(self) -> bool: + """ + Check if the token has expired + """ + return datetime.now(UTC) > self.expires_at.replace(tzinfo=UTC) + + +class EmailUpdateToken(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user_id: Optional[int] = Field(foreign_key="user.id") + token: str = Field(default_factory=lambda: str( + uuid4()), index=True, unique=True) + expires_at: datetime = Field( + default_factory=lambda: datetime.now(UTC) + timedelta(hours=1)) + used: bool = Field(default=False) + + account: Mapped[Optional[Account]] = Relationship( + back_populates="email_update_tokens") + + def is_expired(self) -> bool: + """ + Check if the token has expired + """ + return datetime.now(UTC) > self.expires_at.replace(tzinfo=UTC) + -# --- Database models --- +# --- Public database models --- default_roles = ["Owner", "Administrator", "Member"] @@ -85,22 +136,63 @@ class RolePermissionLink(SQLModel, table=True): foreign_key="permission.id", primary_key=True) -class Permission(SQLModel, table=True): - """ - Represents a permission that can be assigned to a role. Should not be - modified unless the application logic and ValidPermissions enum change. - """ +class UserBase(SQLModel): + name: Optional[str] = None + avatar_data: Optional[bytes] = Field( + default=None, sa_column=Column(LargeBinary) + ) + avatar_content_type: Optional[str] = Field( + default=None + ) + + +# TODO: Prevent deleting a user who is sole owner of an organization +# TODO: Automate change of updated_at when user is updated +class User(UserBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) - name: ValidPermissions = Field( - sa_column=Column(SQLAlchemyEnum(ValidPermissions, create_type=False))) created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) + account_id: Optional[int] = Field(foreign_key="account.id", unique=True) + account: Mapped[Optional[Account]] = Relationship( + back_populates="user" + ) roles: Mapped[List["Role"]] = Relationship( - back_populates="permissions", - link_model=RolePermissionLink + back_populates="users", + link_model=UserRoleLink ) + @property + def organizations(self) -> List["Organization"]: + """ + Returns all organizations the user belongs to via their roles. + """ + organizations = [] + organization_ids = set() + for role in self.roles: + if role.organization_id not in organization_ids: + organizations.append(role.organization) + organization_ids.add(role.organization_id) + return organizations + + def has_permission(self, permission: ValidPermissions, organization: Union["Organization", int]) -> bool: + """ + Check if the user has a specific permission for a given organization. + """ + organization_id: Optional[int] = None + if isinstance(organization, Organization): + organization_id = organization.id + else: + organization_id = organization + + if not organization_id: + raise DataIntegrityError(resource="Organization ID") + + for role in self.roles: + if role.organization_id == organization_id: + return permission in [perm.name for perm in role.permissions] + return False + class Organization(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) @@ -150,7 +242,7 @@ class Role(SQLModel, table=True): updated_at: datetime = Field(default_factory=utc_time) organization: Mapped[Organization] = Relationship(back_populates="roles") - users: Mapped[List["User"]] = Relationship( + users: Mapped[List[User]] = Relationship( back_populates="roles", link_model=UserRoleLink ) @@ -159,115 +251,18 @@ class Role(SQLModel, table=True): link_model=RolePermissionLink ) - -class PasswordResetToken(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - user_id: Optional[int] = Field(foreign_key="user.id") - token: str = Field(default_factory=lambda: str( - uuid4()), index=True, unique=True) - expires_at: datetime = Field( - default_factory=lambda: datetime.now(UTC) + timedelta(hours=1)) - used: bool = Field(default=False) - - user: Mapped[Optional["User"]] = Relationship( - back_populates="password_reset_tokens") - - def is_expired(self) -> bool: - """ - Check if the token has expired - """ - return datetime.now(UTC) > self.expires_at.replace(tzinfo=UTC) - - -class EmailUpdateToken(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - user_id: Optional[int] = Field(foreign_key="user.id") - token: str = Field(default_factory=lambda: str( - uuid4()), index=True, unique=True) - expires_at: datetime = Field( - default_factory=lambda: datetime.now(UTC) + timedelta(hours=1)) - used: bool = Field(default=False) - - user: Mapped[Optional["User"]] = Relationship( - back_populates="email_update_tokens") - - def is_expired(self) -> bool: - """ - Check if the token has expired - """ - return datetime.now(UTC) > self.expires_at.replace(tzinfo=UTC) - - -class Account(AccountBase, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - created_at: datetime = Field(default_factory=utc_time) - updated_at: datetime = Field(default_factory=utc_time) - - user_id: Optional[int] = Field(foreign_key="user.id", unique=True) - - user: Mapped[Optional["User"]] = Relationship( - back_populates="account", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan", - "single_parent": True - } - ) - - -# TODO: Prevent deleting a user who is sole owner of an organization -# TODO: Automate change of updated_at when user is updated -class User(UserBase, table=True): +class Permission(SQLModel, table=True): + """ + Represents a permission that can be assigned to a role. Should not be + modified unless the application logic and ValidPermissions enum change. + """ id: Optional[int] = Field(default=None, primary_key=True) + name: ValidPermissions = Field( + sa_column=Column(SQLAlchemyEnum(ValidPermissions, create_type=False))) created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) roles: Mapped[List[Role]] = Relationship( - back_populates="users", - link_model=UserRoleLink - ) - password_reset_tokens: Mapped[List["PasswordResetToken"]] = Relationship( - back_populates="user", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan" - } - ) - email_update_tokens: Mapped[List["EmailUpdateToken"]] = Relationship( - back_populates="user", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan" - } - ) - account: Mapped[Optional[Account]] = Relationship( - back_populates="user" - ) - - @property - def organizations(self) -> List[Organization]: - """ - Returns all organizations the user belongs to via their roles. - """ - organizations = [] - organization_ids = set() - for role in self.roles: - if role.organization_id not in organization_ids: - organizations.append(role.organization) - organization_ids.add(role.organization_id) - return organizations - - def has_permission(self, permission: ValidPermissions, organization: Union[Organization, int]) -> bool: - """ - Check if the user has a specific permission for a given organization. - """ - organization_id: Optional[int] = None - if isinstance(organization, Organization): - organization_id = organization.id - else: - organization_id = organization - - if not organization_id: - raise DataIntegrityError(resource="Organization ID") - - for role in self.roles: - if role.organization_id == organization_id: - return permission in [perm.name for perm in role.permissions] - return False + back_populates="permissions", + link_model=RolePermissionLink + ) \ No newline at end of file From 92240fc8ae71ca853857ef22e5acf65564dde987 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Thu, 13 Mar 2025 10:44:18 -0400 Subject: [PATCH 04/17] Moved enums and exceptions to separate files --- exceptions/exceptions.py | 8 +++ exceptions/http_exceptions.py | 130 ++++++++++++++++++++++++++++++++++ routers/account.py | 24 ++----- routers/organization.py | 34 ++------- routers/role.py | 43 +---------- utils/auth.py | 49 +------------ utils/db.py | 9 ++- utils/enums.py | 11 +++ utils/images.py | 13 ++-- utils/models.py | 34 +-------- 10 files changed, 181 insertions(+), 174 deletions(-) create mode 100644 exceptions/exceptions.py create mode 100644 exceptions/http_exceptions.py create mode 100644 utils/enums.py diff --git a/exceptions/exceptions.py b/exceptions/exceptions.py new file mode 100644 index 0000000..65637f3 --- /dev/null +++ b/exceptions/exceptions.py @@ -0,0 +1,8 @@ +from utils.models import User + + +class NeedsNewTokens(Exception): + def __init__(self, user: User, access_token: str, refresh_token: str): + self.user = user + self.access_token = access_token + self.refresh_token = refresh_token diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py new file mode 100644 index 0000000..3db361d --- /dev/null +++ b/exceptions/http_exceptions.py @@ -0,0 +1,130 @@ +from fastapi import HTTPException, status +from utils.enums import ValidPermissions + +class EmailAlreadyRegisteredError(HTTPException): + def __init__(self): + super().__init__( + status_code=409, + detail="This email is already registered" + ) + + +class CredentialsError(HTTPException): + def __init__(self, message: str = "Invalid credentials"): + super().__init__( + status_code=401, + detail=message + ) + + +class AuthenticationError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_303_SEE_OTHER, + headers={"Location": "/login"} + ) + + +class PasswordValidationError(HTTPException): + def __init__(self, field: str, message: str): + super().__init__( + status_code=422, + detail={ + "field": field, + "message": message + } + ) + + +class PasswordMismatchError(PasswordValidationError): + def __init__(self, field: str = "confirm_password"): + super().__init__( + field=field, + message="The passwords you entered do not match" + ) + + +class InsufficientPermissionsError(HTTPException): + def __init__(self): + super().__init__( + status_code=403, + detail="You don't have permission to perform this action" + ) + + +class EmptyOrganizationNameError(HTTPException): + def __init__(self): + super().__init__( + status_code=400, + detail="Organization name cannot be empty" + ) + + +class OrganizationNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=404, + detail="Organization not found" + ) + + +class OrganizationNameTakenError(HTTPException): + def __init__(self): + super().__init__( + status_code=400, + detail="Organization name already taken" + ) + + +class InvalidPermissionError(HTTPException): + """Raised when a user attempts to assign an invalid permission to a role""" + + def __init__(self, permission: ValidPermissions): + super().__init__( + status_code=400, + detail=f"Invalid permission: {permission}" + ) + + +class RoleAlreadyExistsError(HTTPException): + """Raised when attempting to create a role with a name that already exists""" + + def __init__(self): + super().__init__(status_code=400, detail="Role already exists") + + +class RoleNotFoundError(HTTPException): + """Raised when a requested role does not exist""" + + def __init__(self): + super().__init__(status_code=404, detail="Role not found") + + +class RoleHasUsersError(HTTPException): + """Raised when a requested role to be deleted has users""" + + def __init__(self): + super().__init__( + status_code=400, + detail="Role cannot be deleted until users with that role are reassigned" + ) + + +class DataIntegrityError(HTTPException): + def __init__( + self, + resource: str = "Database resource" + ): + super().__init__( + status_code=500, + detail=( + f"{resource} is in a broken state; please contact a system administrator" + ) + ) + + +class InvalidImageError(HTTPException): + """Raised when an invalid image is uploaded""" + + def __init__(self, message: str = "Invalid image file"): + super().__init__(status_code=400, detail=message) \ No newline at end of file diff --git a/routers/account.py b/routers/account.py index 3e6aa18..75c8bdc 100644 --- a/routers/account.py +++ b/routers/account.py @@ -3,7 +3,7 @@ from typing import Optional from urllib.parse import urlparse from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Form, Request +from fastapi import APIRouter, Depends, BackgroundTasks, Form, Request from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel, EmailStr, ConfigDict @@ -28,6 +28,7 @@ PasswordValidationError, get_optional_user ) +from exceptions.http_exceptions import EmailAlreadyRegisteredError, CredentialsError logger = getLogger("uvicorn.error") @@ -37,20 +38,7 @@ # --- Custom Exceptions --- -class EmailAlreadyRegisteredError(HTTPException): - def __init__(self): - super().__init__( - status_code=409, - detail="This email is already registered" - ) - -class AuthenticationError(HTTPException): - def __init__(self, message: str = "Invalid credentials"): - super().__init__( - status_code=401, - detail=message - ) # --- Server Request and Response Models --- @@ -246,7 +234,7 @@ async def read_reset_password( # Raise informative error to let user know the token is invalid and may have expired if not authorized_user: - raise HTTPException(status_code=400, detail="Invalid or expired token") + raise CredentialsError(message="Invalid or expired token") return templates.TemplateResponse( "authentication/reset_password.html", @@ -310,7 +298,7 @@ async def login( User.email == user.email)).first() if not db_user or not db_user.password or not verify_password(user.password, db_user.password.hashed_password): - raise AuthenticationError() + raise CredentialsError() # Create access token access_token = create_access_token( @@ -416,7 +404,7 @@ async def reset_password( user.email, user.token, session) if not authorized_user or not reset_token: - raise AuthenticationError("Invalid or expired password reset token; please request a new one") + raise CredentialsError("Invalid or expired password reset token; please request a new one") # Update password and mark token as used if authorized_user.password: @@ -488,7 +476,7 @@ async def confirm_email_update( ) if not user or not update_token: - raise AuthenticationError("Invalid or expired email update token; please request a new one") + raise CredentialsError("Invalid or expired email update token; please request a new one") # Update email and mark token as used user.email = new_email diff --git a/routers/organization.py b/routers/organization.py index 162a998..4c70766 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -1,13 +1,14 @@ from logging import getLogger -from fastapi import APIRouter, Depends, HTTPException, Form, Request +from datetime import datetime +from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel, ConfigDict, field_validator from sqlmodel import Session, select from utils.db import get_session -from utils.auth import get_authenticated_user, get_user_with_relations, InsufficientPermissionsError +from utils.auth import get_authenticated_user, get_user_with_relations from utils.models import Organization, User, Role, utc_time, default_roles, ValidPermissions -from datetime import datetime +from exceptions.http_exceptions import EmptyOrganizationNameError, OrganizationNotFoundError, OrganizationNameTakenError, InsufficientPermissionsError logger = getLogger("uvicorn.error") @@ -15,33 +16,6 @@ templates = Jinja2Templates(directory="templates") -# --- Custom Exceptions --- - - -class EmptyOrganizationNameError(HTTPException): - def __init__(self): - super().__init__( - status_code=400, - detail="Organization name cannot be empty" - ) - - -class OrganizationNotFoundError(HTTPException): - def __init__(self): - super().__init__( - status_code=404, - detail="Organization not found" - ) - - -class OrganizationNameTakenError(HTTPException): - def __init__(self): - super().__init__( - status_code=400, - detail="Organization name already taken" - ) - - # --- Server Request and Response Models --- diff --git a/routers/role.py b/routers/role.py index 1e939c4..e69fe4f 100644 --- a/routers/role.py +++ b/routers/role.py @@ -2,57 +2,20 @@ # they themselves have. from typing import List, Sequence, Optional from logging import getLogger -from fastapi import APIRouter, Depends, Form, HTTPException +from fastapi import APIRouter, Depends, Form from fastapi.responses import RedirectResponse from pydantic import BaseModel, ConfigDict from sqlmodel import Session, select, col from sqlalchemy.orm import selectinload from utils.db import get_session -from utils.auth import get_authenticated_user, InsufficientPermissionsError +from utils.auth import get_authenticated_user from utils.models import Role, Permission, ValidPermissions, utc_time, User, DataIntegrityError +from exceptions.http_exceptions import InsufficientPermissionsError, InvalidPermissionError, RoleAlreadyExistsError, RoleNotFoundError, RoleHasUsersError logger = getLogger("uvicorn.error") router = APIRouter(prefix="/roles", tags=["roles"]) - -# --- Custom Exceptions --- - - -class InvalidPermissionError(HTTPException): - """Raised when a user attempts to assign an invalid permission to a role""" - - def __init__(self, permission: ValidPermissions): - super().__init__( - status_code=400, - detail=f"Invalid permission: {permission}" - ) - - -class RoleAlreadyExistsError(HTTPException): - """Raised when attempting to create a role with a name that already exists""" - - def __init__(self): - super().__init__(status_code=400, detail="Role already exists") - - -class RoleNotFoundError(HTTPException): - """Raised when a requested role does not exist""" - - def __init__(self): - super().__init__(status_code=404, detail="Role not found") - - -class RoleHasUsersError(HTTPException): - """Raised when a requested role to be deleted has users""" - - def __init__(self): - super().__init__( - status_code=400, - detail="Role cannot be deleted until users with that role are reassigned" - ) - - # --- Server Request Models --- class RoleCreate(BaseModel): diff --git a/utils/auth.py b/utils/auth.py index 33ffa83..2d87add 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -14,9 +14,11 @@ from typing import Optional from jinja2.environment import Template from fastapi.templating import Jinja2Templates -from fastapi import Depends, Cookie, HTTPException, status, Request +from fastapi import Depends, Cookie from utils.db import get_session from utils.models import User, Role, PasswordResetToken, EmailUpdateToken +from exceptions.http_exceptions import PasswordValidationError, PasswordMismatchError, AuthenticationError +from exceptions.exceptions import NeedsNewTokens load_dotenv() resend.api_key = os.environ["RESEND_API_KEY"] @@ -84,51 +86,6 @@ def replacer(match: re.Match) -> str: ) -# --- Custom Exceptions --- - - -class NeedsNewTokens(Exception): - def __init__(self, user: User, access_token: str, refresh_token: str): - self.user = user - self.access_token = access_token - self.refresh_token = refresh_token - - -class AuthenticationError(HTTPException): - def __init__(self): - super().__init__( - status_code=status.HTTP_303_SEE_OTHER, - headers={"Location": "/login"} - ) - - -class PasswordValidationError(HTTPException): - def __init__(self, field: str, message: str): - super().__init__( - status_code=422, - detail={ - "field": field, - "message": message - } - ) - - -class PasswordMismatchError(PasswordValidationError): - def __init__(self, field: str = "confirm_password"): - super().__init__( - field=field, - message="The passwords you entered do not match" - ) - - -class InsufficientPermissionsError(HTTPException): - def __init__(self): - super().__init__( - status_code=403, - detail="You don't have permission to perform this action" - ) - - # --- Helpers --- diff --git a/utils/db.py b/utils/db.py index 98e3cc8..0157b50 100644 --- a/utils/db.py +++ b/utils/db.py @@ -4,7 +4,8 @@ from dotenv import load_dotenv from sqlalchemy.engine import URL from sqlmodel import create_engine, Session, SQLModel, select -from utils.models import Role, Permission, RolePermissionLink, default_roles, ValidPermissions +from utils.models import Role, Permission, RolePermissionLink +from utils.enums import ValidPermissions # Load environment variables from a .env file load_dotenv() @@ -13,6 +14,12 @@ logger = logging.getLogger("uvicorn.error") +# --- Constants --- + + +default_roles = ["Owner", "Administrator", "Member"] + + # --- Database connection functions --- diff --git a/utils/enums.py b/utils/enums.py new file mode 100644 index 0000000..878d9ef --- /dev/null +++ b/utils/enums.py @@ -0,0 +1,11 @@ +from enum import Enum + +class ValidPermissions(Enum): + DELETE_ORGANIZATION = "Delete Organization" + EDIT_ORGANIZATION = "Edit Organization" + INVITE_USER = "Invite User" + REMOVE_USER = "Remove User" + EDIT_USER_ROLE = "Edit User Role" + CREATE_ROLE = "Create Role" + DELETE_ROLE = "Delete Role" + EDIT_ROLE = "Edit Role" \ No newline at end of file diff --git a/utils/images.py b/utils/images.py index 67bfaf4..3e3be2f 100644 --- a/utils/images.py +++ b/utils/images.py @@ -1,10 +1,13 @@ # utils/images.py -from fastapi import HTTPException from PIL import Image import io from typing import Tuple +from exceptions.http_exceptions import InvalidImageError + + +# --- Constants --- + -# Constants MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB in bytes ALLOWED_CONTENT_TYPES = { 'image/jpeg': 'JPEG', @@ -15,11 +18,7 @@ MAX_DIMENSION = 2000 -class InvalidImageError(HTTPException): - """Raised when an invalid image is uploaded""" - - def __init__(self, message: str = "Invalid image file"): - super().__init__(status_code=400, detail=message) +# --- Functions --- def validate_and_process_image( diff --git a/utils/models.py b/utils/models.py index 6f8e654..98e47ff 100644 --- a/utils/models.py +++ b/utils/models.py @@ -1,13 +1,13 @@ from logging import getLogger, DEBUG -from enum import Enum from uuid import uuid4 from datetime import datetime, UTC, timedelta from typing import Optional, List, Union -from fastapi import HTTPException from pydantic import EmailStr from sqlmodel import SQLModel, Field, Relationship from sqlalchemy import Column, Enum as SQLAlchemyEnum, LargeBinary from sqlalchemy.orm import Mapped +from utils.enums import ValidPermissions +from exceptions.http_exceptions import DataIntegrityError logger = getLogger("uvicorn.error") logger.setLevel(DEBUG) @@ -20,22 +20,6 @@ def utc_time(): return datetime.now(UTC) -# --- Custom exceptions --- - - -class DataIntegrityError(HTTPException): - def __init__( - self, - resource: str = "Database resource" - ): - super().__init__( - status_code=500, - detail=( - f"{resource} is in a broken state; please contact a system administrator" - ) - ) - - # --- Private database models --- @@ -107,20 +91,6 @@ def is_expired(self) -> bool: # --- Public database models --- -default_roles = ["Owner", "Administrator", "Member"] - - -class ValidPermissions(Enum): - DELETE_ORGANIZATION = "Delete Organization" - EDIT_ORGANIZATION = "Edit Organization" - INVITE_USER = "Invite User" - REMOVE_USER = "Remove User" - EDIT_USER_ROLE = "Edit User Role" - CREATE_ROLE = "Create Role" - DELETE_ROLE = "Delete Role" - EDIT_ROLE = "Edit Role" - - class UserRoleLink(SQLModel, table=True): """ Associates users with roles. This creates a many-to-many relationship From 694bb2f62b801b521a604c21056c527aa700ef8f Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Thu, 13 Mar 2025 17:36:15 -0400 Subject: [PATCH 05/17] Mostly complete refactor --- routers/account.py | 370 ++++++++---------- templates/authentication/reset_password.html | 14 +- utils/auth.py | 177 +-------- utils/dependencies.py | 379 +++++++++++++++++++ utils/models.py | 4 +- 5 files changed, 573 insertions(+), 371 deletions(-) create mode 100644 utils/dependencies.py diff --git a/routers/account.py b/routers/account.py index 75c8bdc..43bfc21 100644 --- a/routers/account.py +++ b/routers/account.py @@ -1,20 +1,16 @@ # auth.py from logging import getLogger -from typing import Optional +from typing import Optional, Tuple from urllib.parse import urlparse -from datetime import datetime from fastapi import APIRouter, Depends, BackgroundTasks, Form, Request from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates -from pydantic import BaseModel, EmailStr, ConfigDict +from pydantic import EmailStr from sqlmodel import Session, select -from utils.models import User, Account, DataIntegrityError, User +from utils.models import User, DataIntegrityError, Account from utils.db import get_session from utils.auth import ( HTML_PASSWORD_PATTERN, - get_user_from_reset_token, - create_password_validator, - create_passwords_match_validator, oauth2_scheme_cookie, get_password_hash, verify_password, @@ -23,168 +19,95 @@ validate_token, send_reset_email, send_email_update_confirmation, - get_user_from_email_update_token, - get_authenticated_user, - PasswordValidationError, - get_optional_user + create_password_validator, + create_passwords_match_validator +) +from utils.dependencies import ( + get_authenticated_account, + get_optional_user, + get_account_from_reset_token, + get_account_from_email_update_token, + get_account_from_credentials +) +from exceptions.http_exceptions import ( + EmailAlreadyRegisteredError, + CredentialsError, + PasswordValidationError ) -from exceptions.http_exceptions import EmailAlreadyRegisteredError, CredentialsError logger = getLogger("uvicorn.error") router = APIRouter(prefix="/account", tags=["account"]) templates = Jinja2Templates(directory="templates") -# --- Custom Exceptions --- - - - - - -# --- Server Request and Response Models --- - - -class DeleteAccount(Account): - @classmethod - async def as_form( - cls, - email: EmailStr = Form(...), - password: str = Form(...), - ): - return cls(email=email, password=password) - - -class CreateAccount(Account): - name: str - password: str - confirm_password: str - - validate_password_strength = create_password_validator("password") - validate_passwords_match = create_passwords_match_validator( - "password", "confirm_password") - - @classmethod - async def as_form( - cls, - name: str = Form(...), - email: EmailStr = Form(...), - password: str = Form(...), - confirm_password: str = Form(...) - ): - return cls( - name=name, - email=email, - password=password, - confirm_password=confirm_password - ) - - -class UserLogin(BaseModel): - email: EmailStr - password: str - - @classmethod - async def as_form( - cls, - email: EmailStr = Form(...), - password: str = Form(...) - ): - return cls(email=email, password=password) - - -class UserForgotPassword(BaseModel): - email: EmailStr - - @classmethod - async def as_form( - cls, - email: EmailStr = Form(...) - ): - return cls(email=email) - -class UserResetPassword(BaseModel): - email: EmailStr - token: str - new_password: str - confirm_new_password: str - - # Use the factory with a different field name - validate_password_strength = create_password_validator("new_password") - validate_passwords_match = create_passwords_match_validator( - "new_password", "confirm_new_password") - - @classmethod - async def as_form( - cls, - email: EmailStr = Form(...), - token: str = Form(...), - new_password: str = Form(...), - confirm_new_password: str = Form(...) - ): - return cls(email=email, token=token, - new_password=new_password, confirm_new_password=confirm_new_password) - - -class UpdateEmail(BaseModel): - new_email: EmailStr - - @classmethod - async def as_form( - cls, - new_email: EmailStr = Form(...) - ): - return cls(new_email=new_email) - - -class UserRead(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - name: str - email: EmailStr - organization_id: Optional[int] - created_at: datetime - updated_at: datetime +# --- Route-specific dependencies --- + + +def validate_password_strength_and_match( + password: str = Form(...), + confirm_password: str = Form(...) +) -> None: + """ + Validates password strength and confirms passwords match. + + Args: + password: Password from form + confirm_password: Confirmation password from form + + Raises: + PasswordValidationError: If password is weak or passwords don't match + """ + # Validate password strength + validator = create_password_validator("password") + validator({"password": password}) + + # Validate passwords match + match_validator = create_passwords_match_validator("password", "confirm_password") + match_validator({"password": password, "confirm_password": confirm_password}) # --- Routes --- -# TODO: Check the email too @router.post("/delete", response_class=RedirectResponse) async def delete_account( - user_delete_account: DeleteAccount = Depends( - DeleteAccount.as_form), - user: User = Depends(get_authenticated_user), + email: EmailStr = Form(...), + password: str = Form(...), + account: Account = Depends(get_authenticated_account), session: Session = Depends(get_session) ): - if not user.password: - raise DataIntegrityError( - resource="User password" - ) - - if not verify_password( - user_delete_account.password, - user.password.hashed_password - ): + """ + Delete a user account after verifying credentials. + """ + # Verify the provided email matches the authenticated user + if email != account.email: + raise CredentialsError(message="Email does not match authenticated account") + + # Verify password + if not verify_password(password, account.hashed_password): raise PasswordValidationError( field="password", message="Password is incorrect" ) - # Delete the user - session.delete(user) + # Delete the account + session.delete(account) session.commit() # Log out the user return RedirectResponse(url="/auth/logout", status_code=303) + + @router.get("/login") async def read_login( request: Request, user: Optional[User] = Depends(get_optional_user), email_updated: Optional[str] = "false" ): + """ + Render login page or redirect to dashboard if already logged in. + """ if user: return RedirectResponse(url="/dashboard", status_code=302) return templates.TemplateResponse( @@ -198,6 +121,9 @@ async def read_register( request: Request, user: Optional[User] = Depends(get_optional_user) ): + """ + Render registration page or redirect to dashboard if already logged in. + """ if user: return RedirectResponse(url="/dashboard", status_code=302) @@ -213,6 +139,9 @@ async def read_forgot_password( user: Optional[User] = Depends(get_optional_user), show_form: Optional[str] = "true", ): + """ + Render forgot password page or redirect to dashboard if already logged in. + """ if user: return RedirectResponse(url="/dashboard", status_code=302) @@ -230,10 +159,13 @@ async def read_reset_password( user: Optional[User] = Depends(get_optional_user), session: Session = Depends(get_session) ): - authorized_user, _ = get_user_from_reset_token(email, token, session) + """ + Render reset password page after validating token. + """ + authorized_account, _ = get_account_from_reset_token(email, token, session) # Raise informative error to let user know the token is invalid and may have expired - if not authorized_user: + if not authorized_account: raise CredentialsError(message="Invalid or expired token") return templates.TemplateResponse( @@ -242,32 +174,42 @@ async def read_reset_password( ) -# TODO: Use custom error message in the case where the user is already registered @router.post("/register", response_class=RedirectResponse) async def register( - user: UserRegister = Depends(UserRegister.as_form), + name: str = Form(...), + email: EmailStr = Form(...), session: Session = Depends(get_session), + _: None = Depends(validate_password_strength_and_match), + password: str = Form(...) ) -> RedirectResponse: + """ + Register a new user account. + """ # Check if the email is already registered - db_user = session.exec(select(User).where( - User.email == user.email)).first() + account: Optional[Account] = session.exec(select(Account).where( + Account.email == email)).one_or_none() - if db_user: + if account: raise EmailAlreadyRegisteredError() # Hash the password - hashed_password = get_password_hash(user.password) + hashed_password = get_password_hash(password) + # Create the account + account = Account(email=email, hashed_password=hashed_password) + session.add(account) + session.flush() # Flush to get the account ID + # Create the user - db_user = User(name=user.name, email=user.email, - password=UserPassword(hashed_password=hashed_password)) - session.add(db_user) + account.user = User(name=name) + session.add(account) session.commit() - session.refresh(db_user) + session.refresh(account) # Create access token - access_token = create_access_token(data={"sub": db_user.email}) - refresh_token = create_refresh_token(data={"sub": db_user.email}) + access_token = create_access_token(data={"sub": email}) + refresh_token = create_refresh_token(data={"sub": email}) + # Set cookie response = RedirectResponse(url="/", status_code=303) response.set_cookie( @@ -290,20 +232,18 @@ async def register( @router.post("/login", response_class=RedirectResponse) async def login( - user: UserLogin = Depends(UserLogin.as_form), - session: Session = Depends(get_session), + account_and_session: Tuple[Account, Session] = Depends(get_account_from_credentials) ) -> RedirectResponse: - # Check if the email is registered - db_user = session.exec(select(User).where( - User.email == user.email)).first() - - if not db_user or not db_user.password or not verify_password(user.password, db_user.password.hashed_password): - raise CredentialsError() + """ + Log in a user with valid credentials. + """ + account, session = account_and_session # Create access token access_token = create_access_token( - data={"sub": db_user.email, "fresh": True}) - refresh_token = create_refresh_token(data={"sub": db_user.email}) + data={"sub": account.email, "fresh": True} + ) + refresh_token = create_refresh_token(data={"sub": account.email}) # Set cookie response = RedirectResponse(url="/", status_code=303) @@ -328,10 +268,12 @@ async def login( # Updated refresh_token endpoint @router.post("/refresh", response_class=RedirectResponse) async def refresh_token( - tokens: tuple[Optional[str], Optional[str] - ] = Depends(oauth2_scheme_cookie), + tokens: tuple[Optional[str], Optional[str]] = Depends(oauth2_scheme_cookie), session: Session = Depends(get_session), ) -> RedirectResponse: + """ + Refresh the access token using a valid refresh token. + """ _, refresh_token = tokens if not refresh_token: return RedirectResponse(url="/login", status_code=303) @@ -344,14 +286,15 @@ async def refresh_token( return response user_email = decoded_token.get("sub") - db_user = session.exec(select(User).where( - User.email == user_email)).first() - if not db_user: + account = session.exec(select(Account).where( + Account.email == user_email)).one_or_none() + if not account: return RedirectResponse(url="/login", status_code=303) new_access_token = create_access_token( - data={"sub": db_user.email, "fresh": False}) - new_refresh_token = create_refresh_token(data={"sub": db_user.email}) + data={"sub": account.email, "fresh": False} + ) + new_refresh_token = create_refresh_token(data={"sub": account.email}) response = RedirectResponse(url="/", status_code=303) response.set_cookie( @@ -376,14 +319,18 @@ async def refresh_token( async def forgot_password( background_tasks: BackgroundTasks, request: Request, - user: UserForgotPassword = Depends(UserForgotPassword.as_form), + email: EmailStr = Form(...), session: Session = Depends(get_session) ): - db_user = session.exec(select(User).where( - User.email == user.email)).first() + """ + Send a password reset email to the user. + """ + # TODO: Make this a dependency? + account = session.exec(select(Account).where( + Account.email == email)).one_or_none() - if db_user: - background_tasks.add_task(send_reset_email, user.email, session) + if account: + background_tasks.add_task(send_reset_email, email, session) # Get the referer header, default to /forgot_password if not present referer = request.headers.get("referer", "/forgot_password") @@ -397,36 +344,39 @@ async def forgot_password( @router.post("/reset_password") async def reset_password( - user: UserResetPassword = Depends(UserResetPassword.as_form), + email: EmailStr = Form(...), + token: str = Form(...), + # TODO: Just return validated new password or even hashed password from the validator + _: None = Depends(validate_password_strength_and_match), + new_password: str = Form(...), session: Session = Depends(get_session) ): - authorized_user, reset_token = get_user_from_reset_token( - user.email, user.token, session) + """ + Reset a user's password using a valid token. + """ + # TODO: Make this a dependency? + authorized_account, reset_token = get_account_from_reset_token( + email, token, session + ) - if not authorized_user or not reset_token: + if not authorized_account or not reset_token: raise CredentialsError("Invalid or expired password reset token; please request a new one") # Update password and mark token as used - if authorized_user.password: - authorized_user.password.hashed_password = get_password_hash( - user.new_password - ) - else: - logger.warning( - "User password not found during password reset; creating new password for user") - authorized_user.password = UserPassword( - hashed_password=get_password_hash(user.new_password) - ) + authorized_account.hashed_password = get_password_hash(new_password) reset_token.used = True session.commit() - session.refresh(authorized_user) + session.refresh(authorized_account) return RedirectResponse(url="/login", status_code=303) @router.get("/logout", response_class=RedirectResponse) def logout(): + """ + Log out a user by clearing their cookies. + """ response = RedirectResponse(url="/", status_code=303) response.delete_cookie("access_token") response.delete_cookie("refresh_token") @@ -435,26 +385,37 @@ def logout(): @router.post("/update_email") async def request_email_update( - update: UpdateEmail = Depends(UpdateEmail.as_form), - user: User = Depends(get_authenticated_user), + email: EmailStr = Form(...), + new_email: EmailStr = Form(...), + account: Account = Depends(get_authenticated_account), session: Session = Depends(get_session) ): + """ + Request to update a user's email address. + """ + # Verify the provided email matches the authenticated user + if email != account.email: + raise CredentialsError(message="Email does not match authenticated user") + + if email == new_email: + raise CredentialsError(message="New email is the same as the current email") + # Check if the new email is already registered existing_user = session.exec( - select(User).where(User.email == update.new_email) + select(Account.id).where(Account.email == new_email) ).first() if existing_user: raise EmailAlreadyRegisteredError() - if not user.id: - raise DataIntegrityError(resource="User id") + if not account.id: + raise DataIntegrityError(resource="Account id") # Send confirmation email send_email_update_confirmation( - current_email=user.email, - new_email=update.new_email, - user_id=user.id, + current_email=email, + new_email=new_email, + account_id=account.id, session=session ) @@ -466,20 +427,23 @@ async def request_email_update( @router.get("/confirm_email_update") async def confirm_email_update( - user_id: int, + account_id: int, token: str, new_email: str, session: Session = Depends(get_session) ): - user, update_token = get_user_from_email_update_token( - user_id, token, session + """ + Confirm an email update using a valid token. + """ + # TODO: Just eager load the update token with the account + account, update_token = get_account_from_email_update_token( + account_id, token, session ) - if not user or not update_token: + if not account or not update_token: raise CredentialsError("Invalid or expired email update token; please request a new one") - - # Update email and mark token as used - user.email = new_email + + account.email = new_email update_token.used = True session.commit() diff --git a/templates/authentication/reset_password.html b/templates/authentication/reset_password.html index bf6884b..d7fb341 100644 --- a/templates/authentication/reset_password.html +++ b/templates/authentication/reset_password.html @@ -8,15 +8,15 @@
- +
- - New Password + @@ -27,8 +27,8 @@
- - Confirm New Password +
Passwords do not match. @@ -47,8 +47,8 @@