diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..28f4d1c --- /dev/null +++ b/.env.sample @@ -0,0 +1,12 @@ +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=lessongen +POSTGRES_USER=lessongen +POSTGRES_PASSWORD=lessongen +DATABASE_URL=postgresql+psycopg2://lessongen:lessongen@localhost:5432/lessongen +SECRET_KEY=change_me +APP_VERSION=0.1.0 +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +OPENAI_API_KEY= +GC_API_SCOPES=https://www.googleapis.com/auth/classroom.courses https://www.googleapis.com/auth/documents diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml new file mode 100644 index 0000000..0031910 --- /dev/null +++ b/.github/workflows/node-ci.yml @@ -0,0 +1,40 @@ +name: Node CI + +on: + push: + paths: + - "frontend/**" + - ".github/workflows/node-ci.yml" + pull_request: + paths: + - "frontend/**" + - ".github/workflows/node-ci.yml" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + working-directory: frontend + run: npm install + + - name: Lint + working-directory: frontend + run: npm run lint + + - name: Typecheck + working-directory: frontend + run: npm run build -- --mode development + + - name: Tests + working-directory: frontend + run: npm test -- --run diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..fd5d7fd --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,45 @@ +name: Python CI + +on: + push: + paths: + - "backend/**" + - ".github/workflows/python-ci.yml" + pull_request: + paths: + - "backend/**" + - ".github/workflows/python-ci.yml" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Poetry + uses: abatilo/actions-poetry@v2 + with: + poetry-version: "1.7.1" + + - name: Install dependencies + working-directory: backend + run: | + poetry install --no-interaction --no-ansi + + - name: Lint + working-directory: backend + run: | + poetry run ruff check . + poetry run black --check . + poetry run mypy app + + - name: Tests + working-directory: backend + run: | + poetry run pytest -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0f6db2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Python +__pycache__/ +*.py[cod] +*.sqlite3 +.env +.venv/ +.poetry/ + +# Node +node_modules/ +dist/ +*.log + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/*.log +.vscode/.ropeproject + +# Alembic +backend/migrations/__pycache__/ + +# Coverage +htmlcov/ +.coverage + +# Misc +*.swp diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..14d4e60 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "FastAPI: Attach", + "type": "python", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}/backend", + "remoteRoot": "/app" + } + ] + }, + { + "name": "Frontend: Chrome", + "request": "launch", + "type": "pwa-chrome", + "url": "http://localhost:5173", + "webRoot": "${workspaceFolder}/frontend/src" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..05236eb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "editor.formatOnSave": true, + "python.formatting.provider": "black", + "python.linting.enabled": true, + "python.linting.mypyEnabled": true, + "python.linting.ruffEnabled": true, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..691958e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,37 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Docker: Up", + "type": "shell", + "command": "make up" + }, + { + "label": "Docker: Down", + "type": "shell", + "command": "make down" + }, + { + "label": "API: Dev", + "type": "shell", + "command": "make api", + "problemMatcher": [] + }, + { + "label": "Web: Dev", + "type": "shell", + "command": "make web", + "problemMatcher": [] + }, + { + "label": "Tests: API", + "type": "shell", + "command": "cd backend && pytest -q" + }, + { + "label": "Tests: Web", + "type": "shell", + "command": "cd frontend && npm test" + } + ] +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..62dea0c --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.PHONY: up down api web test fmt lint migrate seed + +up: +@docker compose up -d + +down: +@docker compose down -v + +api: +@cd backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +web: +@cd frontend && npm run dev + +test: +@cd backend && pytest -q +@cd frontend && npm test -- --run + +fmt: +@cd backend && ruff check --fix . && black . +@cd frontend && npm run format + +lint: +@cd backend && ruff check . && mypy app +@cd frontend && npm run lint + +migrate: +@cd backend && alembic upgrade head + +seed: +@cd backend && python -m app.scripts.seed_demo diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..25a6064 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim AS base + +ENV PYTHONUNBUFFERED=1 \ + POETRY_VERSION=1.7.1 + +RUN apt-get update && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/* + +RUN pip install "poetry==${POETRY_VERSION}" + +WORKDIR /app + +COPY pyproject.toml /app/ +RUN poetry config virtualenvs.create false \ + && poetry install --no-interaction --no-ansi + +COPY app /app/app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..6ea0630 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,9 @@ +# LessonGen Backend + +FastAPI application providing LessonGen APIs. Key commands: + +```bash +poetry install +poetry run uvicorn app.main:app --reload +poetry run pytest +``` diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..4f46dbf --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = migrations +sqlalchemy.url = postgresql+psycopg2://lessongen:lessongen@db:5432/lessongen + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = console +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..f01085c --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,4 @@ +"""LessonGen FastAPI application package.""" +from .main import app + +__all__ = ["app"] diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..b1a95a9 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,4 @@ +"""API package.""" +from .routes import api_router + +__all__ = ["api_router"] diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..2b6f90a --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,22 @@ +"""Health check endpoints.""" +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.config import settings + +router = APIRouter() + + +@router.get("/health", summary="Application health check") +def healthcheck() -> dict[str, str]: + """Return a simple health response.""" + + return {"status": "ok"} + + +@router.get("/version", summary="Application version") +def version() -> dict[str, str]: + """Return the deployed application version.""" + + return {"version": settings.app_version} diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py new file mode 100644 index 0000000..cbba01b --- /dev/null +++ b/backend/app/api/routes.py @@ -0,0 +1,9 @@ +"""API router configuration.""" +from __future__ import annotations + +from fastapi import APIRouter + +from . import health + +api_router = APIRouter() +api_router.include_router(health.router, tags=["health"]) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..cb7b7c6 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,4 @@ +"""Core configuration and security utilities.""" +from .config import settings + +__all__ = ["settings"] diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..a498fc5 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,49 @@ +"""Application configuration settings.""" +from __future__ import annotations + +from functools import lru_cache +from typing import List + +from pydantic import AnyHttpUrl, Field +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Pydantic settings for the FastAPI application.""" + + app_name: str = Field(default="LessonGen API", env="APP_NAME") + app_version: str = Field(default="0.1.0", env="APP_VERSION") + environment: str = Field(default="development", env="ENVIRONMENT") + api_prefix: str = Field(default="/api", env="API_PREFIX") + + database_url: str = Field( + default="postgresql+psycopg2://lessongen:lessongen@localhost:5432/lessongen", + env="DATABASE_URL", + ) + + secret_key: str = Field(default="change_me", env="SECRET_KEY") + jwt_algorithm: str = Field(default="HS256", env="JWT_ALGORITHM") + access_token_expire_minutes: int = Field( + default=60 * 24, + env="ACCESS_TOKEN_EXPIRE_MINUTES", + ) + + backend_cors_origins: List[AnyHttpUrl] | List[str] = Field( + default_factory=lambda: ["http://localhost:5173"], + env="BACKEND_CORS_ORIGINS", + ) + + class Config: + case_sensitive = False + env_file = ".env" + env_file_encoding = "utf-8" + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Return the cached settings instance.""" + + return Settings() + + +settings = get_settings() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..4299138 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,33 @@ +"""Security utilities for JWT token management.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict + +from jose import JWTError, jwt + +from .config import settings + + +class TokenError(RuntimeError): + """Raised when a JWT token cannot be decoded or validated.""" + + +def create_access_token(subject: str | Any, expires_delta: timedelta | None = None) -> str: + """Create a signed JWT access token.""" + + to_encode: Dict[str, Any] = {"sub": str(subject)} + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(minutes=settings.access_token_expire_minutes) + ) + to_encode["exp"] = expire + return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm) + + +def decode_token(token: str) -> Dict[str, Any]: + """Decode a JWT token and return its payload.""" + + try: + return jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm]) + except JWTError as exc: # pragma: no cover - defensive branch + raise TokenError("Could not validate credentials") from exc diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..271947c --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1,5 @@ +"""Database helpers.""" +from .base import Base +from .session import get_session + +__all__ = ["Base", "get_session"] diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..158adf7 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,10 @@ +"""Declarative base for SQLAlchemy models.""" +from __future__ import annotations + +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Base class for SQLAlchemy models.""" + + pass diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..33d2dbd --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,20 @@ +"""Database session management.""" +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings + +engine = create_engine(settings.database_url, future=True, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, class_=Session, autoflush=False, autocommit=False) + + +def get_session() -> Session: + """Yield a SQLAlchemy session for dependency injection.""" + + session = SessionLocal() + try: + yield session + finally: + session.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..37ca642 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,29 @@ +"""FastAPI application entry point.""" +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.routes import api_router +from app.core.config import settings + + +def create_application() -> FastAPI: + """Create and configure the FastAPI application.""" + + application = FastAPI(title=settings.app_name, version=settings.app_version) + + application.add_middleware( + CORSMiddleware, + allow_origins=[str(origin) for origin in settings.backend_cors_origins], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + application.include_router(api_router) + + return application + + +app = create_application() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..4ef0053 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,7 @@ +"""SQLAlchemy models for LessonGen.""" +from .district import District +from .school import School +from .tenant import Tenant +from .user import User + +__all__ = ["Tenant", "District", "School", "User"] diff --git a/backend/app/models/district.py b/backend/app/models/district.py new file mode 100644 index 0000000..b79df78 --- /dev/null +++ b/backend/app/models/district.py @@ -0,0 +1,38 @@ +"""District data model.""" +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from .school import School + from .tenant import Tenant + from .user import User + + +class District(Base): + """Represents a school district under a tenant.""" + + __tablename__ = "districts" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(length=255), nullable=False) + + tenant: Mapped["Tenant"] = relationship(back_populates="districts") + schools: Mapped[list["School"]] = relationship( + back_populates="district", cascade="all, delete-orphan" + ) + users: Mapped[list["User"]] = relationship( + back_populates="district", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/school.py b/backend/app/models/school.py new file mode 100644 index 0000000..4e05440 --- /dev/null +++ b/backend/app/models/school.py @@ -0,0 +1,35 @@ +"""School data model.""" +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from .district import District + from .user import User + + +class School(Base): + """Represents a school within a district.""" + + __tablename__ = "schools" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + district_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("districts.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(length=255), nullable=False) + grade_levels: Mapped[str | None] = mapped_column(String(length=50), nullable=True) + + district: Mapped["District"] = relationship(back_populates="schools") + users: Mapped[list["User"]] = relationship( + back_populates="school", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py new file mode 100644 index 0000000..9593b4b --- /dev/null +++ b/backend/app/models/tenant.py @@ -0,0 +1,33 @@ +"""Tenant data model.""" +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from .district import District + from .user import User + + +class Tenant(Base): + """Represents an organizational tenant within LessonGen.""" + + __tablename__ = "tenants" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(length=255), nullable=False, unique=True) + + districts: Mapped[list["District"]] = relationship( + back_populates="tenant", cascade="all, delete-orphan" + ) + users: Mapped[list["User"]] = relationship( + back_populates="tenant", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..c5730ed --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,43 @@ +"""User data model.""" +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +if TYPE_CHECKING: + from .district import District + from .school import School + from .tenant import Tenant + + +class User(Base): + """Represents a user in LessonGen.""" + + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False + ) + district_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("districts.id", ondelete="SET NULL"), nullable=True + ) + school_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("schools.id", ondelete="SET NULL"), nullable=True + ) + email: Mapped[str] = mapped_column(String(length=255), unique=True, nullable=False) + full_name: Mapped[str | None] = mapped_column(String(length=255), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) + + tenant: Mapped["Tenant"] = relationship(back_populates="users") + district: Mapped["District" | None] = relationship(back_populates="users") + school: Mapped["School" | None] = relationship(back_populates="users") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..0f3f77a --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas will live here in future sprints.""" diff --git a/backend/app/scripts/__init__.py b/backend/app/scripts/__init__.py new file mode 100644 index 0000000..984f1cf --- /dev/null +++ b/backend/app/scripts/__init__.py @@ -0,0 +1 @@ +"""Utility scripts for LessonGen.""" diff --git a/backend/app/scripts/seed_demo.py b/backend/app/scripts/seed_demo.py new file mode 100644 index 0000000..3b0d74b --- /dev/null +++ b/backend/app/scripts/seed_demo.py @@ -0,0 +1,12 @@ +"""Placeholder seed script.""" +from __future__ import annotations + + +def main() -> None: # pragma: no cover - placeholder implementation + """Seed the database with demo data.""" + + print("Demo seed not implemented yet.") + + +if __name__ == "__main__": # pragma: no cover - script entry point + main() diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..341f4e0 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,65 @@ +"""Alembic environment configuration.""" +from __future__ import annotations + +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +from app.core.config import settings +from app.db.base import Base # noqa: F401 +from app import models # noqa: F401 pylint: disable=unused-import + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option("sqlalchemy.url", settings.database_url) +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/versions/0001_initial.py b/backend/migrations/versions/0001_initial.py new file mode 100644 index 0000000..392958b --- /dev/null +++ b/backend/migrations/versions/0001_initial.py @@ -0,0 +1,61 @@ +"""Initial schema for tenants, districts, schools, and users.""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "tenants", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("name", sa.String(length=255), nullable=False, unique=True), + ) + + op.create_table( + "districts", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + ) + + op.create_table( + "schools", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("district_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("grade_levels", sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(["district_id"], ["districts.id"], ondelete="CASCADE"), + ) + + op.create_table( + "users", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("district_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("school_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("email", sa.String(length=255), nullable=False, unique=True), + sa.Column("full_name", sa.String(length=255), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["district_id"], ["districts.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["school_id"], ["schools.id"], ondelete="SET NULL"), + ) + + +def downgrade() -> None: + op.drop_table("users") + op.drop_table("schools") + op.drop_table("districts") + op.drop_table("tenants") diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..44b39df --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,48 @@ +[tool.poetry] +name = "lessongen-backend" +version = "0.1.0" +description = "LessonGen FastAPI backend" +authors = ["LessonGen Team "] +readme = "README.md" +packages = [{include = "app"}] + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.110.0" +uvicorn = {extras = ["standard"], version = "^0.29.0"} +sqlalchemy = "^2.0.25" +psycopg2-binary = "^2.9.9" +alembic = "^1.13.1" +pydantic = "^2.6.1" +pydantic-settings = "^2.2.1" +python-jose = {extras = ["cryptography"], version = "^3.3.0"} +passlib = {extras = ["bcrypt"], version = "^1.7.4"} + +[tool.poetry.group.dev.dependencies] +pytest = "^8.1.1" +httpx = "^0.27.0" +ruff = "^0.3.0" +black = "^24.2.0" +mypy = "^1.8.0" + +[build-system] +requires = ["poetry-core>=1.5.0"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.black] +line-length = 100 +target-version = ['py311'] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["app"] + +[tool.mypy] +python_version = "3.11" +ignore_missing_imports = true +namespace_packages = true +mypy_path = "app" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..a8355ef --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,23 @@ +"""Tests for health endpoints.""" +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_healthcheck() -> None: + """Health endpoint returns expected payload.""" + + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_version() -> None: + """Version endpoint returns an application version string.""" + + response = client.get("/version") + assert response.status_code == 200 + assert "version" in response.json() + assert isinstance(response.json()["version"], str) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5b0db2f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,74 @@ +version: "3.9" + +services: + db: + image: postgres:15 + container_name: lessongen-db + restart: unless-stopped + environment: + POSTGRES_DB: lessongen + POSTGRES_USER: lessongen + POSTGRES_PASSWORD: lessongen + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + api: + build: + context: ./backend + dockerfile: Dockerfile + container_name: lessongen-api + restart: unless-stopped + env_file: + - .env.sample + environment: + DATABASE_URL: postgresql+psycopg2://lessongen:lessongen@db:5432/lessongen + depends_on: + - db + ports: + - "8000:8000" + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 + + web: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: lessongen-web + restart: unless-stopped + depends_on: + - api + ports: + - "5173:5173" + environment: + VITE_API_BASE_URL: http://localhost:8000 + command: npm run dev -- --host 0.0.0.0 --port 5173 + + pgadmin: + image: dpage/pgadmin4:7.5 + container_name: lessongen-pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: admin@lessongen.local + PGADMIN_DEFAULT_PASSWORD: lessongen + depends_on: + - db + ports: + - "8080:80" + + minio: + image: quay.io/minio/minio:RELEASE.2024-02-17T01-15-57Z + container_name: lessongen-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + +volumes: + postgres_data: + minio_data: diff --git a/docs/adr/0001-foundation.md b/docs/adr/0001-foundation.md new file mode 100644 index 0000000..a8b7a53 --- /dev/null +++ b/docs/adr/0001-foundation.md @@ -0,0 +1,31 @@ +# ADR 0001: Foundational Architecture and Stack + +## Status +Accepted – Sprint 1 + +## Context +LessonGen requires a modern, cloud-friendly architecture that supports rapid iteration on +AI-assisted lesson planning while maintaining a clear separation of concerns between backend, +frontend, and infrastructure concerns. Sprint 1 focuses on delivering a runnable stack with +continuous integration guardrails. + +## Decision +- Backend: FastAPI with SQLAlchemy and Alembic for API services, using Postgres as the + persistent data store. +- Frontend: Vite + React + Tailwind CSS for a type-safe, component-driven web client. +- Infrastructure: Docker Compose for local orchestration of the API, web client, Postgres, + pgAdmin, and MinIO. +- Tooling: Poetry for backend dependency management, npm for frontend, Ruff/Black/mypy for + Python quality gates, ESLint/Prettier/Vitest for frontend quality gates. +- Authentication scaffolding relies on JWT tokens; Sprint 2 will replace this with Google + OAuth. + +## Consequences +- Developers can spin up the full stack locally using `make up` and connect to the health + endpoints for smoke testing. +- CI pipelines enforce linting, formatting, type checks, and unit tests for both backend and + frontend. +- The schema for tenants, districts, schools, and users is established with a baseline Alembic + migration. +- Additional services (e.g., AI generation workers) can be layered on in later sprints without + restructuring the foundation established here. diff --git a/docs/runbook-local.md b/docs/runbook-local.md new file mode 100644 index 0000000..6438189 --- /dev/null +++ b/docs/runbook-local.md @@ -0,0 +1,40 @@ +# LessonGen Local Development Runbook + +## Prerequisites +- Docker Desktop or Docker Engine 20+ +- Python 3.11 with Poetry installed (if running backend outside Docker) +- Node.js 20+ + +## Environment Setup +1. Copy `.env.sample` to `.env` and adjust secrets as needed. +2. Install backend dependencies with `poetry install` (from `backend/`). +3. Install frontend dependencies with `npm install` (from `frontend/`). + +## Running the Stack +```bash +make up +``` +- API available at http://localhost:8000 +- Frontend available at http://localhost:5173 +- pgAdmin at http://localhost:8080 (admin@lessongen.local / lessongen) +- MinIO console at http://localhost:9001 (minioadmin / minioadmin) + +Stop the stack and remove volumes: +```bash +make down +``` + +## Developer Workflow +- Backend development: `make api` +- Frontend development: `make web` +- Run tests: `make test` +- Apply migrations: `make migrate` + +## Health Checks +- `GET http://localhost:8000/health` should respond with `{"status": "ok"}`. +- `GET http://localhost:8000/version` returns the running API version. + +## Troubleshooting +- If dependencies are missing in Docker builds, rerun `npm install` or `poetry install` locally + to refresh lockfiles. +- Ensure no other services are bound to ports 5432, 8000, 5173, 8080, or 9000/9001. diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..cf2bfb7 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,32 @@ +module.exports = { + root: true, + env: { + browser: true, + es2021: true + }, + extends: [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaFeatures: { + jsx: true + }, + ecmaVersion: "latest", + sourceType: "module" + }, + plugins: ["react", "@typescript-eslint"], + rules: { + "react/react-in-jsx-scope": "off", + "react/prop-types": "off" + }, + settings: { + react: { + version: "detect" + } + } +}; diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..5d3c35d --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": false, + "semi": true, + "trailingComma": "none" +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..43b2837 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7f686ab --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,9 @@ +# LessonGen Frontend + +React + Vite + Tailwind client for LessonGen. Key commands: + +```bash +npm install +npm run dev +npm run test -- --run +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4b53c72 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + LessonGen + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..94210f7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "lessongen-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint src --ext ts,tsx", + "format": "prettier --write .", + "test": "vitest" + }, + "dependencies": { + "@tanstack/react-query": "^5.28.9", + "axios": "^1.6.7", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.2.0", + "@testing-library/react": "^14.2.1", + "@types/node": "^20.11.30", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^24.0.0", + "postcss": "^8.4.35", + "prettier": "^3.2.5", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.1.4", + "vitest": "^1.3.1" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/__tests__/app.test.tsx b/frontend/src/__tests__/app.test.tsx new file mode 100644 index 0000000..c7aafbd --- /dev/null +++ b/frontend/src/__tests__/app.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; + +import App from "../routes/App"; + +describe("App routing", () => { + it("renders dashboard by default", () => { + render( + + + + ); + + expect(screen.getByText(/Welcome back/i)).toBeInTheDocument(); + }); + + it("navigates to lessons page", () => { + render( + + + + ); + + expect(screen.getByText(/Lessons/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..141ae20 --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,43 @@ +import { Link, Outlet, useLocation } from "react-router-dom"; + +const navItems = [ + { to: "/dashboard", label: "Dashboard" }, + { to: "/lessons", label: "Lessons" } +]; + +const AppLayout = () => { + const location = useLocation(); + + return ( +
+
+
+ + LessonGen + + +
+
+
+ +
+
+ ); +}; + +export default AppLayout; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..65a379d --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..05bce0b --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; + +import App from "./routes/App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + + +); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..5f2a59c --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,21 @@ +const DashboardPage = () => { + return ( +
+
+

