Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ GEMINI_MODEL=gemini-2.5-flash
OPENAI_API_KEY=''
OPENAI_MODEL=gpt-4.1-mini
LOG_LEVEL=INFO
# Optional: path to SQLite DB file (default: planera.db next to requirements.txt)
# DATABASE_PATH=/absolute/path/to/planera.db
# Required for real deployments: strong random secret for signing JWTs
# JWT_SECRET_KEY=change-me
# JWT_ALGORITHM=HS256
# ACCESS_TOKEN_EXPIRE_MINUTES=10080
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ coverage/
.venv/
venv/
__pycache__/
planera.db
*.py[cod]
.pytest_cache/
.mypy_cache/
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,18 @@ pip install -r requirements.txt
cp .env.example .env
```

Use this project virtualenv for all Python commands (`uvicorn`, `pytest`, `pip`). In each new shell, activate it first:

```bash
source .venv/bin/activate
```

*(Windows Git Bash: `source .venv/Scripts/activate` — PowerShell: `.venv\Scripts\Activate.ps1`.)*

### 2. Run the API

```bash
source .venv/bin/activate
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```

Expand All @@ -113,6 +122,11 @@ API endpoints:
- `POST /uploads`
- `GET /inspections/{inspection_id}`
- `POST /analyze`
- `POST /auth/signup` — create user (SQLite), returns JWT
- `POST /auth/login` — issue JWT
- `GET /auth/me` — current user (`Authorization: Bearer <token>`)

**Database:** On API startup the app creates SQLite tables if needed (no separate migration step for this demo). By default the DB file is `planera.db` in the project root (same directory as `requirements.txt`). Override with `DATABASE_PATH` in `.env`. Add a strong `JWT_SECRET_KEY` before any shared deployment; the repo default is for local dev only.

Example request:

Expand Down Expand Up @@ -146,6 +160,10 @@ Backend settings are defined in `.env.example`:
- `OPENAI_API_KEY`
- `OPENAI_MODEL`
- `LOG_LEVEL`
- `DATABASE_PATH` (optional; default `planera.db` beside `requirements.txt`)
- `JWT_SECRET_KEY` (optional for local dev; **required** for non-local use)
- `JWT_ALGORITHM` (default `HS256`)
- `ACCESS_TOKEN_EXPIRE_MINUTES` (default `10080`)

Frontend settings live in `ui/.env.example`.

Expand Down Expand Up @@ -181,8 +199,12 @@ Key derived fields include:

## Running Tests

Use the project virtualenv so `pytest` and packages like `passlib` match `requirements.txt` (if you use Conda/base Python, a bare `pytest` may run the wrong interpreter and fail imports):

```bash
pytest
source .venv/bin/activate
pip install -r requirements.txt
python -m pytest
```

The test suite covers:
Expand Down
64 changes: 64 additions & 0 deletions app/api/auth_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Authentication routes (JWT access tokens, SQLite-backed users)."""

from __future__ import annotations

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.auth.deps import get_current_user
from app.auth.schemas import AuthTokenResponse, LoginRequest, MeResponse, SignupRequest, UserPublic
from app.auth.security import create_access_token, hash_password, normalize_email, verify_password
from app.db.session import get_db
from app.models.user import User


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


def _display_name_from_signup(display_name: str | None) -> str | None:
if display_name is None:
return None
stripped = display_name.strip()
return stripped or None


@router.post("/signup", response_model=AuthTokenResponse)
def signup(body: SignupRequest, db: Session = Depends(get_db)) -> AuthTokenResponse:
email = normalize_email(str(body.email))
existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if existing is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"message": "Email already registered."},
)

user = User(
email=email,
hashed_password=hash_password(body.password),
display_name=_display_name_from_signup(body.display_name),
)
db.add(user)
db.commit()
db.refresh(user)
token = create_access_token(subject_user_id=user.id)
return AuthTokenResponse(user=UserPublic.model_validate(user), access_token=token)


@router.post("/login", response_model=AuthTokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)) -> AuthTokenResponse:
email = normalize_email(str(body.email))
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is None or not verify_password(body.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"message": "Invalid email or password."},
)

token = create_access_token(subject_user_id=user.id)
return AuthTokenResponse(user=UserPublic.model_validate(user), access_token=token)


@router.get("/me", response_model=MeResponse)
def me(current_user: User = Depends(get_current_user)) -> MeResponse:
return MeResponse(user=UserPublic.model_validate(current_user))
1 change: 1 addition & 0 deletions app/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Authentication helpers and dependencies."""
46 changes: 46 additions & 0 deletions app/auth/deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""FastAPI dependencies for authentication."""

from __future__ import annotations

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session

from app.auth.security import decode_access_token
from app.db.session import get_db
from app.models.user import User

_bearer = HTTPBearer(auto_error=False)


def get_current_user(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
db: Session = Depends(get_db),
) -> User:
if creds is None or creds.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"message": "Not authenticated."},
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(creds.credentials)
user_id_str: str | None = payload.get("sub")
if user_id_str is None:
raise ValueError("missing sub")
user_id = int(user_id_str)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"message": "Invalid or expired token."},
headers={"WWW-Authenticate": "Bearer"},
) from exc

user = db.get(User, user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"message": "User no longer exists."},
headers={"WWW-Authenticate": "Bearer"},
)
return user
37 changes: 37 additions & 0 deletions app/auth/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Pydantic schemas for auth API payloads."""

from __future__ import annotations

from datetime import datetime

from pydantic import BaseModel, EmailStr, Field


class SignupRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8, max_length=128)
display_name: str | None = Field(default=None, max_length=255)


class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=1, max_length=128)


class UserPublic(BaseModel):
model_config = {"from_attributes": True}

id: int
email: str
display_name: str | None
created_at: datetime


class AuthTokenResponse(BaseModel):
user: UserPublic
access_token: str
token_type: str = "bearer"


class MeResponse(BaseModel):
user: UserPublic
36 changes: 36 additions & 0 deletions app/auth/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Password hashing and JWT helpers."""

from __future__ import annotations

from datetime import datetime, timedelta, timezone

import jwt
from passlib.context import CryptContext

from app.config import get_settings

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def normalize_email(email: str) -> str:
return email.strip().lower()


def hash_password(plain: str) -> str:
return pwd_context.hash(plain)


def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)


def create_access_token(*, subject_user_id: int) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": str(subject_user_id), "exp": expire}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)


def decode_access_token(token: str) -> dict:
settings = get_settings()
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
8 changes: 8 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ class Settings(BaseSettings):
default_factory=lambda: ["http://localhost:5173", "http://127.0.0.1:5173"],
alias="CORS_ALLOW_ORIGINS",
)
# Auth / SQLite (set JWT_SECRET_KEY in any shared or production-like environment)
database_path: Path = Field(default=BASE_DIR / "planera.db", alias="DATABASE_PATH")
jwt_secret_key: str = Field(
default="dev-insecure-jwt-secret-change-me",
alias="JWT_SECRET_KEY",
)
jwt_algorithm: str = Field(default="HS256", alias="JWT_ALGORITHM")
access_token_expire_minutes: int = Field(default=10080, alias="ACCESS_TOKEN_EXPIRE_MINUTES") # 7 days

@field_validator("cors_allow_origins", mode="before")
@classmethod
Expand Down
6 changes: 6 additions & 0 deletions app/db/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Database package."""

from app.db.base import Base
from app.db.session import get_db, get_engine, reset_engine_and_session

__all__ = ["Base", "get_db", "get_engine", "reset_engine_and_session"]
9 changes: 9 additions & 0 deletions app/db/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Declarative base for SQLAlchemy models."""

from __future__ import annotations

from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
"""Application ORM base class."""
51 changes: 51 additions & 0 deletions app/db/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""SQLite engine and session factory (lazy init so tests can override settings first)."""

from __future__ import annotations

from collections.abc import Generator

from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

from app.config import get_settings

_engine = None
_session_factory: sessionmaker[Session] | None = None


def reset_engine_and_session() -> None:
"""Dispose the engine and clear factories (used by tests after changing DB path)."""

global _engine, _session_factory
if _engine is not None:
_engine.dispose()
_engine = None
_session_factory = None


def get_engine():
global _engine
if _engine is None:
settings = get_settings()
_engine = create_engine(
f"sqlite:///{settings.database_path}",
connect_args={"check_same_thread": False},
)
return _engine


def get_session_factory() -> sessionmaker[Session]:
global _session_factory
if _session_factory is None:
_session_factory = sessionmaker(autocommit=False, autoflush=False, bind=get_engine())
return _session_factory


def get_db() -> Generator[Session, None, None]:
"""FastAPI dependency: one request-scoped session."""

db = get_session_factory()()
try:
yield db
finally:
db.close()
18 changes: 18 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

from __future__ import annotations

from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.api.auth_routes import router as auth_router
from app.api.routes import router
from app.config import get_settings
from app.utils.logging import configure_logging
Expand All @@ -13,10 +16,24 @@
settings = get_settings()
configure_logging(settings.log_level)


@asynccontextmanager
async def lifespan(_app: FastAPI):
"""Create database tables on startup (SQLite file demo; no Alembic in this phase)."""

from app.db.base import Base
from app.db.session import get_engine
from app.models import User # noqa: F401

Base.metadata.create_all(bind=get_engine())
yield


app = FastAPI(
title=settings.app_name,
version="0.1.0",
description="Natural-language analytics copilot for GTM teams.",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
Expand All @@ -25,4 +42,5 @@
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(router)
5 changes: 5 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""ORM models (import for metadata registration)."""

from app.models.user import User

__all__ = ["User"]
Loading
Loading