diff --git a/backend/.gitignore b/backend/.gitignore index 64d49ae..d27070e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -213,4 +213,14 @@ marimo/_lsp/ __marimo__/ # Streamlit -.streamlit/secrets.toml \ No newline at end of file +.streamlit/secrets.toml + + + +# ReadMe Specific Gitignores +# -------------------------- + + + +# database files +*.db \ No newline at end of file diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index a68d117..3b438a9 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,7 +1,7 @@ # Copyright (c) 2026 Lachlan Harris. All Rights Reserved. # This project is licensed under Apache 2.0 # -# src/api/v1/api.py +# app/api/v1/api.py # Primary API router for V1. This file can be considered the # "main" file for the API diff --git a/backend/app/api/v1/auth/auth.py b/backend/app/api/v1/auth/auth.py index ed8be0f..d047106 100644 --- a/backend/app/api/v1/auth/auth.py +++ b/backend/app/api/v1/auth/auth.py @@ -1,7 +1,7 @@ # Copyright (c) 2026 Lachlan Harris. All Rights Reserved. # This project is licensed under Apache 2.0 # -# src/api/v1/auth/auth.py +# app/api/v1/auth/auth.py # Handles authentication logic from .config import * @@ -15,15 +15,27 @@ from pwdlib import PasswordHash from typing import Annotated +from sqlmodel import select + +from ..db import SessionDep +from ..models import UserDB + # Database interaction # -------------------- def get_user(db, username: str): - if username in db: - user_dict = db[username] - return UserInDB(**user_dict) + statement = select(UserDB).where(UserDB.username == username) + user = db.exec(statement).first() + if not user: + return None + return UserInDB( + id=user.id, + username=user.username, + created_at=user.created_at, + password_hash=user.password_hash, + ) # Password hashing / verification @@ -38,15 +50,11 @@ def get_password_hash(password): return password_hash.hash(password) -DUMMY_HASH = password_hash.hash("dummypassword") - - -def authenticate_user(fake_db, username: str, password: str): - user = get_user(fake_db, username) +def authenticate_user(session: SessionDep, username: str, password: str): + user = get_user(session, username) if not user: - verify_password(password, DUMMY_HASH) return False - if not verify_password(password, user.hashed_password): + if not verify_password(password, user.password_hash): return False return user @@ -70,7 +78,10 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): # -------------- -async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): +async def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + session: SessionDep, +): credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -84,7 +95,7 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): token_data = TokenData(username=username) except InvalidTokenError: raise credentials_exception - user = get_user(fake_users_db, username=token_data.username) + user = get_user(session, username=token_data.username) if user is None: raise credentials_exception return user @@ -93,6 +104,4 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): async def get_current_active_user( current_user: Annotated[User, Depends(get_current_user)], ): - if current_user.disabled: - raise HTTPException(status_code=400, detail="Inactive user") return current_user diff --git a/backend/app/api/v1/auth/config.py b/backend/app/api/v1/auth/config.py index 7f58d3b..a22b1e4 100644 --- a/backend/app/api/v1/auth/config.py +++ b/backend/app/api/v1/auth/config.py @@ -1,10 +1,11 @@ # Copyright (c) 2026 Lachlan Harris. All Rights Reserved. # This project is licensed under Apache 2.0 # -# src/api/v1/auth/env.py +# app/api/v1/auth/env.py # Manages authentication-specific env variables & constants import os +from datetime import datetime from dotenv import load_dotenv @@ -29,35 +30,50 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token") -fake_users_db = { - "lachlanharris": { - "username": "lachlanharris", - "full_name": "Lachlan Harris", - "email": "contact@lachlanharris.dev", - "hashed_password": "$argon2id$v=19$m=65536,t=3,p=4$wagCPXjifgvUFBzq4hqe3w$CYaIb8sB+wtD+Vu/P4uod1+Qof8h+1g7bbDlBID48Rc", # "secret" - "disabled": False, - } -} - # Types # ----- class Token(BaseModel): + """ + Represents a JWT access token response + """ + access_token: str token_type: str class TokenData(BaseModel): + """ + Represents the data contained within a JWT access token + """ + username: str | None = None class User(BaseModel): + """ + Represents a user in the system, excluding sensitive information + for the purpose of API responses (i.e. /api/v1/auth/me) + """ + + id: int username: str - email: str | None = None - full_name: str | None = None - disabled: bool | None = None + created_at: datetime | None = None class UserInDB(User): - hashed_password: str + """ + Represents a user in the database, including sensitive information + """ + + password_hash: str + + +class UserCreate(BaseModel): + """ + Represents the data required to create a new user account + """ + + username: str + password: str diff --git a/backend/app/api/v1/auth/endpoints.py b/backend/app/api/v1/auth/endpoints.py index b7102a4..0deabcc 100644 --- a/backend/app/api/v1/auth/endpoints.py +++ b/backend/app/api/v1/auth/endpoints.py @@ -1,7 +1,7 @@ # Copyright (c) 2026 Lachlan Harris. All Rights Reserved. # This project is licensed under Apache 2.0 # -# src/api/v1/auth.py +# app/api/v1/auth.py # JWT Authentication API endpoints according to the V1 specification from .config import * @@ -14,6 +14,11 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from typing import Annotated +from sqlmodel import select + +from ..db import SessionDep +from ..models import UserDB + router = APIRouter() @@ -24,6 +29,7 @@ @router.post("/token") async def login_for_access_token( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + session: SessionDep, ) -> Token: """ Request a JWT access token by providing a username and password @@ -39,7 +45,7 @@ async def login_for_access_token( Errors: 401 Unauthorized: If the username or password is incorrect """ - user = authenticate_user(fake_users_db, form_data.username, form_data.password) + user = authenticate_user(session, form_data.username, form_data.password) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -53,6 +59,38 @@ async def login_for_access_token( return Token(access_token=access_token, token_type="bearer") +@router.post("/signup", response_model=User) +def signup(user: UserCreate, session: SessionDep) -> User: + """Register a new user. + + Requires: + username: string + password: string + + Response: + id: integer + username: string + created_at: timestamp + + Errors: + 409 Conflict: If the username already exists + """ + + existing = session.exec( + select(UserDB).where(UserDB.username == user.username) + ).first() + if existing: + raise HTTPException(status_code=409, detail="Username already exists") + + db_user = UserDB( + username=user.username, password_hash=get_password_hash(user.password) + ) + session.add(db_user) + session.commit() + session.refresh(db_user) + return User(id=db_user.id, username=db_user.username, created_at=db_user.created_at) + + # Utilities # --------- @@ -67,9 +105,8 @@ async def read_users_me( Authorization: true Response: + id: integer username: string - email: string - full_name: string - disabled: boolean + created_at: timestamp """ return current_user diff --git a/backend/app/api/v1/db.py b/backend/app/api/v1/db.py index 1794c54..34f6fb6 100644 --- a/backend/app/api/v1/db.py +++ b/backend/app/api/v1/db.py @@ -1,14 +1,51 @@ -from sqlmodel import Session, create_engine, select +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This project is licensed under Apache 2.0 +# +# app/api/v1/db/utils.py +# Database utility functions for the V1 specification + +from typing import Annotated + +from fastapi import Depends from sqlalchemy.engine import Engine +from sqlmodel import SQLModel, Session, create_engine, select from .settings import settings +def _get_connect_args(database_url: str) -> dict: + # from fastapi sql tutorial; required for SQLite across threads. + if database_url.startswith("sqlite"): + return {"check_same_thread": False} + return {} + + +engine: Engine = create_engine( + settings.database_url, + connect_args=_get_connect_args(settings.database_url), + pool_pre_ping=True, +) + + +def create_db_and_tables() -> None: + # import models to ensure registration with SQLModel + # the 'noqa' tag fixes the linter whining about the unused import + from . import models # noqa: F401 + + SQLModel.metadata.create_all(engine) + + def get_engine() -> Engine: - return create_engine( - settings.database_url, - pool_pre_ping=True, - ) + return engine + + +def get_session(): + with Session(engine) as session: + yield session + + +# SessionDep is a dependency for injecting a database session into API endpoints +SessionDep = Annotated[Session, Depends(get_session)] def ping_db(engine: Engine) -> None: diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py index 8c51b61..9e9f49a 100644 --- a/backend/app/api/v1/health.py +++ b/backend/app/api/v1/health.py @@ -1,7 +1,7 @@ # Copyright (c) 2026 Lachlan Harris. All Rights Reserved. # This project is licensed under Apache 2.0 # -# src/api/v1/health.py +# app/api/v1/health.py # Health check API endpoints according to the V1 specification from fastapi import APIRouter, Depends, HTTPException diff --git a/backend/app/api/v1/models.py b/backend/app/api/v1/models.py new file mode 100644 index 0000000..f4199d4 --- /dev/null +++ b/backend/app/api/v1/models.py @@ -0,0 +1,94 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This project is licensed under Apache 2.0 +# +# app/api/v1/models.py +# SQLModel table models for the V1 specification. +# +# see https://dbdiagram.io/d/ReadMe-69e1d5ff0aa78f6bc1f6860e + +from __future__ import annotations + +from datetime import date, datetime +from enum import Enum + +from sqlalchemy import Column, DateTime, UniqueConstraint, func +from sqlalchemy import Enum as SAEnum +from sqlmodel import Field, SQLModel + + +class TextType(str, Enum): + book = "book" + article = "article" + paper = "paper" + other = "other" + + +class UserDB(SQLModel, table=True): + __tablename__ = "users" + + id: int | None = Field(default=None, primary_key=True) + username: str = Field(index=True, unique=True, nullable=False) + password_hash: str = Field(nullable=False) + created_at: datetime | None = Field( + default=None, + sa_column=Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ), + ) + + +class TextDB(SQLModel, table=True): + __tablename__ = "texts" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", nullable=False, index=True) + + title: str = Field(nullable=False) + author: str | None = None + type: TextType | None = Field( + default=None, + sa_column=Column("type", SAEnum(TextType, name="text_type")), + ) + + total_pages: int | None = None + is_mandatory: bool = Field(default=False, nullable=False) + priority: int | None = None + due_date: date | None = None + + created_at: datetime | None = Field( + default=None, + sa_column=Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ), + ) + + +class AvailabilityDB(SQLModel, table=True): + __tablename__ = "availability" + __table_args__ = (UniqueConstraint("user_id", "day_of_week"),) + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", nullable=False, index=True) + day_of_week: int = Field(nullable=False) + available_minutes: int | None = None + + +class SessionDB(SQLModel, table=True): + __tablename__ = "sessions" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", nullable=False, index=True) + text_id: int = Field(foreign_key="texts.id", nullable=False, index=True) + + scheduled_date: date = Field(nullable=False) + pages_start: int | None = None + pages_end: int | None = None + duration_minutes: int | None = None + is_completed: bool = Field(default=False, nullable=False) + + created_at: datetime | None = Field( + default=None, + sa_column=Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ), + ) diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 883795b..4fc2a71 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -1,12 +1,22 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This project is licensed under Apache 2.0 +# +# app/api/v1/settings.py +# Application settings for the V1 specification + from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): - database_url: str = "postgresql+psycopg://app:app@127.0.0.1:5432/app" + # compose sets DATABASE_URL to the correct postgres url. pytest sets it to a local sqlite file db + # (backend/.pytest.db). if not set (i.e. local dev), if not set, fall back to a local automatically + # created sqlite file DB + database_url: str = "sqlite:///./database.db" model_config = SettingsConfigDict( env_prefix="", extra="ignore", ) -settings = Settings() \ No newline at end of file + +settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index 1620fc8..18e8f11 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,8 +1,24 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This project is licensed under Apache 2.0 +# +# app/main.py +# Main application file that establishes fastapi + +from contextlib import asynccontextmanager + from fastapi import FastAPI import uvicorn from .api.v1.api import api_router +from .api.v1.db import create_db_and_tables + + +@asynccontextmanager +async def lifespan(app: FastAPI): + create_db_and_tables() + yield + -app = FastAPI(title="ReadMe Backend", version="0.1.0") +app = FastAPI(title="ReadMe Backend", version="0.1.0", lifespan=lifespan) app.include_router(api_router, prefix="/api/v1") diff --git a/backend/conftest.py b/backend/conftest.py index e7ec755..0e5d722 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -1,12 +1,56 @@ # Copyright (c) 2026 Lachlan Harris. All Rights Reserved. # This code is licensed under Apache 2.0 # -# Fix for VS code test discovery not finding the backend folder -# for module imports +# conftest.py +# Pytest configuration import sys from pathlib import Path +import os -BACKEND_DIR = Path(__file__).resolve().parent / "backend" +import pytest +from fastapi.testclient import TestClient + + +BACKEND_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(BACKEND_DIR)) + + +# Ensure unit tests don't require a running Postgres instance. +# The application still defaults to Postgres for normal runs via settings. +TEST_DB_PATH = Path(__file__).resolve().parent / ".pytest.db" +os.environ["DATABASE_URL"] = f"sqlite:///{TEST_DB_PATH}" + + +TEST_USER = { + "username": "lachlanharris", + "password": "secret", +} + + +@pytest.fixture +def client(): + from app.api.v1.db import create_db_and_tables, engine + from app.main import app + + create_db_and_tables() + + with TestClient(app) as test_client: + # seed the test user + res = test_client.post("/api/v1/auth/signup", json=TEST_USER) + if res.status_code not in (200, 409): + raise RuntimeError( + f"Failed to seed test user: {res.status_code} {res.text}" + ) + yield test_client + + +@pytest.fixture(scope="session", autouse=True) +def run_after_all_tests(): + # pre-test code; pass + yield + # post-test code + # delete the database file + if TEST_DB_PATH.exists(): + TEST_DB_PATH.unlink() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index fc63d1f..3dae7a7 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,15 +1,10 @@ # Copyright (c) 2026 Lachlan Harris. All Rights Reserved. # This code is licensed under Apache 2.0 # -# Test file scoped to src/api/v1/auth +# Test file scoped to app/api/v1/auth -from fastapi.testclient import TestClient - -def test_auth_token_ok() -> None: - from app.main import app - - client = TestClient(app) +def test_auth_token_ok(client) -> None: res = client.post( "/api/v1/auth/token", data={"username": "lachlanharris", "password": "secret"} ) @@ -18,10 +13,7 @@ def test_auth_token_ok() -> None: assert res.json()["token_type"] == "bearer" -def test_auth_token_bad_credentials() -> None: - from app.main import app - - client = TestClient(app) +def test_auth_token_bad_credentials(client) -> None: res = client.post( "/api/v1/auth/token", data={"username": "lachlanharris", "password": "wrongpassword"}, @@ -36,10 +28,7 @@ def test_auth_token_bad_credentials() -> None: assert res.json()["detail"] == "Incorrect username or password" -def test_auth_me_ok() -> None: - from app.main import app - - client = TestClient(app) +def test_auth_me_ok(client) -> None: token_res = client.post( "/api/v1/auth/token", data={"username": "lachlanharris", "password": "secret"} ) @@ -49,18 +38,13 @@ def test_auth_me_ok() -> None: "/api/v1/auth/me", headers={"Authorization": f"Bearer {access_token}"} ) assert res.status_code == 200 - assert res.json() == { - "username": "lachlanharris", - "full_name": "Lachlan Harris", - "email": "contact@lachlanharris.dev", - "disabled": False, - } - + body = res.json() + assert body["username"] == "lachlanharris" + assert isinstance(body["id"], int) + assert "created_at" in body -def test_auth_me_no_token() -> None: - from app.main import app - client = TestClient(app) +def test_auth_me_no_token(client) -> None: res = client.get("/api/v1/auth/me") assert res.status_code == 401 assert res.json()["detail"] == "Not authenticated" diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py new file mode 100644 index 0000000..e96282f --- /dev/null +++ b/backend/tests/test_db.py @@ -0,0 +1,17 @@ +# Copyright (c) 2026 Lachlan Harris. All Rights Reserved. +# This code is licensed under Apache 2.0 +# +# Test file for database functionality. confirms the connection between the API and the DB, +# and runs through the non-api-endpoint db logic. limted for now + +from fastapi.testclient import TestClient +from sqlmodel import create_engine + + +def test_db_connection() -> None: + from app.main import app + + client = TestClient(app) + res = client.get("/api/v1/health/db") + assert res.status_code == 200 + assert res.json() == {"status": "ok"}