Welcome back!

+

+ This dashboard will evolve to show lesson generation activity, queued jobs, and + actionable insights across your tenant. +

+
+
+

Sprint 1 placeholder

+

+ Connect Google OAuth, lesson metrics, and organization switching in upcoming sprints. +

+
+
+ ); +}; + +export default DashboardPage; diff --git a/frontend/src/pages/LessonsPage.tsx b/frontend/src/pages/LessonsPage.tsx new file mode 100644 index 0000000..ba959b0 --- /dev/null +++ b/frontend/src/pages/LessonsPage.tsx @@ -0,0 +1,42 @@ +const LessonsPage = () => { + const placeholderLessons = [ + { + id: 1, + title: "Explore the Solar System", + grade: "5", + subject: "Science" + }, + { + id: 2, + title: "Fractions in Everyday Life", + grade: "4", + subject: "Mathematics" + } + ]; + + return ( +
+
+

Lessons

+

+ Generated lessons and drafts will appear here once the generation service is live. +

+
+
+ {placeholderLessons.map((lesson) => ( +
+

{lesson.title}

+

+ Grade {lesson.grade} · {lesson.subject} +

+

+ Align standards, versioning, and exports will be wired up in later sprints. +

+
+ ))} +
+
+ ); +}; + +export default LessonsPage; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..a475142 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,44 @@ +import { FormEvent, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +const LoginPage = () => { + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + navigate("/dashboard"); + }; + + return ( +
+
+

Sign in to LessonGen

+
+ + +

+ Google OAuth will arrive in Sprint 2. For now this placeholder demonstrates routing. +

+
+
+
+ ); +}; + +export default LoginPage; diff --git a/frontend/src/routes/App.tsx b/frontend/src/routes/App.tsx new file mode 100644 index 0000000..55e412c --- /dev/null +++ b/frontend/src/routes/App.tsx @@ -0,0 +1,25 @@ +import { Suspense } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; + +import AppLayout from "../components/AppLayout"; +import DashboardPage from "../pages/DashboardPage"; +import LessonsPage from "../pages/LessonsPage"; +import LoginPage from "../pages/LoginPage"; + +const App = () => { + return ( + Loading…}> + + } /> + }> + } /> + } /> + + } /> + Page not found.} /> + + + ); +}; + +export default App; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..b56b181 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,16 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + brand: { + DEFAULT: "#2563eb", + dark: "#1d4ed8", + light: "#60a5fa" + } + } + } + }, + plugins: [] +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1e64964 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..9d31e2a --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4de4990 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + open: true, + }, + preview: { + port: 4173, + }, +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..b9ffe34 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: "./vitest.setup.ts" + } +}); diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/frontend/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..bf7c197 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,4 @@ +# Infrastructure + +This directory will house infrastructure-as-code assets beyond the local Docker Compose stack. +Future sprints may introduce Kubernetes manifests, Terraform modules, and deployment scripts.