first commit

This commit is contained in:
dvirlabs 2026-02-04 16:50:33 +02:00
commit 132997c164
53 changed files with 6958 additions and 0 deletions

36
.env.example Normal file
View File

@ -0,0 +1,36 @@
# Database
DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/lomda
# JWT Secrets (CHANGE THESE IN PRODUCTION!)
JWT_SECRET_KEY=change_me_access
JWT_REFRESH_SECRET_KEY=change_me_refresh
JWT_ALGORITHM=HS256
# Token Expiration
ACCESS_TOKEN_EXPIRES_MINUTES=30
REFRESH_TOKEN_EXPIRES_DAYS=7
# Frontend URL
FRONTEND_URL=http://localhost:5173
# CORS Origins (comma-separated)
CORS_ORIGINS=http://localhost:5173
# Admin Seed Credentials
ADMIN_SEED_EMAIL=admin@example.com
ADMIN_SEED_PASSWORD=admin123
# Google OAuth (optional - get from Google Cloud Console)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
# Upload Directory
UPLOAD_DIR=/data/uploads
# SMTP (optional, for future email features)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=

91
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,91 @@
# Lomda Hub - AI Coding Agent Instructions
## Architecture Overview
**Stack**: React+Vite (TypeScript) frontend, FastAPI backend, PostgreSQL database with SQLAlchemy 2.0 ORM, Alembic migrations.
**Deployment**: Docker Compose with 3 services (db, backend, frontend). Frontend on 5173, backend on 8000, postgres on 5433.
**Auth**: JWT access/refresh tokens + Google OAuth. Tokens passed via `Authorization: Bearer` header. Two roles: `admin` and `learner`.
## Key Structural Patterns
### Backend Structure
- **Models** ([backend/app/models.py](../backend/app/models.py)): SQLAlchemy 2.0 with type hints (`Mapped[T]`). Enums: `UserRole`, `ModuleType`, `ProgressStatus`, `QuestionType`.
- **Routers**: Three main routers in [backend/app/routers](../backend/app/routers): `auth.py` (login/register/OAuth), `admin.py` (course/module CRUD), `learner.py` (enrollments/progress).
- **Dependencies** ([backend/app/deps.py](../backend/app/deps.py)): `get_db()` for DB sessions, `get_current_user()` for auth, `require_admin()` for admin-only routes.
- **Config** ([backend/app/core/config.py](../backend/app/core/config.py)): Settings loaded from env vars using Pydantic `BaseSettings`.
### Frontend Structure
- **Pages** ([frontend/src/pages](../frontend/src/pages)): Each major view (login, courses, player, admin dashboard/editor) as separate page component.
- **API Client** ([frontend/src/api](../frontend/src/api)): Centralized API calls. Token management in `client.ts` with localStorage.
- **Routing**: React Router v6 in [App.tsx](../frontend/src/App.tsx). Admin routes prefixed with `/admin`.
### Data Flow: Locked Course Progression
Courses have ordered modules. Learner must complete module N before unlocking N+1:
1. On enrollment, first module is `unlocked`, rest are `locked` (see `ModuleProgress` model).
2. Content modules: mark completed when viewed.
3. Quiz modules: mark completed only if attempt passes (`score >= pass_score`).
4. Backend logic in [learner.py](../backend/app/routers/learner.py): `submit_quiz_attempt` updates progress and unlocks next module.
## Critical Workflows
### Database Migrations
1. After model changes: `cd backend && alembic revision --autogenerate -m "description"`
2. Apply: `alembic upgrade head` (or `docker compose restart backend` for Docker)
3. Seed data: `python -m app.scripts.seed` (creates admin user + sample data)
### Development Commands
**Docker**: `docker compose up --build` (runs migrations + seed automatically via Dockerfile CMD)
**Local backend**: `uvicorn app.main:app --reload` (from backend dir with venv active)
**Local frontend**: `npm run dev` (from frontend dir)
### Google OAuth Flow
- Login: GET `/auth/google/login` → redirects to Google → callback at `/auth/google/callback`
- Callback returns `RedirectResponse` to `FRONTEND_URL/auth/callback?access_token=...&refresh_token=...`
- Frontend ([GoogleCallbackPage.tsx](../frontend/src/pages/GoogleCallbackPage.tsx)) extracts tokens from URL params and saves to localStorage
- **Critical**: FastAPI route must NOT have trailing slash mismatch or it will 307 redirect infinitely
## Project-Specific Conventions
### API Response Patterns
- Use Pydantic schemas for request/response validation (see [schemas.py](../backend/app/schemas.py))
- Admin endpoints return extended models with `group_ids` arrays
- Learner endpoints filter by current user (never expose other learners' data)
### Error Handling
- Raise `HTTPException` with appropriate status codes (400 bad request, 401 unauthorized, 403 forbidden, 404 not found)
- No generic try/except unless specific recovery logic needed
### Database Relationships
- Use SQLAlchemy relationship cascades: `cascade="all, delete-orphan"` for parent→child
- Unique constraints for enrollment, progress, group membership: prevent duplicates via `__table_args__`
### Frontend State Management
- No global state library; use React hooks + fetch in components
- Token refresh: not auto-implemented; tokens expire after 30min (access) / 7days (refresh)
## Integration Points
### External Dependencies
- Google OAuth: requires `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` env vars
- SMTP: config present but not actively used (future email features)
### Cross-Component Communication
- Frontend calls backend REST API; no WebSockets or real-time updates
- CORS: configured in [main.py](../backend/app/main.py) via `CORS_ORIGINS` env var (default: `http://localhost:5173`)
## Common Gotchas
1. **Trailing slashes**: FastAPI auto-redirects `/path/` to `/path` (307). Define routes without trailing slashes consistently.
2. **Password hashing**: bcrypt truncates at 72 bytes; see defensive check in [auth.py](../backend/app/auth.py).
3. **Module ordering**: `order_index` is manually managed. No auto-reordering on deletion.
4. **Admin seed**: Default admin created with email from `ADMIN_SEED_EMAIL` env var (default: `admin@example.com`).
## Key Files to Reference
- [models.py](../backend/app/models.py): All database schema
- [schemas.py](../backend/app/schemas.py): Request/response validation
- [deps.py](../backend/app/deps.py): Auth dependencies
- [learner.py](../backend/app/routers/learner.py): Core learning flow logic
- [App.tsx](../frontend/src/App.tsx): Frontend routing and navigation

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.env
node_modules
__pycache__
.venv
test/

178
README.md Normal file
View File

@ -0,0 +1,178 @@
# Lomda Hub (Mini-LMS)
Mini לומדות platform with locked progression, groups, Google OAuth login, achievements system, file uploads, and admin analytics.
## Features
- **Locked Progression**: Learners must complete module N before unlocking N+1
- **Google OAuth**: Sign in with Google account
- **JWT Authentication**: Access and refresh tokens for secure API access
- **Achievements System**: Earn achievements for completing courses, perfect scores, and consistent learning
- **File Uploads**: Admin can attach PDF/PPTX presentations to content modules
- **Admin Analytics**: View statistics on users, courses, enrollments, completions, and quiz attempts
- **Groups**: Restrict course access to specific user groups
- **Dark/Light Theme**: Toggle between dark and light modes
## Stack
- Frontend: React + Vite + TypeScript + React Router + MUI
- Backend: FastAPI + SQLAlchemy 2.0 + Alembic
- Database: Postgres
- Auth: JWT (access + refresh) + Google OAuth
- Dev: Docker Compose
## Quick start (Docker)
1. Create a `.env` file (see `.env.example` for reference) or set environment variables
2. `docker compose build`
3. `docker compose up -d`
Services:
- Frontend: http://localhost:5173
- Backend API: http://localhost:8000
- Backend API Docs: http://localhost:8000/docs
- Postgres: localhost:5433
Default admin credentials (seeded):
- Email: `admin@example.com`
- Password: `admin123`
## Google OAuth setup (dev)
1. Create OAuth credentials in [Google Cloud Console](https://console.cloud.google.com/).
2. Set Authorized redirect URI to `http://localhost:8000/auth/google/callback`.
3. Set the following in `.env`:
- `GOOGLE_CLIENT_ID=your_client_id`
- `GOOGLE_CLIENT_SECRET=your_client_secret`
- `GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback`
- `FRONTEND_URL=http://localhost:5173`
## Environment Variables
Create a `.env` file in the project root with the following variables (see `.env.example`):
```bash
# Database
DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/lomda
# JWT Secrets (change in production!)
JWT_SECRET_KEY=change_me_access
JWT_REFRESH_SECRET_KEY=change_me_refresh
# Frontend URL
FRONTEND_URL=http://localhost:5173
# CORS Origins (comma-separated)
CORS_ORIGINS=http://localhost:5173
# Admin Seed Credentials
ADMIN_SEED_EMAIL=admin@example.com
ADMIN_SEED_PASSWORD=admin123
# Google OAuth (optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
# Upload Directory
UPLOAD_DIR=/data/uploads
# SMTP (optional, for future email features)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
```
## Local development (no Docker)
### Backend:
1. `cd backend`
2. `python -m venv .venv && .venv\Scripts\activate` (Windows) or `source .venv/bin/activate` (Linux/Mac)
3. `pip install -r requirements.txt`
4. `alembic upgrade head`
5. `python -m app.scripts.seed`
6. `uvicorn app.main:app --reload`
### Frontend:
1. `cd frontend`
2. `npm install`
3. Create `frontend/.env` with: `VITE_API_URL=http://localhost:8000`
4. `npm run dev`
## Testing Features
### Google OAuth Login
1. Ensure `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are set in `.env`
2. Visit http://localhost:5173/login
3. Click "Continue with Google"
4. Complete Google sign-in flow
5. You should be redirected back to the app authenticated
### File Uploads
1. Login as admin (`admin@example.com` / `admin123`)
2. Navigate to Admin → Courses → Select a course → Modules
3. Edit a content module
4. Upload a PDF or PPTX presentation
5. Save the module
6. As a learner, enroll in the course and open that module
7. Click "View Presentation" to open the uploaded file
### Achievements
1. Login as a learner
2. Navigate to "Achievements" in the header
3. Complete courses and quizzes to earn achievements:
- **First Course Complete**: Complete your first course
- **Perfect Final**: Score 100% on any final exam
- **Triple Perfect**: Score 100% on three quiz attempts in a row
- **Fast Finisher**: Complete a course within 24 hours of enrollment
- **Consistent Learner**: Log in on 5 different days
### Admin Analytics
1. Login as admin
2. Navigate to Admin → Analytics
3. View user and course statistics
4. Click "View Details" to see detailed information on specific users or courses
### Theme Toggle
- Click the sun/moon icon in the header to switch between light and dark themes
- Theme preference is saved in localStorage
## Database Migrations
After model changes:
1. `cd backend`
2. `alembic revision --autogenerate -m "description"`
3. `alembic upgrade head`
In Docker, migrations run automatically on container startup.
## Architecture Notes
- **Locked Progression**: On enrollment, first module is `unlocked`, rest are `locked`. Content modules mark completed when viewed. Quiz modules mark completed only if score >= pass_score.
- **Admin vs Learner**: Admins can preview courses without enrolling. Learners must enroll and follow progression rules.
- **Groups**: Courses can be restricted to specific groups. If a course has no groups, it's public (all learners can see).
- **Uploads**: Stored on disk in `UPLOAD_DIR` (/data/uploads by default). Metadata tracked in database. Only authenticated users can download.
- **Achievements**: Evaluated after module completion and quiz submission. Uses login_events table to track consistent learner achievement.
## Troubleshooting
### Google OAuth 307 Redirects
- Ensure `GOOGLE_REDIRECT_URI` matches exactly what's configured in Google Cloud Console
- Backend uses `redirect_slashes=False` to prevent automatic trailing slash redirects
### Uploads Not Working
- Ensure `UPLOAD_DIR` exists and backend has write permissions
- In Docker, uploads are stored in the `upload_data` volume
### Dark Mode Not Persisting
- Check browser localStorage for `lomda_theme` key
- Clear localStorage and try toggling theme again
## Environment
- Do not commit real secrets. Use `.env` locally and keep it in `.gitignore`.
- See `.env.example` for required values.
## License
MIT

17
backend/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY ./app /app/app
COPY ./migrations /app/migrations
COPY ./alembic.ini /app/alembic.ini
EXPOSE 8000
CMD ["bash", "-lc", "alembic upgrade head && python -m app.scripts.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"]

35
backend/alembic.ini Normal file
View File

@ -0,0 +1,35 @@
[alembic]
script_location = migrations
sqlalchemy.url = postgresql+psycopg2://postgres:postgres@db:5432/lomda
[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 = %(levelname)-5.5s [%(name)s] %(message)s

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@


137
backend/app/achievements.py Normal file
View File

@ -0,0 +1,137 @@
"""Achievement evaluation and awarding logic"""
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import func, distinct
from app.models import (
User,
Achievement,
UserAchievement,
QuizAttempt,
LoginEvent,
Enrollment,
ModuleProgress,
Course,
Module,
ProgressStatus,
)
def check_and_award_achievements(db: Session, user_id: int):
"""Check and award all eligible achievements for a user"""
user = db.get(User, user_id)
if not user:
return
# Get all achievements
all_achievements = db.query(Achievement).all()
existing_user_achievements = {
ua.achievement_id
for ua in db.query(UserAchievement).filter(UserAchievement.user_id == user_id).all()
}
for achievement in all_achievements:
if achievement.id in existing_user_achievements:
continue
earned = False
if achievement.code == "FIRST_COURSE_COMPLETE":
# Check if user has completed any course
completed_count = (
db.query(func.count(distinct(ModuleProgress.module_id)))
.join(Module, Module.id == ModuleProgress.module_id)
.filter(
ModuleProgress.user_id == user_id,
ModuleProgress.status == ProgressStatus.completed,
)
.scalar()
)
# Check if all modules in at least one course are completed
user_courses = (
db.query(Course.id)
.join(Enrollment, Enrollment.course_id == Course.id)
.filter(Enrollment.user_id == user_id)
.all()
)
for (course_id,) in user_courses:
total_modules = db.query(func.count(Module.id)).filter(Module.course_id == course_id).scalar()
completed_modules = (
db.query(func.count(Module.id))
.join(ModuleProgress, ModuleProgress.module_id == Module.id)
.filter(
Module.course_id == course_id,
ModuleProgress.user_id == user_id,
ModuleProgress.status == ProgressStatus.completed,
)
.scalar()
)
if total_modules > 0 and completed_modules >= total_modules:
earned = True
break
elif achievement.code == "PERFECT_FINAL":
# Check if any quiz attempt has score 100
perfect = (
db.query(QuizAttempt)
.filter(QuizAttempt.user_id == user_id, QuizAttempt.score == 100.0)
.first()
)
earned = perfect is not None
elif achievement.code == "THREE_PERFECTS_ROW":
# Check last 3 attempts for consecutive 100s
attempts = (
db.query(QuizAttempt)
.filter(QuizAttempt.user_id == user_id)
.order_by(QuizAttempt.submitted_at.desc())
.limit(3)
.all()
)
if len(attempts) >= 3 and all(a.score == 100.0 for a in attempts):
earned = True
elif achievement.code == "FAST_FINISHER":
# Check if any course was completed within 24h of enrollment
user_enrollments = (
db.query(Enrollment)
.filter(Enrollment.user_id == user_id)
.all()
)
for enrollment in user_enrollments:
total_modules = (
db.query(func.count(Module.id))
.filter(Module.course_id == enrollment.course_id)
.scalar()
)
completed_modules = (
db.query(ModuleProgress)
.join(Module, Module.id == ModuleProgress.module_id)
.filter(
Module.course_id == enrollment.course_id,
ModuleProgress.user_id == user_id,
ModuleProgress.status == ProgressStatus.completed,
)
.all()
)
if total_modules > 0 and len(completed_modules) >= total_modules:
# Check if last completion was within 24h of enrollment
last_completion = max(m.completed_at for m in completed_modules if m.completed_at)
if last_completion and (last_completion - enrollment.enrolled_at) <= timedelta(hours=24):
earned = True
break
elif achievement.code == "CONSISTENT_LEARNER":
# Check if user has logged in on 5 different days
distinct_days = (
db.query(func.date(LoginEvent.logged_in_at))
.filter(LoginEvent.user_id == user_id)
.distinct()
.count()
)
earned = distinct_days >= 5
if earned:
db.add(UserAchievement(user_id=user_id, achievement_id=achievement.id))
db.commit()

37
backend/app/auth.py Normal file
View File

@ -0,0 +1,37 @@
from datetime import datetime, timedelta
from typing import Any, Optional
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
# bcrypt limits to 72 bytes; truncate defensively to avoid runtime errors
if len(password.encode("utf-8")) > 72:
password = password.encode("utf-8")[:72].decode("utf-8", errors="ignore")
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def create_access_token(subject: str, expires_minutes: Optional[int] = None) -> str:
expire = datetime.utcnow() + timedelta(minutes=expires_minutes or settings.access_token_expires_minutes)
payload = {"sub": subject, "exp": expire, "type": "access"}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def create_refresh_token(subject: str, expires_days: Optional[int] = None) -> str:
expire = datetime.utcnow() + timedelta(days=expires_days or settings.refresh_token_expires_days)
payload = {"sub": subject, "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.jwt_refresh_secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str, token_type: str) -> dict[str, Any]:
secret = settings.jwt_secret_key if token_type == "access" else settings.jwt_refresh_secret_key
return jwt.decode(token, secret, algorithms=[settings.jwt_algorithm])

View File

@ -0,0 +1,29 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql+psycopg2://postgres:postgres@db:5432/lomda"
jwt_secret_key: str = "change_me_access"
jwt_refresh_secret_key: str = "change_me_refresh"
jwt_algorithm: str = "HS256"
access_token_expires_minutes: int = 30
refresh_token_expires_days: int = 7
cors_origins: str = "http://localhost:5173"
admin_seed_email: str = "admin@lomda.local"
admin_seed_password: str = "admin123"
frontend_url: str = "http://localhost:5173"
google_client_id: str = ""
google_client_secret: str = ""
google_redirect_uri: str = "http://localhost:8000/auth/google/callback"
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = ""
upload_dir: str = "/data/uploads"
class Config:
env_file = ".env"
settings = Settings()

12
backend/app/db.py Normal file
View File

@ -0,0 +1,12 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.core.config import settings
class Base(DeclarativeBase):
pass
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)

42
backend/app/deps.py Normal file
View File

@ -0,0 +1,42 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from sqlalchemy.orm import Session
from app.auth import decode_token
from app.db import SessionLocal
from app.models import User, UserRole
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
try:
payload = decode_token(token, "access")
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user_id = int(payload.get("sub"))
except (JWTError, ValueError):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User inactive")
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role != UserRole.admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
return user

49
backend/app/main.py Normal file
View File

@ -0,0 +1,49 @@
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.core.config import settings
from app.routers import auth, admin, learner
from app.deps import get_current_user, get_db
from app.models import Upload
import uvicorn
app = FastAPI(title="Lomda Hub", redirect_slashes=False)
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=origins or ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(learner.router)
@app.get("/")
def root():
return {"status": "ok"}
@app.get("/me")
def me(user=Depends(get_current_user)):
return {"id": user.id, "email": user.email, "role": user.role}
@app.get("/uploads/{upload_id}")
def serve_upload(upload_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
upload = db.get(Upload, upload_id)
if not upload:
raise HTTPException(status_code=404, detail="Upload not found")
return FileResponse(upload.file_path, media_type=upload.content_type, filename=upload.filename)
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

229
backend/app/models.py Normal file
View File

@ -0,0 +1,229 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import List, Optional
from sqlalchemy import Boolean, DateTime, Enum as SAEnum, ForeignKey, Integer, String, Text, UniqueConstraint, Float, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.db import Base
class UserRole(str, Enum):
admin = "admin"
learner = "learner"
class ModuleType(str, Enum):
content = "content"
quiz = "quiz"
class ProgressStatus(str, Enum):
locked = "locked"
unlocked = "unlocked"
completed = "completed"
class QuestionType(str, Enum):
mcq = "mcq"
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole, name="role_enum"), default=UserRole.learner)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
oauth_provider: Mapped[Optional[str]] = mapped_column(String(50))
oauth_subject: Mapped[Optional[str]] = mapped_column(String(255))
picture_url: Mapped[Optional[str]] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
enrollments: Mapped[List[Enrollment]] = relationship("Enrollment", back_populates="user")
group_memberships: Mapped[List[GroupMembership]] = relationship("GroupMembership", back_populates="user")
class Course(Base):
__tablename__ = "courses"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(255))
description: Mapped[Optional[str]] = mapped_column(Text)
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
modules: Mapped[List[Module]] = relationship("Module", back_populates="course", cascade="all, delete-orphan")
course_groups: Mapped[List[CourseGroup]] = relationship("CourseGroup", back_populates="course", cascade="all, delete-orphan")
class Module(Base):
__tablename__ = "modules"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id", ondelete="CASCADE"), index=True)
order_index: Mapped[int] = mapped_column(Integer)
type: Mapped[ModuleType] = mapped_column(SAEnum(ModuleType, name="module_type_enum"))
title: Mapped[str] = mapped_column(String(255))
content_text: Mapped[Optional[str]] = mapped_column(Text)
pass_score: Mapped[Optional[int]] = mapped_column(Integer)
upload_id: Mapped[Optional[int]] = mapped_column(ForeignKey("uploads.id", ondelete="SET NULL"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
course: Mapped[Course] = relationship("Course", back_populates="modules")
questions: Mapped[List[QuizQuestion]] = relationship("QuizQuestion", back_populates="module", cascade="all, delete-orphan")
upload: Mapped[Optional["Upload"]] = relationship("Upload", back_populates="modules")
class QuizQuestion(Base):
__tablename__ = "quiz_questions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
module_id: Mapped[int] = mapped_column(ForeignKey("modules.id", ondelete="CASCADE"), index=True)
prompt: Mapped[str] = mapped_column(Text)
question_type: Mapped[QuestionType] = mapped_column(SAEnum(QuestionType, name="question_type_enum"), default=QuestionType.mcq)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
module: Mapped[Module] = relationship("Module", back_populates="questions")
choices: Mapped[List[QuizChoice]] = relationship("QuizChoice", back_populates="question", cascade="all, delete-orphan")
class QuizChoice(Base):
__tablename__ = "quiz_choices"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
question_id: Mapped[int] = mapped_column(ForeignKey("quiz_questions.id", ondelete="CASCADE"), index=True)
text: Mapped[str] = mapped_column(Text)
is_correct: Mapped[bool] = mapped_column(Boolean, default=False)
question: Mapped[QuizQuestion] = relationship("QuizQuestion", back_populates="choices")
class Enrollment(Base):
__tablename__ = "enrollments"
__table_args__ = (UniqueConstraint("user_id", "course_id", name="uq_enrollment"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id", ondelete="CASCADE"), index=True)
enrolled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[User] = relationship("User", back_populates="enrollments")
course: Mapped[Course] = relationship("Course")
class ModuleProgress(Base):
__tablename__ = "module_progress"
__table_args__ = (UniqueConstraint("user_id", "module_id", name="uq_module_progress"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
module_id: Mapped[int] = mapped_column(ForeignKey("modules.id", ondelete="CASCADE"), index=True)
status: Mapped[ProgressStatus] = mapped_column(SAEnum(ProgressStatus, name="progress_status_enum"), default=ProgressStatus.locked)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
score: Mapped[Optional[float]] = mapped_column(Float)
class QuizAttempt(Base):
__tablename__ = "quiz_attempts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
module_id: Mapped[int] = mapped_column(ForeignKey("modules.id", ondelete="CASCADE"), index=True)
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
score: Mapped[float] = mapped_column(Float)
passed: Mapped[bool] = mapped_column(Boolean)
answers_json: Mapped[dict] = mapped_column(JSON)
class Group(Base):
__tablename__ = "groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
memberships: Mapped[List[GroupMembership]] = relationship("GroupMembership", back_populates="group", cascade="all, delete-orphan")
course_groups: Mapped[List[CourseGroup]] = relationship("CourseGroup", back_populates="group", cascade="all, delete-orphan")
class GroupMembership(Base):
__tablename__ = "group_memberships"
__table_args__ = (UniqueConstraint("group_id", "user_id", name="uq_group_membership"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
group_id: Mapped[int] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
group: Mapped[Group] = relationship("Group", back_populates="memberships")
user: Mapped[User] = relationship("User", back_populates="group_memberships")
class CourseGroup(Base):
__tablename__ = "course_groups"
__table_args__ = (UniqueConstraint("course_id", "group_id", name="uq_course_group"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id", ondelete="CASCADE"), index=True)
group_id: Mapped[int] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), index=True)
course: Mapped[Course] = relationship("Course", back_populates="course_groups")
group: Mapped[Group] = relationship("Group", back_populates="course_groups")
class Upload(Base):
__tablename__ = "uploads"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str] = mapped_column(String(100))
file_path: Mapped[str] = mapped_column(String(512))
size_bytes: Mapped[int] = mapped_column(Integer)
uploaded_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
uploader: Mapped[User] = relationship("User")
modules: Mapped[List[Module]] = relationship("Module", back_populates="upload")
class Achievement(Base):
__tablename__ = "achievements"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(100), unique=True, index=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user_achievements: Mapped[List["UserAchievement"]] = relationship("UserAchievement", back_populates="achievement", cascade="all, delete-orphan")
class UserAchievement(Base):
__tablename__ = "user_achievements"
__table_args__ = (UniqueConstraint("user_id", "achievement_id", name="uq_user_achievement"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
achievement_id: Mapped[int] = mapped_column(ForeignKey("achievements.id", ondelete="CASCADE"), index=True)
earned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[User] = relationship("User")
achievement: Mapped[Achievement] = relationship("Achievement", back_populates="user_achievements")
class LoginEvent(Base):
__tablename__ = "login_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
logged_in_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[User] = relationship("User")

View File

@ -0,0 +1,3 @@
from app.routers import auth, admin, learner
__all__ = ["auth", "admin", "learner"]

View File

@ -0,0 +1,570 @@
from typing import List, Dict, Any
import os
import secrets
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.core.config import settings
from app.deps import get_db, require_admin, get_current_user
from app.models import Course, Module, QuizQuestion, QuizChoice, CourseGroup, Group, GroupMembership, User, UserRole, Upload, Enrollment, ModuleProgress, QuizAttempt, ProgressStatus
from app.schemas import (
AdminCourseCreate,
AdminCourseOut,
AdminCourseUpdate,
AdminUserOut,
AdminUserUpdate,
GroupCreate,
GroupOut,
GroupUpdate,
GroupMemberAdd,
ModuleCreate,
ModuleOut,
ModuleUpdate,
QuizQuestionCreate,
QuizQuestionOut,
QuizQuestionUpdate,
UploadOut,
)
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
@router.get("/courses", response_model=List[AdminCourseOut])
def list_courses(db: Session = Depends(get_db)):
courses = db.query(Course).order_by(Course.created_at.desc()).all()
results = []
for course in courses:
group_ids = [cg.group_id for cg in course.course_groups]
results.append(
AdminCourseOut(
id=course.id,
title=course.title,
description=course.description,
is_published=course.is_published,
created_at=course.created_at,
updated_at=course.updated_at,
group_ids=group_ids,
)
)
return results
@router.post("/courses", response_model=AdminCourseOut)
def create_course(payload: AdminCourseCreate, db: Session = Depends(get_db)):
course = Course(**payload.model_dump(exclude={"group_ids"}))
db.add(course)
db.commit()
db.refresh(course)
if payload.group_ids:
for gid in payload.group_ids:
db.add(CourseGroup(course_id=course.id, group_id=gid))
db.commit()
db.refresh(course)
return AdminCourseOut(
id=course.id,
title=course.title,
description=course.description,
is_published=course.is_published,
created_at=course.created_at,
updated_at=course.updated_at,
group_ids=[cg.group_id for cg in course.course_groups],
)
@router.put("/courses/{course_id}", response_model=AdminCourseOut)
def update_course(course_id: int, payload: AdminCourseUpdate, db: Session = Depends(get_db)):
course = db.get(Course, course_id)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
data = payload.model_dump(exclude_unset=True)
group_ids = data.pop("group_ids", None)
for key, value in data.items():
setattr(course, key, value)
if group_ids is not None:
course.course_groups.clear()
for gid in group_ids:
course.course_groups.append(CourseGroup(group_id=gid))
db.commit()
db.refresh(course)
return AdminCourseOut(
id=course.id,
title=course.title,
description=course.description,
is_published=course.is_published,
created_at=course.created_at,
updated_at=course.updated_at,
group_ids=[cg.group_id for cg in course.course_groups],
)
@router.delete("/courses/{course_id}")
def delete_course(course_id: int, db: Session = Depends(get_db)):
course = db.get(Course, course_id)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
db.delete(course)
db.commit()
return {"ok": True}
@router.patch("/courses/{course_id}/publish", response_model=AdminCourseOut)
def toggle_publish(course_id: int, db: Session = Depends(get_db)):
course = db.get(Course, course_id)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
course.is_published = not course.is_published
db.commit()
db.refresh(course)
return AdminCourseOut(
id=course.id,
title=course.title,
description=course.description,
is_published=course.is_published,
created_at=course.created_at,
updated_at=course.updated_at,
group_ids=[cg.group_id for cg in course.course_groups],
)
@router.get("/courses/{course_id}/modules", response_model=List[ModuleOut])
def list_modules(course_id: int, db: Session = Depends(get_db)):
return (
db.query(Module)
.filter(Module.course_id == course_id)
.order_by(Module.order_index)
.all()
)
@router.post("/courses/{course_id}/modules", response_model=ModuleOut)
def create_module(course_id: int, payload: ModuleCreate, db: Session = Depends(get_db)):
course = db.get(Course, course_id)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
module = Module(course_id=course_id, **payload.model_dump())
db.add(module)
db.commit()
db.refresh(module)
return module
@router.put("/modules/{module_id}", response_model=ModuleOut)
def update_module(module_id: int, payload: ModuleUpdate, db: Session = Depends(get_db)):
module = db.get(Module, module_id)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(module, key, value)
db.commit()
db.refresh(module)
return module
@router.get("/modules/{module_id}", response_model=ModuleOut)
def get_module(module_id: int, db: Session = Depends(get_db)):
module = db.get(Module, module_id)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
return module
@router.get("/users", response_model=List[AdminUserOut])
def list_users(q: str | None = None, db: Session = Depends(get_db)):
query = db.query(User)
if q:
query = query.filter(User.email.ilike(f"%{q}%"))
return query.order_by(User.created_at.desc()).all()
@router.get("/users/{user_id}", response_model=AdminUserOut)
def get_user(user_id: int, db: Session = Depends(get_db)):
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.patch("/users/{user_id}", response_model=AdminUserOut)
def update_user(user_id: int, payload: AdminUserUpdate, db: Session = Depends(get_db)):
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
data = payload.model_dump(exclude_unset=True)
if "role" in data and data["role"] in [UserRole.admin, UserRole.learner, "admin", "learner"]:
user.role = UserRole(data["role"])
if "is_active" in data:
user.is_active = data["is_active"]
db.commit()
db.refresh(user)
return user
@router.get("/groups", response_model=List[GroupOut])
def list_groups(db: Session = Depends(get_db)):
return db.query(Group).order_by(Group.created_at.desc()).all()
@router.post("/groups", response_model=GroupOut)
def create_group(payload: GroupCreate, db: Session = Depends(get_db)):
existing = db.query(Group).filter(Group.name == payload.name).first()
if existing:
raise HTTPException(status_code=400, detail="Group name already exists")
group = Group(name=payload.name)
db.add(group)
db.commit()
db.refresh(group)
return group
@router.put("/groups/{group_id}", response_model=GroupOut)
def update_group(group_id: int, payload: GroupUpdate, db: Session = Depends(get_db)):
group = db.get(Group, group_id)
if not group:
raise HTTPException(status_code=404, detail="Group not found")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(group, key, value)
db.commit()
db.refresh(group)
return group
@router.delete("/groups/{group_id}")
def delete_group(group_id: int, db: Session = Depends(get_db)):
group = db.get(Group, group_id)
if not group:
raise HTTPException(status_code=404, detail="Group not found")
db.delete(group)
db.commit()
return {"ok": True}
@router.get("/groups/{group_id}/members", response_model=List[AdminUserOut])
def list_group_members(group_id: int, db: Session = Depends(get_db)):
group = db.get(Group, group_id)
if not group:
raise HTTPException(status_code=404, detail="Group not found")
members = (
db.query(User)
.join(GroupMembership, GroupMembership.user_id == User.id)
.filter(GroupMembership.group_id == group_id)
.all()
)
return members
@router.post("/groups/{group_id}/members")
def add_group_member(group_id: int, payload: GroupMemberAdd, db: Session = Depends(get_db)):
group = db.get(Group, group_id)
if not group:
raise HTTPException(status_code=404, detail="Group not found")
user = None
if payload.user_id:
user = db.get(User, payload.user_id)
if not user and payload.email:
user = db.query(User).filter(User.email == payload.email).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing = (
db.query(GroupMembership)
.filter(GroupMembership.group_id == group_id, GroupMembership.user_id == user.id)
.first()
)
if existing:
return {"ok": True}
db.add(GroupMembership(group_id=group_id, user_id=user.id))
db.commit()
return {"ok": True}
@router.delete("/groups/{group_id}/members/{user_id}")
def remove_group_member(group_id: int, user_id: int, db: Session = Depends(get_db)):
membership = (
db.query(GroupMembership)
.filter(GroupMembership.group_id == group_id, GroupMembership.user_id == user_id)
.first()
)
if not membership:
raise HTTPException(status_code=404, detail="Membership not found")
db.delete(membership)
db.commit()
return {"ok": True}
@router.delete("/modules/{module_id}")
def delete_module(module_id: int, db: Session = Depends(get_db)):
module = db.get(Module, module_id)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
db.delete(module)
db.commit()
return {"ok": True}
@router.get("/modules/{module_id}/questions", response_model=List[QuizQuestionOut])
def list_questions(module_id: int, db: Session = Depends(get_db)):
return (
db.query(QuizQuestion)
.filter(QuizQuestion.module_id == module_id)
.all()
)
@router.post("/modules/{module_id}/questions", response_model=QuizQuestionOut)
def create_question(module_id: int, payload: QuizQuestionCreate, db: Session = Depends(get_db)):
module = db.get(Module, module_id)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
question = QuizQuestion(module_id=module_id, prompt=payload.prompt, question_type=payload.question_type)
db.add(question)
db.flush()
for choice in payload.choices:
db.add(QuizChoice(question_id=question.id, text=choice.text, is_correct=choice.is_correct))
db.commit()
db.refresh(question)
return question
@router.put("/questions/{question_id}", response_model=QuizQuestionOut)
def update_question(question_id: int, payload: QuizQuestionUpdate, db: Session = Depends(get_db)):
question = db.get(QuizQuestion, question_id)
if not question:
raise HTTPException(status_code=404, detail="Question not found")
data = payload.model_dump(exclude_unset=True)
if "prompt" in data:
question.prompt = data["prompt"]
if "question_type" in data:
question.question_type = data["question_type"]
if "choices" in data:
question.choices.clear()
for choice in data["choices"] or []:
question.choices.append(QuizChoice(text=choice.text, is_correct=choice.is_correct))
db.commit()
db.refresh(question)
return question
@router.delete("/questions/{question_id}")
def delete_question(question_id: int, db: Session = Depends(get_db)):
question = db.get(QuizQuestion, question_id)
if not question:
raise HTTPException(status_code=404, detail="Question not found")
db.delete(question)
db.commit()
return {"ok": True}
@router.post("/uploads", response_model=UploadOut)
async def upload_file(file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(get_current_user)):
# Validate file type (PDF or PPTX only)
allowed_types = ["application/pdf", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.ms-powerpoint"]
if file.content_type not in allowed_types:
raise HTTPException(status_code=400, detail="Only PDF and PPTX files are allowed")
# Ensure upload directory exists
upload_dir = Path(settings.upload_dir)
upload_dir.mkdir(parents=True, exist_ok=True)
# Generate unique filename
file_ext = Path(file.filename).suffix
unique_name = f"{secrets.token_urlsafe(16)}{file_ext}"
file_path = upload_dir / unique_name
# Save file
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
# Store metadata in database
upload = Upload(
filename=file.filename,
content_type=file.content_type,
file_path=str(file_path),
size_bytes=len(content),
uploaded_by=user.id,
)
db.add(upload)
db.commit()
db.refresh(upload)
return upload
@router.get("/uploads/{upload_id}")
def get_upload(upload_id: int, db: Session = Depends(get_db)):
upload = db.get(Upload, upload_id)
if not upload:
raise HTTPException(status_code=404, detail="Upload not found")
return upload
@router.get("/stats/users")
def stats_users(db: Session = Depends(get_db)) -> List[Dict[str, Any]]:
"""Get statistics for all users"""
users = db.query(User).filter(User.role == UserRole.learner).all()
result = []
for user in users:
enrollments_count = db.query(func.count(Enrollment.id)).filter(Enrollment.user_id == user.id).scalar()
completed_count = (
db.query(func.count(func.distinct(ModuleProgress.module_id)))
.join(Module, Module.id == ModuleProgress.module_id)
.filter(
ModuleProgress.user_id == user.id,
ModuleProgress.status == ProgressStatus.completed,
)
.scalar()
)
attempts_count = db.query(func.count(QuizAttempt.id)).filter(QuizAttempt.user_id == user.id).scalar()
result.append({
"id": user.id,
"email": user.email,
"enrollments_count": enrollments_count,
"completed_modules_count": completed_count,
"attempts_count": attempts_count,
"created_at": user.created_at,
})
return result
@router.get("/stats/users/{user_id}")
def stats_user(user_id: int, db: Session = Depends(get_db)) -> Dict[str, Any]:
"""Get detailed statistics for a specific user"""
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
enrollments = db.query(Enrollment).filter(Enrollment.user_id == user_id).all()
courses_data = []
for enrollment in enrollments:
course = db.get(Course, enrollment.course_id)
if not course:
continue
modules = db.query(Module).filter(Module.course_id == course.id).order_by(Module.order_index).all()
progress_map = {
p.module_id: p
for p in db.query(ModuleProgress).filter(ModuleProgress.user_id == user_id).all()
}
completed = [p for p in progress_map.values() if p.status == ProgressStatus.completed]
total_modules = len(modules)
completed_modules = len([m for m in modules if progress_map.get(m.id) and progress_map.get(m.id).status == ProgressStatus.completed])
progress_percent = (completed_modules / total_modules * 100) if total_modules > 0 else 0
# Get quiz attempts for this course
attempts_data = []
for module in modules:
if module.type.value == "quiz":
attempts = (
db.query(QuizAttempt)
.filter(QuizAttempt.user_id == user_id, QuizAttempt.module_id == module.id)
.order_by(QuizAttempt.submitted_at.desc())
.all()
)
for attempt in attempts:
attempts_data.append({
"module_id": module.id,
"module_title": module.title,
"score": attempt.score,
"passed": attempt.passed,
"submitted_at": attempt.submitted_at,
})
courses_data.append({
"course_id": course.id,
"course_title": course.title,
"enrolled_at": enrollment.enrolled_at,
"progress_percent": progress_percent,
"completed_modules": completed_modules,
"total_modules": total_modules,
"attempts": attempts_data,
})
return {
"user_id": user_id,
"email": user.email,
"courses": courses_data,
}
@router.get("/stats/courses")
def stats_courses(db: Session = Depends(get_db)) -> List[Dict[str, Any]]:
"""Get statistics for all courses"""
courses = db.query(Course).all()
result = []
for course in courses:
enrollments_count = db.query(func.count(Enrollment.id)).filter(Enrollment.course_id == course.id).scalar()
# Count users who completed all modules
modules = db.query(Module).filter(Module.course_id == course.id).all()
total_modules = len(modules)
completions_count = 0
if total_modules > 0:
enrollments = db.query(Enrollment).filter(Enrollment.course_id == course.id).all()
for enrollment in enrollments:
completed_modules = (
db.query(func.count(ModuleProgress.id))
.join(Module, Module.id == ModuleProgress.module_id)
.filter(
Module.course_id == course.id,
ModuleProgress.user_id == enrollment.user_id,
ModuleProgress.status == ProgressStatus.completed,
)
.scalar()
)
if completed_modules >= total_modules:
completions_count += 1
# Get final exam average (last module if it's a quiz)
final_exam_avg = None
if modules:
last_module = modules[-1]
if last_module.type.value == "quiz":
avg_score = db.query(func.avg(QuizAttempt.score)).filter(QuizAttempt.module_id == last_module.id).scalar()
final_exam_avg = float(avg_score) if avg_score else None
result.append({
"id": course.id,
"title": course.title,
"enrollments_count": enrollments_count,
"completions_count": completions_count,
"final_exam_avg_score": final_exam_avg,
})
return result
@router.get("/stats/courses/{course_id}")
def stats_course(course_id: int, db: Session = Depends(get_db)) -> Dict[str, Any]:
"""Get detailed statistics for a specific course"""
course = db.get(Course, course_id)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
enrollments = db.query(Enrollment).filter(Enrollment.course_id == course_id).all()
modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all()
attempts_distribution = []
for module in modules:
if module.type.value == "quiz":
attempts = db.query(QuizAttempt).filter(QuizAttempt.module_id == module.id).all()
attempts_distribution.append({
"module_id": module.id,
"module_title": module.title,
"total_attempts": len(attempts),
"avg_score": sum(a.score for a in attempts) / len(attempts) if attempts else 0,
"pass_rate": sum(1 for a in attempts if a.passed) / len(attempts) * 100 if attempts else 0,
})
return {
"course_id": course.id,
"title": course.title,
"enrollments_count": len(enrollments),
"total_modules": len(modules),
"attempts_distribution": attempts_distribution,
}

183
backend/app/routers/auth.py Normal file
View File

@ -0,0 +1,183 @@
from datetime import datetime, timedelta
import secrets
import requests
import logging
from jose import jwt
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.auth import create_access_token, create_refresh_token, hash_password, verify_password, decode_token
from app.core.config import settings
from app.deps import get_db, get_current_user
from app.models import User, UserRole, LoginEvent
from app.schemas import TokenPair, UserCreate, UserOut, TokenRefresh
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserOut)
def register(payload: UserCreate, db: Session = Depends(get_db)):
existing = db.query(User).filter(User.email == payload.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
user = User(email=payload.email, password_hash=hash_password(payload.password), role=UserRole.learner)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login", response_model=TokenPair)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form.username).first()
if not user or not verify_password(form.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User inactive")
# Track login event
db.add(LoginEvent(user_id=user.id))
db.commit()
return TokenPair(
access_token=create_access_token(str(user.id)),
refresh_token=create_refresh_token(str(user.id)),
)
@router.post("/refresh", response_model=TokenPair)
def refresh(payload: TokenRefresh):
decoded = decode_token(payload.refresh_token, "refresh")
if decoded.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
user_id = decoded.get("sub")
return TokenPair(
access_token=create_access_token(str(user_id)),
refresh_token=create_refresh_token(str(user_id)),
)
@router.get("/me", response_model=UserOut)
def me(user: User = Depends(get_current_user)):
return user
def build_google_oauth_url(state: str) -> str:
params = {
"client_id": settings.google_client_id,
"redirect_uri": settings.google_redirect_uri,
"response_type": "code",
"scope": "openid email profile",
"access_type": "online",
"include_granted_scopes": "true",
"state": state,
"prompt": "select_account",
}
base = "https://accounts.google.com/o/oauth2/v2/auth"
query = "&".join([f"{k}={requests.utils.quote(str(v))}" for k, v in params.items()])
return f"{base}?{query}"
@router.get("/google/login")
def google_login():
if not settings.google_client_id or not settings.google_client_secret:
raise HTTPException(status_code=400, detail="Google OAuth not configured")
nonce = secrets.token_urlsafe(16)
state_payload = {
"nonce": nonce,
"exp": int((datetime.utcnow() + timedelta(minutes=10)).timestamp()),
"type": "oauth_state",
}
state = jwt.encode(state_payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
auth_url = build_google_oauth_url(state)
logger.info(f"Redirecting to Google OAuth: {auth_url[:100]}...")
return RedirectResponse(auth_url, status_code=302)
@router.get("/google/callback")
def google_callback(code: str | None = None, state: str | None = None, db: Session = Depends(get_db)):
logger.info("Google OAuth callback received")
if not code or not state:
logger.error(f"Missing code or state: code={bool(code)}, state={bool(state)}")
raise HTTPException(status_code=400, detail="Missing code or state")
try:
decoded = jwt.decode(state, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except Exception as e:
logger.error(f"Failed to decode state: {e}")
raise HTTPException(status_code=400, detail="Invalid state")
if decoded.get("type") != "oauth_state":
logger.error("State type mismatch")
raise HTTPException(status_code=400, detail="Invalid state")
token_res = requests.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": settings.google_redirect_uri,
"grant_type": "authorization_code",
},
timeout=10,
)
if token_res.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to exchange code")
token_data = token_res.json()
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Missing access token")
userinfo_res = requests.get(
"https://www.googleapis.com/oauth2/v3/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
if userinfo_res.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to fetch user info")
profile = userinfo_res.json()
email = profile.get("email")
sub = profile.get("sub")
picture = profile.get("picture")
if not email:
raise HTTPException(status_code=400, detail="Email not available")
user = db.query(User).filter(User.email == email).first()
if not user:
user = User(
email=email,
password_hash=hash_password(secrets.token_urlsafe(24)),
role=UserRole.learner,
oauth_provider="google",
oauth_subject=sub,
picture_url=picture,
)
db.add(user)
db.commit()
db.refresh(user)
else:
if user.oauth_provider != "google":
user.oauth_provider = "google"
if not user.oauth_subject:
user.oauth_subject = sub
if picture and not user.picture_url:
user.picture_url = picture
db.commit()
db.refresh(user)
# Track login event
db.add(LoginEvent(user_id=user.id))
db.commit()
token_pair = TokenPair(
access_token=create_access_token(str(user.id)),
refresh_token=create_refresh_token(str(user.id)),
)
redirect_url = f"{settings.frontend_url}/auth/callback?access_token={token_pair.access_token}&refresh_token={token_pair.refresh_token}"
logger.info(f"OAuth successful for user {user.email}, redirecting to frontend")
return RedirectResponse(redirect_url, status_code=302)

View File

@ -0,0 +1,282 @@
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.deps import get_db, get_current_user
from app.models import Course, Enrollment, Module, ModuleProgress, ModuleType, ProgressStatus, QuizAttempt, CourseGroup, GroupMembership, UserRole, Achievement, UserAchievement
from app.schemas import CourseOut, CourseWithModules, EnrollOut, ModuleWithProgress, QuizResult, QuizSubmit, ProgressOut, AchievementOut
from app.services.progression import ensure_module_progress, recompute_progress, complete_content_module
from app.services.quiz import grade_quiz
from app.achievements import check_and_award_achievements
router = APIRouter(tags=["learner"])
@router.get("/courses", response_model=List[CourseOut])
def list_courses(db: Session = Depends(get_db), user=Depends(get_current_user)):
if user.role == UserRole.admin:
return db.query(Course).order_by(Course.created_at.desc()).all()
user_group_ids = [
gm.group_id for gm in db.query(GroupMembership).filter(GroupMembership.user_id == user.id).all()
]
courses = db.query(Course).filter(Course.is_published == True).order_by(Course.created_at.desc()).all()
allowed = []
for course in courses:
group_ids = [cg.group_id for cg in course.course_groups]
if not group_ids:
allowed.append(course)
elif any(gid in user_group_ids for gid in group_ids):
allowed.append(course)
return allowed
@router.post("/courses/{course_id}/enroll", response_model=EnrollOut)
def enroll(course_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
course = db.get(Course, course_id)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
if user.role != UserRole.admin:
if not course.is_published:
raise HTTPException(status_code=404, detail="Course not found")
group_ids = [cg.group_id for cg in course.course_groups]
if group_ids:
user_group_ids = [
gm.group_id for gm in db.query(GroupMembership).filter(GroupMembership.user_id == user.id).all()
]
if not any(gid in user_group_ids for gid in group_ids):
raise HTTPException(status_code=403, detail="Not allowed")
existing = (
db.query(Enrollment)
.filter(Enrollment.user_id == user.id, Enrollment.course_id == course_id)
.first()
)
if existing:
return EnrollOut(course_id=course_id, enrolled_at=existing.enrolled_at)
enrollment = Enrollment(user_id=user.id, course_id=course_id)
db.add(enrollment)
db.flush()
ensure_module_progress(db, user.id, course_id)
recompute_progress(db, user.id, course_id)
db.refresh(enrollment)
return EnrollOut(course_id=course_id, enrolled_at=enrollment.enrolled_at)
@router.get("/courses/{course_id}", response_model=CourseWithModules)
def get_course(course_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
course = db.get(Course, course_id)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
if user.role != UserRole.admin:
if not course.is_published:
raise HTTPException(status_code=404, detail="Course not found")
group_ids = [cg.group_id for cg in course.course_groups]
if group_ids:
user_group_ids = [
gm.group_id for gm in db.query(GroupMembership).filter(GroupMembership.user_id == user.id).all()
]
if not any(gid in user_group_ids for gid in group_ids):
raise HTTPException(status_code=403, detail="Not allowed")
enrollment = (
db.query(Enrollment)
.filter(Enrollment.user_id == user.id, Enrollment.course_id == course_id)
.first()
)
if not enrollment and user.role != UserRole.admin:
raise HTTPException(status_code=403, detail="Not enrolled")
if not enrollment and user.role == UserRole.admin:
# preview mode for admin without enrolling
modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all()
module_views: List[ModuleWithProgress] = []
for idx, module in enumerate(modules):
status = ProgressStatus.unlocked if idx == 0 else ProgressStatus.locked
module_views.append(
ModuleWithProgress(
id=module.id,
order_index=module.order_index,
type=module.type,
title=module.title,
content_text=module.content_text,
pass_score=module.pass_score,
status=status,
completed_at=None,
score=None,
questions=module.questions,
)
)
return CourseWithModules(
id=course.id,
title=course.title,
description=course.description,
is_published=course.is_published,
modules=module_views,
)
ensure_module_progress(db, user.id, course_id)
recompute_progress(db, user.id, course_id)
modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all()
progress_map = {
p.module_id: p
for p in db.query(ModuleProgress).filter(ModuleProgress.user_id == user.id).all()
}
module_views: List[ModuleWithProgress] = []
for module in modules:
progress = progress_map.get(module.id)
module_views.append(
ModuleWithProgress(
id=module.id,
order_index=module.order_index,
type=module.type,
title=module.title,
content_text=module.content_text,
pass_score=module.pass_score,
status=progress.status if progress else ProgressStatus.locked,
completed_at=progress.completed_at if progress else None,
score=progress.score if progress else None,
questions=module.questions,
)
)
return CourseWithModules(
id=course.id,
title=course.title,
description=course.description,
is_published=course.is_published,
modules=module_views,
)
@router.post("/modules/{module_id}/complete", response_model=QuizResult)
def complete_module(module_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
module = db.get(Module, module_id)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
progress = (
db.query(ModuleProgress)
.filter(ModuleProgress.user_id == user.id, ModuleProgress.module_id == module_id)
.first()
)
if not progress or progress.status != ProgressStatus.unlocked:
raise HTTPException(status_code=403, detail="Module locked")
if module.type != ModuleType.content:
raise HTTPException(status_code=400, detail="Not a content module")
complete_content_module(db, user.id, module)
recompute_progress(db, user.id, module.course_id)
check_and_award_achievements(db, user.id)
return QuizResult(score=100.0, passed=True)
@router.post("/modules/{module_id}/quiz/submit", response_model=QuizResult)
def submit_quiz(module_id: int, payload: QuizSubmit, db: Session = Depends(get_db), user=Depends(get_current_user)):
module = db.get(Module, module_id)
if not module or module.type != ModuleType.quiz:
raise HTTPException(status_code=404, detail="Quiz module not found")
progress = (
db.query(ModuleProgress)
.filter(ModuleProgress.user_id == user.id, ModuleProgress.module_id == module_id)
.first()
)
if not progress or progress.status != ProgressStatus.unlocked:
raise HTTPException(status_code=403, detail="Module locked")
score, _, _ = grade_quiz(db, module, payload.answers)
pass_score = module.pass_score or 80
passed = score >= pass_score
attempt = QuizAttempt(
user_id=user.id,
module_id=module_id,
score=score,
passed=passed,
answers_json=payload.answers,
)
db.add(attempt)
progress.score = score
if passed:
progress.status = ProgressStatus.completed
progress.completed_at = datetime.utcnow()
db.commit()
check_and_award_achievements(db, user.id)
recompute_progress(db, user.id, module.course_id)
return QuizResult(score=score, passed=passed)
@router.get("/progress", response_model=ProgressOut)
def overall_progress(db: Session = Depends(get_db), user=Depends(get_current_user)):
enrollments = db.query(Enrollment).filter(Enrollment.user_id == user.id).all()
courses_out = []
for enrollment in enrollments:
course = db.get(Course, enrollment.course_id)
if not course:
continue
modules = db.query(Module).filter(Module.course_id == course.id).order_by(Module.order_index).all()
if not modules:
continue
progress_map = {
p.module_id: p
for p in db.query(ModuleProgress).filter(ModuleProgress.user_id == user.id).all()
}
completed = [p for p in progress_map.values() if p.status == ProgressStatus.completed]
percent = (len(completed) / len(modules)) * 100
final_module = modules[-1]
final_progress = progress_map.get(final_module.id)
final_passed = final_progress.score is not None and final_progress.score >= (final_module.pass_score or 80)
if final_progress and final_progress.score is not None and not final_passed:
status_value = "failed"
elif final_progress and final_progress.status == ProgressStatus.completed and final_passed:
status_value = "passed"
elif percent >= 100:
status_value = "completed"
else:
status_value = "in_progress"
completed_at = None
if completed:
completed_at = max([p.completed_at for p in completed if p.completed_at is not None], default=None)
courses_out.append(
{
"course_id": course.id,
"title": course.title,
"percent": percent,
"status": status_value,
"final_exam_score": final_progress.score if final_progress else None,
"final_exam_passed": final_passed if final_progress else None,
"completed_at": completed_at,
}
)
@router.get("/me/achievements", response_model=List[AchievementOut])
def my_achievements(db: Session = Depends(get_db), user=Depends(get_current_user)):
all_achievements = db.query(Achievement).all()
user_achievements = {
ua.achievement_id: ua
for ua in db.query(UserAchievement).filter(UserAchievement.user_id == user.id).all()
}
result = []
for achievement in all_achievements:
ua = user_achievements.get(achievement.id)
result.append(
AchievementOut(
id=achievement.id,
code=achievement.code,
name=achievement.name,
description=achievement.description,
earned=ua is not None,
earned_at=ua.earned_at if ua else None,
)
)
return result
return {"courses": courses_out}

276
backend/app/schemas.py Normal file
View File

@ -0,0 +1,276 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional, Dict
from pydantic import BaseModel, EmailStr, Field
from pydantic.config import ConfigDict
from app.models import ModuleType, ProgressStatus, QuestionType
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenRefresh(BaseModel):
refresh_token: str
class UserCreate(BaseModel):
email: EmailStr
password: str = Field(min_length=6)
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: EmailStr
role: str
is_active: bool
picture_url: Optional[str] = None
class AdminUserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: EmailStr
role: str
is_active: bool
oauth_provider: Optional[str] = None
oauth_subject: Optional[str] = None
picture_url: Optional[str] = None
created_at: datetime
class AdminUserUpdate(BaseModel):
role: Optional[str] = None
is_active: Optional[bool] = None
class CourseCreate(BaseModel):
title: str
description: Optional[str] = None
is_published: bool = False
class CourseUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
is_published: Optional[bool] = None
class AdminCourseCreate(CourseCreate):
group_ids: List[int] = []
class AdminCourseUpdate(CourseUpdate):
group_ids: Optional[List[int]] = None
class CourseOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
description: Optional[str] = None
is_published: bool
created_at: datetime
updated_at: datetime
class AdminCourseOut(CourseOut):
group_ids: List[int] = []
class ModuleCreate(BaseModel):
order_index: int
type: ModuleType
title: str
content_text: Optional[str] = None
pass_score: Optional[int] = 80
upload_id: Optional[int] = None
class ModuleUpdate(BaseModel):
order_index: Optional[int] = None
type: Optional[ModuleType] = None
title: Optional[str] = None
content_text: Optional[str] = None
pass_score: Optional[int] = None
upload_id: Optional[int] = None
class QuizChoiceCreate(BaseModel):
text: str
is_correct: bool = False
class QuizChoiceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
text: str
is_correct: bool
class QuizChoicePublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
text: str
class QuizQuestionCreate(BaseModel):
prompt: str
question_type: QuestionType = QuestionType.mcq
choices: List[QuizChoiceCreate]
class QuizQuestionUpdate(BaseModel):
prompt: Optional[str] = None
question_type: Optional[QuestionType] = None
choices: Optional[List[QuizChoiceCreate]] = None
class QuizQuestionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
prompt: str
question_type: QuestionType
choices: List[QuizChoiceOut]
class QuizQuestionPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
prompt: str
question_type: QuestionType
choices: List[QuizChoicePublic]
class ModuleOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
course_id: int
order_index: int
type: ModuleType
title: str
content_text: Optional[str] = None
pass_score: Optional[int] = None
upload_id: Optional[int] = None
created_at: datetime
questions: List[QuizQuestionOut] = []
class ModuleProgressOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
module_id: int
status: ProgressStatus
completed_at: Optional[datetime] = None
score: Optional[float] = None
class ModuleWithProgress(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
order_index: int
type: ModuleType
title: str
content_text: Optional[str] = None
pass_score: Optional[int] = None
upload_id: Optional[int] = None
status: ProgressStatus
completed_at: Optional[datetime] = None
score: Optional[float] = None
questions: List[QuizQuestionPublic] = []
class CourseWithModules(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
description: Optional[str] = None
is_published: bool
modules: List[ModuleWithProgress]
class QuizSubmit(BaseModel):
answers: Dict[int, int]
class QuizResult(BaseModel):
score: float
passed: bool
class EnrollOut(BaseModel):
course_id: int
enrolled_at: datetime
class ProgressCourse(BaseModel):
course_id: int
title: str
percent: float
status: str
final_exam_score: Optional[float] = None
final_exam_passed: Optional[bool] = None
completed_at: Optional[datetime] = None
class ProgressOut(BaseModel):
courses: List[ProgressCourse]
class GroupCreate(BaseModel):
name: str
class GroupUpdate(BaseModel):
name: Optional[str] = None
class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
created_at: datetime
class GroupMemberAdd(BaseModel):
user_id: Optional[int] = None
email: Optional[EmailStr] = None
class UploadOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
filename: str
content_type: str
size_bytes: int
created_at: datetime
class AchievementOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
code: str
name: str
description: Optional[str] = None
earned_at: Optional[datetime] = None
earned: bool = False

111
backend/app/scripts/seed.py Normal file
View File

@ -0,0 +1,111 @@
from sqlalchemy.orm import Session
from app.auth import hash_password
from app.core.config import settings
from app.db import SessionLocal
from app.models import Course, Module, ModuleType, QuizQuestion, QuizChoice, User, UserRole, Achievement
def seed():
db: Session = SessionLocal()
try:
admin = db.query(User).filter(User.email == settings.admin_seed_email).first()
if not admin:
admin = User(
email=settings.admin_seed_email,
password_hash=hash_password(settings.admin_seed_password),
role=UserRole.admin,
)
db.add(admin)
# Seed achievements
achievements_data = [
{
"code": "FIRST_COURSE_COMPLETE",
"name": "First Course Complete",
"description": "Complete your first course",
},
{
"code": "PERFECT_FINAL",
"name": "Perfect Final",
"description": "Score 100% on any final exam",
},
{
"code": "THREE_PERFECTS_ROW",
"name": "Triple Perfect",
"description": "Score 100% on three quiz attempts in a row",
},
{
"code": "FAST_FINISHER",
"name": "Fast Finisher",
"description": "Complete a course within 24 hours of enrollment",
},
{
"code": "CONSISTENT_LEARNER",
"name": "Consistent Learner",
"description": "Log in on 5 different days",
},
]
for ach_data in achievements_data:
existing = db.query(Achievement).filter(Achievement.code == ach_data["code"]).first()
if not existing:
db.add(Achievement(**ach_data))
course = db.query(Course).filter(Course.title == "Demo Lomda").first()
if not course:
course = Course(title="Demo Lomda", description="Intro demo course", is_published=True)
db.add(course)
db.flush()
module1 = Module(
course_id=course.id,
order_index=1,
type=ModuleType.content,
title="Welcome",
content_text="Welcome to the demo lomda. Mark complete to continue.",
)
module2 = Module(
course_id=course.id,
order_index=2,
type=ModuleType.quiz,
title="Quick Check",
pass_score=80,
)
module3 = Module(
course_id=course.id,
order_index=3,
type=ModuleType.quiz,
title="Final Exam",
pass_score=80,
)
db.add_all([module1, module2, module3])
db.flush()
q1 = QuizQuestion(module_id=module2.id, prompt="What does LMS stand for?")
db.add(q1)
db.flush()
db.add_all(
[
QuizChoice(question_id=q1.id, text="Learning Management System", is_correct=True),
QuizChoice(question_id=q1.id, text="Local Module Server", is_correct=False),
]
)
q2 = QuizQuestion(module_id=module3.id, prompt="Final exam question: 2 + 2 = ?")
db.add(q2)
db.flush()
db.add_all(
[
QuizChoice(question_id=q2.id, text="4", is_correct=True),
QuizChoice(question_id=q2.id, text="5", is_correct=False),
]
)
db.commit()
finally:
db.close()
if __name__ == "__main__":
seed()

View File

@ -0,0 +1,68 @@
from datetime import datetime
from typing import List
from sqlalchemy.orm import Session
from app.models import Module, ModuleProgress, ProgressStatus
def ensure_module_progress(db: Session, user_id: int, course_id: int) -> List[ModuleProgress]:
modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all()
progress_list: List[ModuleProgress] = []
for module in modules:
progress = (
db.query(ModuleProgress)
.filter(ModuleProgress.user_id == user_id, ModuleProgress.module_id == module.id)
.first()
)
if not progress:
progress = ModuleProgress(user_id=user_id, module_id=module.id, status=ProgressStatus.locked)
db.add(progress)
db.flush()
progress_list.append(progress)
db.commit()
return progress_list
def recompute_progress(db: Session, user_id: int, course_id: int) -> None:
modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all()
progresses = {
p.module_id: p
for p in db.query(ModuleProgress).filter(ModuleProgress.user_id == user_id).all()
}
last_completed_index = -1
for idx, module in enumerate(modules):
progress = progresses.get(module.id)
if not progress:
progress = ModuleProgress(user_id=user_id, module_id=module.id, status=ProgressStatus.locked)
db.add(progress)
progresses[module.id] = progress
if progress.status == ProgressStatus.completed:
last_completed_index = idx
for idx, module in enumerate(modules):
progress = progresses[module.id]
if progress.status == ProgressStatus.completed:
continue
if idx == last_completed_index + 1:
progress.status = ProgressStatus.unlocked
else:
progress.status = ProgressStatus.locked
db.commit()
def complete_content_module(db: Session, user_id: int, module: Module) -> ModuleProgress:
progress = (
db.query(ModuleProgress)
.filter(ModuleProgress.user_id == user_id, ModuleProgress.module_id == module.id)
.first()
)
if not progress:
progress = ModuleProgress(user_id=user_id, module_id=module.id, status=ProgressStatus.unlocked)
db.add(progress)
progress.status = ProgressStatus.completed
progress.completed_at = datetime.utcnow()
db.commit()
return progress

View File

@ -0,0 +1,28 @@
from typing import Dict, Tuple
from sqlalchemy.orm import Session
from app.models import Module, QuizQuestion, QuizChoice
def grade_quiz(db: Session, module: Module, answers: Dict[int, int]) -> Tuple[float, int, int]:
questions = (
db.query(QuizQuestion)
.filter(QuizQuestion.module_id == module.id)
.all()
)
if not questions:
return 0.0, 0, 0
correct = 0
total = len(questions)
for q in questions:
choice_id = answers.get(q.id)
if choice_id is None:
continue
choice = db.get(QuizChoice, choice_id)
if choice and choice.question_id == q.id and choice.is_correct:
correct += 1
score = (correct / total) * 100
return score, correct, total

57
backend/migrations/env.py Normal file
View File

@ -0,0 +1,57 @@
from logging.config import fileConfig
import os
import sys
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
from app.core.config import settings
from app.db import Base
from app import models
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
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:
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()

View File

@ -0,0 +1,120 @@
"""initial
Revision ID: 0001_initial
Revises:
Create Date: 2026-02-03 18:30:00
"""
from alembic import op
import sqlalchemy as sa
revision = "0001_initial"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("role", sa.Enum("admin", "learner", name="role_enum"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_table(
"courses",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text()),
sa.Column("is_published", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"modules",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("course_id", sa.Integer(), sa.ForeignKey("courses.id", ondelete="CASCADE"), nullable=False),
sa.Column("order_index", sa.Integer(), nullable=False),
sa.Column("type", sa.Enum("content", "quiz", name="module_type_enum"), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("content_text", sa.Text()),
sa.Column("pass_score", sa.Integer()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index(op.f("ix_modules_course_id"), "modules", ["course_id"])
op.create_table(
"quiz_questions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("module_id", sa.Integer(), sa.ForeignKey("modules.id", ondelete="CASCADE"), nullable=False),
sa.Column("prompt", sa.Text(), nullable=False),
sa.Column("question_type", sa.Enum("mcq", name="question_type_enum"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index(op.f("ix_quiz_questions_module_id"), "quiz_questions", ["module_id"])
op.create_table(
"quiz_choices",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("question_id", sa.Integer(), sa.ForeignKey("quiz_questions.id", ondelete="CASCADE"), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("is_correct", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.create_index(op.f("ix_quiz_choices_question_id"), "quiz_choices", ["question_id"])
op.create_table(
"enrollments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("course_id", sa.Integer(), sa.ForeignKey("courses.id", ondelete="CASCADE"), nullable=False),
sa.Column("enrolled_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "course_id", name="uq_enrollment"),
)
op.create_index(op.f("ix_enrollments_user_id"), "enrollments", ["user_id"])
op.create_index(op.f("ix_enrollments_course_id"), "enrollments", ["course_id"])
op.create_table(
"module_progress",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("module_id", sa.Integer(), sa.ForeignKey("modules.id", ondelete="CASCADE"), nullable=False),
sa.Column("status", sa.Enum("locked", "unlocked", "completed", name="progress_status_enum"), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True)),
sa.Column("score", sa.Float()),
sa.UniqueConstraint("user_id", "module_id", name="uq_module_progress"),
)
op.create_index(op.f("ix_module_progress_user_id"), "module_progress", ["user_id"])
op.create_index(op.f("ix_module_progress_module_id"), "module_progress", ["module_id"])
op.create_table(
"quiz_attempts",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("module_id", sa.Integer(), sa.ForeignKey("modules.id", ondelete="CASCADE"), nullable=False),
sa.Column("submitted_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("score", sa.Float(), nullable=False),
sa.Column("passed", sa.Boolean(), nullable=False),
sa.Column("answers_json", sa.JSON(), nullable=False),
)
op.create_index(op.f("ix_quiz_attempts_user_id"), "quiz_attempts", ["user_id"])
op.create_index(op.f("ix_quiz_attempts_module_id"), "quiz_attempts", ["module_id"])
def downgrade() -> None:
op.drop_table("quiz_attempts")
op.drop_table("module_progress")
op.drop_table("enrollments")
op.drop_table("quiz_choices")
op.drop_table("quiz_questions")
op.drop_table("modules")
op.drop_table("courses")
op.drop_table("users")
op.execute("DROP TYPE IF EXISTS role_enum")
op.execute("DROP TYPE IF EXISTS module_type_enum")
op.execute("DROP TYPE IF EXISTS progress_status_enum")
op.execute("DROP TYPE IF EXISTS question_type_enum")

View File

@ -0,0 +1,60 @@
"""groups_and_oauth
Revision ID: 0002_groups_oauth
Revises: 0001_initial
Create Date: 2026-02-03 20:00:00
"""
from alembic import op
import sqlalchemy as sa
revision = "0002_groups_oauth"
down_revision = "0001_initial"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()))
op.add_column("users", sa.Column("oauth_provider", sa.String(length=50), nullable=True))
op.add_column("users", sa.Column("oauth_subject", sa.String(length=255), nullable=True))
op.add_column("users", sa.Column("picture_url", sa.String(length=512), nullable=True))
op.create_table(
"groups",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index(op.f("ix_groups_name"), "groups", ["name"], unique=True)
op.create_table(
"group_memberships",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("group_id", sa.Integer(), sa.ForeignKey("groups.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_membership"),
)
op.create_index(op.f("ix_group_memberships_group_id"), "group_memberships", ["group_id"])
op.create_index(op.f("ix_group_memberships_user_id"), "group_memberships", ["user_id"])
op.create_table(
"course_groups",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("course_id", sa.Integer(), sa.ForeignKey("courses.id", ondelete="CASCADE"), nullable=False),
sa.Column("group_id", sa.Integer(), sa.ForeignKey("groups.id", ondelete="CASCADE"), nullable=False),
sa.UniqueConstraint("course_id", "group_id", name="uq_course_group"),
)
op.create_index(op.f("ix_course_groups_course_id"), "course_groups", ["course_id"])
op.create_index(op.f("ix_course_groups_group_id"), "course_groups", ["group_id"])
def downgrade() -> None:
op.drop_table("course_groups")
op.drop_table("group_memberships")
op.drop_table("groups")
op.drop_column("users", "picture_url")
op.drop_column("users", "oauth_subject")
op.drop_column("users", "oauth_provider")
op.drop_column("users", "is_active")

View File

@ -0,0 +1,75 @@
"""add_uploads_and_achievements
Revision ID: 0003_uploads_achievements
Revises: 0002_groups_oauth
Create Date: 2026-02-03 21:00:00
"""
from alembic import op
import sqlalchemy as sa
revision = "0003_uploads_achievements"
down_revision = "0002_groups_oauth"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create uploads table
op.create_table(
"uploads",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("filename", sa.String(length=255), nullable=False),
sa.Column("content_type", sa.String(length=100), nullable=False),
sa.Column("file_path", sa.String(length=512), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("uploaded_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index(op.f("ix_uploads_uploaded_by"), "uploads", ["uploaded_by"])
# Add upload_id to modules table
op.add_column("modules", sa.Column("upload_id", sa.Integer(), sa.ForeignKey("uploads.id", ondelete="SET NULL"), nullable=True))
op.create_index(op.f("ix_modules_upload_id"), "modules", ["upload_id"])
# Create achievements table
op.create_table(
"achievements",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("code", sa.String(length=100), nullable=False, unique=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index(op.f("ix_achievements_code"), "achievements", ["code"], unique=True)
# Create user_achievements table
op.create_table(
"user_achievements",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("achievement_id", sa.Integer(), sa.ForeignKey("achievements.id", ondelete="CASCADE"), nullable=False),
sa.Column("earned_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "achievement_id", name="uq_user_achievement"),
)
op.create_index(op.f("ix_user_achievements_user_id"), "user_achievements", ["user_id"])
op.create_index(op.f("ix_user_achievements_achievement_id"), "user_achievements", ["achievement_id"])
# Create login_events table for tracking logins
op.create_table(
"login_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("logged_in_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index(op.f("ix_login_events_user_id"), "login_events", ["user_id"])
op.create_index(op.f("ix_login_events_logged_in_at"), "login_events", ["logged_in_at"])
def downgrade() -> None:
op.drop_table("login_events")
op.drop_table("user_achievements")
op.drop_table("achievements")
op.drop_index(op.f("ix_modules_upload_id"), "modules")
op.drop_column("modules", "upload_id")
op.drop_table("uploads")

13
backend/requirements.txt Normal file
View File

@ -0,0 +1,13 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
SQLAlchemy==2.0.32
alembic==1.13.2
psycopg2-binary==2.9.9
pydantic==2.8.2
pydantic-settings==2.4.0
email-validator==2.2.0
requests==2.32.3
python-jose==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==3.2.0
python-multipart==0.0.9

55
docker-compose.yml Normal file
View File

@ -0,0 +1,55 @@
services:
db:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: lomda
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
backend:
build: ./backend
environment:
DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/lomda
JWT_SECRET_KEY: change_me_access
JWT_REFRESH_SECRET_KEY: change_me_refresh
CORS_ORIGINS: http://localhost:5173
ADMIN_SEED_EMAIL: admin@example.com
ADMIN_SEED_PASSWORD: admin123
FRONTEND_URL: http://localhost:5173
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GOOGLE_REDIRECT_URI: http://localhost:8000/auth/google/callback
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ${SMTP_FROM}
UPLOAD_DIR: /data/uploads
volumes:
- ./backend:/app
- upload_data:/data/uploads
ports:
- "8000:8000"
depends_on:
- db
frontend:
build: ./frontend
environment:
VITE_API_URL: http://localhost:8000
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "5173:5173"
depends_on:
- backend
command: sh -c "npm install && npm run dev"
volumes:
postgres_data:
upload_data:

12
frontend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* /app/
RUN npm install
COPY . /app
EXPOSE 5173
CMD ["npm", "run", "dev"]

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lomda Hub</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2351
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "lomda-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.7",
"@mui/material": "^5.16.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.2",
"vite": "^5.4.2"
}
}

89
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,89 @@
import { Route, Routes, Link as RouterLink, useNavigate, useLocation } from "react-router-dom";
import { AppBar, Box, Button, Container, Toolbar, Typography, IconButton } from "@mui/material";
import { Brightness4, Brightness7 } from "@mui/icons-material";
import { useEffect, useState } from "react";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
import CoursesPage from "./pages/CoursesPage";
import CoursePlayerPage from "./pages/CoursePlayerPage";
import AdminDashboard from "./pages/AdminDashboard";
import AdminCourseEditor from "./pages/AdminCourseEditor";
import AdminModulesPage from "./pages/AdminModulesPage";
import AdminModuleEdit from "./pages/AdminModuleEdit";
import AdminUsersPage from "./pages/AdminUsersPage";
import AdminGroupsPage from "./pages/AdminGroupsPage";
import AdminStatsPage from "./pages/AdminStatsPage";
import AchievementsPage from "./pages/AchievementsPage";
import GoogleCallbackPage from "./pages/GoogleCallbackPage";
import { clearTokens, getUserRole, getMe, getAccessToken } from "./api/client";
import { useTheme } from "./ThemeProvider";
export default function App() {
const navigate = useNavigate();
const location = useLocation();
const role = getUserRole();
const { mode, toggleTheme } = useTheme();
const [userEmail, setUserEmail] = useState<string | null>(null);
useEffect(() => {
if (getAccessToken()) {
getMe().then(user => setUserEmail(user.email)).catch(() => setUserEmail(null));
}
}, [location]);
return (
<Box sx={{ minHeight: "100vh" }}>
<AppBar position="sticky" color="default" elevation={0} sx={{ borderBottom: 1, borderColor: "divider" }}>
<Toolbar sx={{ display: "flex", justifyContent: "space-between" }}>
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
<Typography variant="h6" component={RouterLink} to="/courses" sx={{ textDecoration: "none", color: "inherit" }}>
Lomda Hub
</Typography>
<Button component={RouterLink} to="/courses" color="inherit">Courses</Button>
{role === "learner" && <Button component={RouterLink} to="/achievements" color="inherit">Achievements</Button>}
{role === "admin" && (
<>
<Button component={RouterLink} to="/admin" color="inherit">Admin</Button>
<Button component={RouterLink} to="/admin/stats" color="inherit">Analytics</Button>
</>
)}
</Box>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
{userEmail && (
<Typography variant="body2" sx={{ mr: 2, color: "text.secondary" }}>
Hello: {userEmail}
</Typography>
)}
<IconButton onClick={toggleTheme} color="inherit">
{mode === "dark" ? <Brightness7 /> : <Brightness4 />}
</IconButton>
<Button component={RouterLink} to="/login" color="inherit">Login</Button>
<Button component={RouterLink} to="/register" color="inherit">Register</Button>
<Button variant="outlined" color="inherit" onClick={() => { clearTokens(); navigate("/login"); }}>
Logout
</Button>
</Box>
</Toolbar>
</AppBar>
<Container sx={{ py: 4 }}>
<Routes>
<Route path="/" element={<CoursesPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/auth/callback" element={<GoogleCallbackPage />} />
<Route path="/courses" element={<CoursesPage />} />
<Route path="/courses/:id" element={<CoursePlayerPage />} />
<Route path="/achievements" element={<AchievementsPage />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/groups" element={<AdminGroupsPage />} />
<Route path="/admin/stats" element={<AdminStatsPage />} />
<Route path="/admin/courses/new" element={<AdminCourseEditor />} />
<Route path="/admin/courses/:id/edit" element={<AdminCourseEditor />} />
<Route path="/admin/courses/:id/modules" element={<AdminModulesPage />} />
<Route path="/admin/modules/:id/edit" element={<AdminModuleEdit />} />
</Routes>
</Container>
</Box>
);
}

View File

@ -0,0 +1,57 @@
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { createTheme, ThemeProvider as MuiThemeProvider, CssBaseline } from "@mui/material";
type ThemeMode = "light" | "dark";
interface ThemeContextType {
mode: ThemeMode;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType>({
mode: "light",
toggleTheme: () => {},
});
export const useTheme = () => useContext(ThemeContext);
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [mode, setMode] = useState<ThemeMode>(() => {
const saved = localStorage.getItem("lomda_theme");
return (saved as ThemeMode) || "light";
});
const toggleTheme = () => {
setMode((prev: ThemeMode) => {
const newMode = prev === "light" ? "dark" : "light";
localStorage.setItem("lomda_theme", newMode);
return newMode;
});
};
const theme = createTheme({
palette: {
mode,
primary: {
main: mode === "light" ? "#1976d2" : "#90caf9",
},
background: {
default: mode === "light" ? "#f5f5f5" : "#121212",
paper: mode === "light" ? "#ffffff" : "#1e1e1e",
},
},
});
useEffect(() => {
document.body.style.backgroundColor = theme.palette.background.default;
}, [theme]);
return (
<ThemeContext.Provider value={{ mode, toggleTheme }}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</ThemeContext.Provider>
);
};

290
frontend/src/api/client.ts Normal file
View File

@ -0,0 +1,290 @@
import { CourseOut, CourseWithModules, ProgressOut, TokenPair, UserOut } from "./types";
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
const ACCESS_KEY = "lomda_access";
const REFRESH_KEY = "lomda_refresh";
const ROLE_KEY = "lomda_role";
export function setTokens(tokens: TokenPair) {
localStorage.setItem(ACCESS_KEY, tokens.access_token);
localStorage.setItem(REFRESH_KEY, tokens.refresh_token);
}
export function clearTokens() {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(ROLE_KEY);
}
export function getAccessToken() {
return localStorage.getItem(ACCESS_KEY);
}
export function getRefreshToken() {
return localStorage.getItem(REFRESH_KEY);
}
export function setUserRole(role: string) {
localStorage.setItem(ROLE_KEY, role);
}
export function getUserRole() {
return localStorage.getItem(ROLE_KEY);
}
async function request<T>(path: string, options: RequestInit = {}, retry = true): Promise<T> {
const token = getAccessToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
if (res.status === 401 && retry && getRefreshToken()) {
const refreshed = await refreshToken();
if (refreshed) {
return request<T>(path, options, false);
}
}
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.detail || "Request failed");
}
return res.json();
}
export async function login(email: string, password: string): Promise<TokenPair> {
const body = new URLSearchParams();
body.set("username", email);
body.set("password", password);
const res = await fetch(`${API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.detail || "Login failed");
}
const data = (await res.json()) as TokenPair;
setTokens(data);
const me = await getMe();
setUserRole(me.role);
return data;
}
export async function register(email: string, password: string) {
return request<UserOut>("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password }),
});
}
export async function getMe() {
return request<UserOut>("/auth/me");
}
export async function refreshToken() {
const refresh = getRefreshToken();
if (!refresh) return false;
const res = await fetch(`${API_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
});
if (!res.ok) return false;
const data = (await res.json()) as TokenPair;
setTokens(data);
return true;
}
export async function listCourses() {
return request<CourseOut[]>("/courses");
}
export async function enroll(courseId: number) {
return request(`/courses/${courseId}/enroll`, { method: "POST" });
}
export async function getCourse(courseId: number) {
return request<CourseWithModules>(`/courses/${courseId}`);
}
export async function completeContent(moduleId: number) {
return request(`/modules/${moduleId}/complete`, { method: "POST" });
}
export async function submitQuiz(moduleId: number, answers: Record<number, number>) {
return request(`/modules/${moduleId}/quiz/submit`, {
method: "POST",
body: JSON.stringify({ answers }),
});
}
export async function getProgress() {
return request<ProgressOut>("/progress");
}
// Admin
export async function adminListCourses() {
return request<any[]>("/admin/courses");
}
export async function adminCreateCourse(payload: Partial<CourseOut>) {
return request<CourseOut>("/admin/courses", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function adminUpdateCourse(id: number, payload: Partial<CourseOut>) {
return request<CourseOut>(`/admin/courses/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
export async function adminTogglePublish(id: number) {
return request<CourseOut>(`/admin/courses/${id}/publish`, { method: "PATCH" });
}
export async function adminDeleteCourse(id: number) {
return request(`/admin/courses/${id}`, { method: "DELETE" });
}
export async function adminListModules(courseId: number) {
return request(`/admin/courses/${courseId}/modules`);
}
export async function adminCreateModule(courseId: number, payload: any) {
return request(`/admin/courses/${courseId}/modules`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function adminUpdateModule(moduleId: number, payload: any) {
return request(`/admin/modules/${moduleId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
export async function adminGetModule(moduleId: number) {
return request(`/admin/modules/${moduleId}`);
}
export async function adminDeleteModule(moduleId: number) {
return request(`/admin/modules/${moduleId}`, { method: "DELETE" });
}
export async function adminListQuestions(moduleId: number) {
return request(`/admin/modules/${moduleId}/questions`);
}
export async function adminCreateQuestion(moduleId: number, payload: any) {
return request(`/admin/modules/${moduleId}/questions`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function adminUpdateQuestion(questionId: number, payload: any) {
return request(`/admin/questions/${questionId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
export async function adminDeleteQuestion(questionId: number) {
return request(`/admin/questions/${questionId}`, { method: "DELETE" });
}
export async function adminListUsers(query?: string) {
const suffix = query ? `?q=${encodeURIComponent(query)}` : "";
return request(`/admin/users${suffix}`);
}
export async function adminUpdateUser(userId: number, payload: any) {
return request(`/admin/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function adminListGroups() {
return request(`/admin/groups`);
}
export async function adminCreateGroup(payload: any) {
return request(`/admin/groups`, { method: "POST", body: JSON.stringify(payload) });
}
export async function adminUpdateGroup(groupId: number, payload: any) {
return request(`/admin/groups/${groupId}`, { method: "PUT", body: JSON.stringify(payload) });
}
export async function adminDeleteGroup(groupId: number) {
return request(`/admin/groups/${groupId}`, { method: "DELETE" });
}
export async function adminListGroupMembers(groupId: number) {
return request(`/admin/groups/${groupId}/members`);
}
export async function adminAddGroupMember(groupId: number, payload: any) {
return request(`/admin/groups/${groupId}/members`, { method: "POST", body: JSON.stringify(payload) });
}
export async function adminRemoveGroupMember(groupId: number, userId: number) {
return request(`/admin/groups/${groupId}/members/${userId}`, { method: "DELETE" });
}
// Uploads
export async function adminUploadFile(file: File) {
const token = getAccessToken();
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${API_URL}/admin/uploads`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!res.ok) {
throw new Error("Upload failed");
}
return res.json();
}
export function getUploadUrl(uploadId: number) {
return `${API_URL}/uploads/${uploadId}`;
}
// Achievements
export async function getMyAchievements() {
return request("/me/achievements");
}
// Admin stats
export async function adminStatsUsers() {
return request("/admin/stats/users");
}
export async function adminStatsUser(userId: number) {
return request(`/admin/stats/users/${userId}`);
}
export async function adminStatsCourses() {
return request("/admin/stats/courses");
}
export async function adminStatsCourse(courseId: number) {
return request(`/admin/stats/courses/${courseId}`);
}

69
frontend/src/api/types.ts Normal file
View File

@ -0,0 +1,69 @@
export type TokenPair = {
access_token: string;
refresh_token: string;
token_type: string;
};
export type UserOut = {
id: number;
email: string;
role: "admin" | "learner";
};
export type CourseOut = {
id: number;
title: string;
description?: string | null;
is_published: boolean;
created_at: string;
updated_at: string;
};
export type QuizChoice = {
id: number;
text: string;
is_correct?: boolean;
};
export type QuizQuestion = {
id: number;
prompt: string;
question_type: "mcq";
choices: QuizChoice[];
};
export type ModuleWithProgress = {
id: number;
order_index: number;
type: "content" | "quiz";
title: string;
content_text?: string | null;
pass_score?: number | null;
upload_id?: number | null;
status: "locked" | "unlocked" | "completed";
completed_at?: string | null;
score?: number | null;
questions: QuizQuestion[];
};
export type CourseWithModules = {
id: number;
title: string;
description?: string | null;
is_published: boolean;
modules: ModuleWithProgress[];
};
export type ProgressCourse = {
course_id: number;
title: string;
percent: number;
status: string;
final_exam_score?: number | null;
final_exam_passed?: boolean | null;
completed_at?: string | null;
};
export type ProgressOut = {
courses: ProgressCourse[];
};

17
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,17 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { ThemeProvider } from "./ThemeProvider";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { Box, Card, CardContent, Typography, Grid, Chip } from "@mui/material";
import { EmojiEvents, Lock } from "@mui/icons-material";
import { getMyAchievements } from "../api/client";
interface Achievement {
id: number;
code: string;
name: string;
description: string;
earned: boolean;
earned_at?: string;
}
export default function AchievementsPage() {
const [achievements, setAchievements] = useState<Achievement[]>([]);
useEffect(() => {
getMyAchievements().then((data) => setAchievements(data as Achievement[]));
}, []);
const earned = achievements.filter((a: Achievement) => a.earned);
const locked = achievements.filter((a: Achievement) => !a.earned);
return (
<Box>
<Typography variant="h4" gutterBottom>
Achievements
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{earned.length} / {achievements.length} unlocked
</Typography>
<Typography variant="h6" sx={{ mt: 3, mb: 2 }}>
Earned
</Typography>
<Grid container spacing={2}>
{earned.map((ach: Achievement) => (
<Grid item xs={12} sm={6} md={4} key={ach.id}>
<Card sx={{ bgcolor: "success.light" }}>
<CardContent>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
<EmojiEvents color="primary" />
<Typography variant="h6">{ach.name}</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{ach.description}
</Typography>
{ach.earned_at && (
<Chip
label={`Earned: ${new Date(ach.earned_at).toLocaleDateString()}`}
size="small"
sx={{ mt: 1 }}
/>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
<Typography variant="h6" sx={{ mt: 4, mb: 2 }}>
Locked
</Typography>
<Grid container spacing={2}>
{locked.map((ach: Achievement) => (
<Grid item xs={12} sm={6} md={4} key={ach.id}>
<Card sx={{ bgcolor: "action.hover", opacity: 0.6 }}>
<CardContent>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
<Lock />
<Typography variant="h6">{ach.name}</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{ach.description}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
);
}

View File

@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Button,
Card,
CardContent,
Chip,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import { adminCreateCourse, adminUpdateCourse, adminListCourses, adminListGroups } from "../api/client";
export default function AdminCourseEditor() {
const { id } = useParams();
const isEdit = Boolean(id);
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isPublished, setIsPublished] = useState(false);
const [groups, setGroups] = useState<any[]>([]);
const [groupIds, setGroupIds] = useState<number[]>([]);
useEffect(() => {
adminListGroups().then(setGroups);
}, []);
useEffect(() => {
if (!isEdit) return;
adminListCourses().then((courses: any[]) => {
const course = courses.find((c) => c.id === Number(id));
if (course) {
setTitle(course.title);
setDescription(course.description || "");
setIsPublished(course.is_published);
setGroupIds(course.group_ids || []);
}
});
}, [id, isEdit]);
return (
<Card sx={{ maxWidth: 760, margin: "0 auto" }}>
<CardContent>
<Typography variant="h5" sx={{ mb: 2 }}>{isEdit ? "Edit course" : "New course"}</Typography>
<Stack spacing={2}>
<TextField label="Title" value={title} onChange={(e) => setTitle(e.target.value)} />
<TextField label="Description" multiline minRows={3} value={description} onChange={(e) => setDescription(e.target.value)} />
<FormControl>
<InputLabel>Visibility</InputLabel>
<Select value={isPublished ? "published" : "draft"} label="Visibility" onChange={(e) => setIsPublished(e.target.value === "published")}>
<MenuItem value="draft">Draft</MenuItem>
<MenuItem value="published">Published</MenuItem>
</Select>
</FormControl>
<FormControl>
<InputLabel>Allowed groups (optional)</InputLabel>
<Select
multiple
value={groupIds}
label="Allowed groups (optional)"
renderValue={(selected) => (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
{selected.map((value) => {
const g = groups.find((gr) => gr.id === value);
return <Chip key={value} label={g ? g.name : value} />;
})}
</Box>
)}
onChange={(e) => setGroupIds(e.target.value as number[])}
>
{groups.map((g) => (
<MenuItem key={g.id} value={g.id}>{g.name}</MenuItem>
))}
</Select>
</FormControl>
<Button variant="contained" onClick={async () => {
if (isEdit) {
await adminUpdateCourse(Number(id), { title, description, is_published: isPublished, group_ids: groupIds } as any);
} else {
await adminCreateCourse({ title, description, is_published: isPublished, group_ids: groupIds } as any);
}
navigate("/admin");
}}>Save</Button>
</Stack>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { Box, Button, Card, CardContent, Stack, Typography } from "@mui/material";
import { adminListCourses, adminTogglePublish, adminDeleteCourse } from "../api/client";
export default function AdminDashboard() {
const [courses, setCourses] = useState<any[]>([]);
const load = async () => {
const data = await adminListCourses();
setCourses(data);
};
useEffect(() => {
load();
}, []);
return (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}>
<Typography variant="h4">Admin Dashboard</Typography>
<Stack direction="row" spacing={1}>
<Button component={RouterLink} to="/admin/users" variant="outlined">Users</Button>
<Button component={RouterLink} to="/admin/groups" variant="outlined">Groups</Button>
<Button component={RouterLink} to="/admin/courses/new" variant="contained">New course</Button>
</Stack>
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 2 }}>
{courses.map((course) => (
<Card key={course.id}>
<CardContent>
<Typography variant="h6">{course.title}</Typography>
<Typography color="text.secondary" sx={{ mb: 2 }}>{course.description}</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 1 }}>
<Button component={RouterLink} to={`/admin/courses/${course.id}/edit`} variant="outlined">Edit</Button>
<Button component={RouterLink} to={`/admin/courses/${course.id}/modules`} variant="outlined">Modules</Button>
</Stack>
<Stack direction="row" spacing={1}>
<Button variant="contained" onClick={async () => {
await adminTogglePublish(course.id);
await load();
}}>{course.is_published ? "Unpublish" : "Publish"}</Button>
<Button color="error" variant="outlined" onClick={async () => {
await adminDeleteCourse(course.id);
await load();
}}>Delete</Button>
</Stack>
</CardContent>
</Card>
))}
</Box>
</Box>
);
}

View File

@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import { Box, Button, Card, CardContent, Divider, Stack, TextField, Typography } from "@mui/material";
import { adminAddGroupMember, adminCreateGroup, adminDeleteGroup, adminListGroupMembers, adminListGroups, adminRemoveGroupMember } from "../api/client";
export default function AdminGroupsPage() {
const [groups, setGroups] = useState<any[]>([]);
const [name, setName] = useState("");
const [selectedGroup, setSelectedGroup] = useState<any>(null);
const [members, setMembers] = useState<any[]>([]);
const [memberEmail, setMemberEmail] = useState("");
const loadGroups = async () => {
const data = await adminListGroups();
setGroups(data);
if (data.length && !selectedGroup) {
setSelectedGroup(data[0]);
}
};
const loadMembers = async (groupId: number) => {
const data = await adminListGroupMembers(groupId);
setMembers(data);
};
useEffect(() => {
loadGroups();
}, []);
useEffect(() => {
if (selectedGroup) {
loadMembers(selectedGroup.id);
}
}, [selectedGroup]);
return (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h4">Groups</Typography>
<Stack direction="row" spacing={1}>
<TextField size="small" label="New group name" value={name} onChange={(e) => setName(e.target.value)} />
<Button variant="contained" onClick={async () => {
await adminCreateGroup({ name });
setName("");
await loadGroups();
}}>Create</Button>
</Stack>
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 2 }}>
{groups.map((group) => (
<Card key={group.id} variant={selectedGroup?.id === group.id ? "outlined" : undefined} onClick={() => setSelectedGroup(group)}>
<CardContent>
<Typography variant="h6">{group.name}</Typography>
<Button color="error" size="small" onClick={async (e) => {
e.stopPropagation();
await adminDeleteGroup(group.id);
setSelectedGroup(null);
await loadGroups();
}}>Delete</Button>
</CardContent>
</Card>
))}
</Box>
{selectedGroup && (
<Card sx={{ mt: 3 }}>
<CardContent>
<Typography variant="h6">Members: {selectedGroup.name}</Typography>
<Divider sx={{ my: 2 }} />
<Stack direction={{ xs: "column", md: "row" }} spacing={1} sx={{ mb: 2 }}>
<TextField label="Add member by email" value={memberEmail} onChange={(e) => setMemberEmail(e.target.value)} />
<Button variant="contained" onClick={async () => {
await adminAddGroupMember(selectedGroup.id, { email: memberEmail });
setMemberEmail("");
await loadMembers(selectedGroup.id);
}}>Add</Button>
</Stack>
<Stack spacing={1}>
{members.map((member) => (
<Box key={member.id} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography>{member.email}</Typography>
<Button color="error" size="small" onClick={async () => {
await adminRemoveGroupMember(selectedGroup.id, member.id);
await loadMembers(selectedGroup.id);
}}>Remove</Button>
</Box>
))}
</Stack>
</CardContent>
</Card>
)}
</Box>
);
}

View File

@ -0,0 +1,134 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Button,
Card,
CardContent,
Divider,
Stack,
TextField,
Typography,
Alert,
} from "@mui/material";
import { adminGetModule, adminListQuestions, adminCreateQuestion, adminDeleteQuestion, adminUpdateModule, adminUploadFile, getUploadUrl } from "../api/client";
export default function AdminModuleEdit() {
const { id } = useParams();
const moduleId = Number(id);
const navigate = useNavigate();
const [module, setModule] = useState<any>(null);
const [content, setContent] = useState("");
const [passScore, setPassScore] = useState(80);
const [title, setTitle] = useState("");
const [orderIndex, setOrderIndex] = useState(1);
const [uploadId, setUploadId] = useState<number | null>(null);
const [questions, setQuestions] = useState<any[]>([]);
const [uploadNotice, setUploadNotice] = useState("");
useEffect(() => {
adminGetModule(moduleId).then((data) => {
setModule(data);
setContent(data.content_text || "");
setPassScore(data.pass_score || 80);
setTitle(data.title || "");
setOrderIndex(data.order_index || 1);
setUploadId(data.upload_id || null);
});
}, [moduleId]);
useEffect(() => {
adminListQuestions(moduleId).then(setQuestions);
}, [moduleId]);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const result = await adminUploadFile(file);
setUploadId(result.id);
setUploadNotice(`Uploaded: ${result.filename}`);
} catch (err: any) {
setUploadNotice(`Upload failed: ${err.message}`);
}
};
if (!module) {
return <Typography color="warning.main">Loading module...</Typography>;
}
return (
<Card sx={{ maxWidth: 860, margin: "0 auto" }}>
<CardContent>
<Typography variant="h5" sx={{ mb: 2 }}>Edit Module</Typography>
<Stack spacing={2}>
<TextField label="Title" value={title} onChange={(e) => setTitle(e.target.value)} />
<TextField label="Order" type="number" value={orderIndex} onChange={(e) => setOrderIndex(Number(e.target.value))} />
{module.type === "content" ? (
<>
<TextField label="Content" multiline minRows={4} value={content} onChange={(e) => setContent(e.target.value)} />
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Presentation (PDF/PPTX)</Typography>
<input type="file" accept=".pdf,.ppt,.pptx" onChange={handleFileUpload} />
{uploadNotice && <Alert severity="info" sx={{ mt: 1 }}>{uploadNotice}</Alert>}
{uploadId && (
<Alert severity="success" sx={{ mt: 1 }}>
Attached presentation (ID: {uploadId}) -{" "}
<a href={getUploadUrl(uploadId)} target="_blank" rel="noreferrer">View</a>
</Alert>
)}
</Box>
</>
) : (
<TextField label="Pass score" type="number" value={passScore} onChange={(e) => setPassScore(Number(e.target.value))} />
)}
{module.type === "quiz" && (
<Box>
<Divider sx={{ my: 2 }} />
<Typography variant="h6">Questions</Typography>
<Stack spacing={2} sx={{ mt: 2 }}>
{questions.map((q) => (
<Card key={q.id} variant="outlined">
<CardContent>
<Typography variant="subtitle1">{q.prompt}</Typography>
<Stack sx={{ mt: 1 }} spacing={0.5}>
{q.choices.map((c: any) => (
<Typography key={c.id} variant="body2">
{c.text} {c.is_correct ? "(correct)" : ""}
</Typography>
))}
</Stack>
<Button color="error" sx={{ mt: 1 }} onClick={async () => {
await adminDeleteQuestion(q.id);
const data = await adminListQuestions(moduleId);
setQuestions(data);
}}>Delete question</Button>
</CardContent>
</Card>
))}
<Button variant="outlined" onClick={async () => {
await adminCreateQuestion(moduleId, {
prompt: "New question",
question_type: "mcq",
choices: [
{ text: "Option A", is_correct: true },
{ text: "Option B", is_correct: false }
]
});
const data = await adminListQuestions(moduleId);
setQuestions(data);
}}>Add sample question</Button>
</Stack>
</Box>
)}
<Button variant="contained" onClick={async () => {
await adminUpdateModule(moduleId, { title, order_index: orderIndex, content_text: content, pass_score: passScore, upload_id: uploadId });
navigate("/admin");
}}>Save</Button>
</Stack>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { Link as RouterLink, useParams } from "react-router-dom";
import { Box, Button, Card, CardContent, MenuItem, Select, Stack, TextField, Typography } from "@mui/material";
import { adminCreateModule, adminDeleteModule, adminListModules } from "../api/client";
export default function AdminModulesPage() {
const { id } = useParams();
const courseId = Number(id);
const [modules, setModules] = useState<any[]>([]);
const [type, setType] = useState<"content" | "quiz">("content");
const [title, setTitle] = useState("");
const [orderIndex, setOrderIndex] = useState(1);
const load = async () => {
const data = await adminListModules(courseId);
setModules(data);
setOrderIndex(data.length + 1);
};
useEffect(() => {
load();
}, [courseId]);
return (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h4">Modules</Typography>
<Button component={RouterLink} to="/admin" variant="outlined">Back</Button>
</Stack>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2 }}>Add module</Typography>
<Stack spacing={2}>
<TextField label="Title" value={title} onChange={(e) => setTitle(e.target.value)} />
<Select value={type} onChange={(e) => setType(e.target.value as any)}>
<MenuItem value="content">Content</MenuItem>
<MenuItem value="quiz">Quiz</MenuItem>
</Select>
<TextField label="Order" type="number" value={orderIndex} onChange={(e) => setOrderIndex(Number(e.target.value))} />
<Button variant="contained" onClick={async () => {
await adminCreateModule(courseId, { title, order_index: orderIndex, type, content_text: "", pass_score: 80 });
setTitle("");
await load();
}}>Add module</Button>
</Stack>
</CardContent>
</Card>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 2 }}>
{modules.map((module) => (
<Card key={module.id}>
<CardContent>
<Typography variant="subtitle1">{module.order_index}. {module.title}</Typography>
<Typography color="text.secondary" sx={{ mb: 2 }}>{module.type}</Typography>
<Stack direction="row" spacing={1}>
<Button component={RouterLink} to={`/admin/modules/${module.id}/edit`} variant="outlined">Edit</Button>
<Button color="error" variant="outlined" onClick={async () => {
await adminDeleteModule(module.id);
await load();
}}>Delete</Button>
</Stack>
</CardContent>
</Card>
))}
</Box>
</Box>
);
}

View File

@ -0,0 +1,207 @@
import { useEffect, useState } from "react";
import {
Box,
Typography,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Paper,
Button,
Dialog,
DialogTitle,
DialogContent,
List,
ListItem,
ListItemText,
} from "@mui/material";
import { adminStatsUsers, adminStatsUser, adminStatsCourses, adminStatsCourse } from "../api/client";
interface UserStat {
id: number;
email: string;
enrollments_count: number;
completed_modules_count: number;
attempts_count: number;
}
interface CourseStat {
id: number;
title: string;
enrollments_count: number;
completions_count: number;
final_exam_avg_score?: number;
}
interface Attempt {
module_title: string;
score: number;
passed: boolean;
submitted_at: string;
}
interface CourseDetail {
course_title: string;
progress_percent: number;
completed_modules: number;
total_modules: number;
attempts?: Attempt[];
}
interface UserDetail {
courses: CourseDetail[];
}
interface ModuleAttempt {
module_title: string;
attempts_count: number;
}
interface CourseDetail2 {
attempts_distribution: ModuleAttempt[];
}
export default function AdminStatsPage() {
const [tab, setTab] = useState(0);
const [usersStats, setUsersStats] = useState<UserStat[]>([]);
const [coursesStats, setCoursesStats] = useState<CourseStat[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detailData, setDetailData] = useState<UserDetail | CourseDetail2 | null>(null);
useEffect(() => {
adminStatsUsers().then((data) => setUsersStats(data as UserStat[]));
adminStatsCourses().then((data) => setCoursesStats(data as CourseStat[]));
}, []);
const viewUserDetail = async (userId: number) => {
const data = await adminStatsUser(userId);
setDetailData(data as UserDetail);
setDetailOpen(true);
};
const viewCourseDetail = async (courseId: number) => {
const data = await adminStatsCourse(courseId);
setDetailData(data as CourseDetail2);
setDetailOpen(true);
};
return (
<Box>
<Typography variant="h4" gutterBottom>
Analytics
</Typography>
<Tabs value={tab} onChange={(_e: React.SyntheticEvent, v: number) => setTab(v)} sx={{ mb: 3 }}>
<Tab label="Users" />
<Tab label="Courses" />
</Tabs>
{tab === 0 && (
<Paper>
<Table>
<TableHead>
<TableRow>
<TableCell>Email</TableCell>
<TableCell>Enrollments</TableCell>
<TableCell>Completed Modules</TableCell>
<TableCell>Attempts</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{usersStats.map((user: UserStat) => (
<TableRow key={user.id}>
<TableCell>{user.email}</TableCell>
<TableCell>{user.enrollments_count}</TableCell>
<TableCell>{user.completed_modules_count}</TableCell>
<TableCell>{user.attempts_count}</TableCell>
<TableCell>
<Button onClick={() => viewUserDetail(user.id)}>View Details</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
)}
{tab === 1 && (
<Paper>
<Table>
<TableHead>
<TableRow>
<TableCell>Title</TableCell>
<TableCell>Enrollments</TableCell>
<TableCell>Completions</TableCell>
<TableCell>Final Avg Score</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{coursesStats.map((course: CourseStat) => (
<TableRow key={course.id}>
<TableCell>{course.title}</TableCell>
<TableCell>{course.enrollments_count}</TableCell>
<TableCell>{course.completions_count}</TableCell>
<TableCell>{course.final_exam_avg_score?.toFixed(1) || "N/A"}</TableCell>
<TableCell>
<Button onClick={() => viewCourseDetail(course.id)}>View Details</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
)}
<Dialog open={detailOpen} onClose={() => setDetailOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Details</DialogTitle>
<DialogContent>
{detailData && 'courses' in detailData && (
<List>
{detailData.courses.map((course: CourseDetail, idx: number) => (
<ListItem key={idx}>
<ListItemText
primary={course.course_title}
secondary={
<>
Progress: {course.progress_percent.toFixed(1)}% | Modules: {course.completed_modules}/
{course.total_modules}
{course.attempts && course.attempts.length > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="body2">Attempts:</Typography>
{course.attempts.map((att: Attempt, i: number) => (
<Typography key={i} variant="caption" display="block">
{att.module_title}: {att.score} ({att.passed ? "passed" : "failed"}) -{" "}
{new Date(att.submitted_at).toLocaleString()}
</Typography>
))}
</Box>
)}
</>
}
/>
</ListItem>
))}
</List>
)}
{detailData && 'attempts_distribution' in detailData && (
<List>
{detailData.attempts_distribution.map((mod: ModuleAttempt, idx: number) => (
<ListItem key={idx}>
<ListItemText
primary={mod.module_title}
secondary={`Attempts: ${mod.attempts_count}`}
/>
</ListItem>
))}
</List>
)}
</DialogContent>
</Dialog>
</Box>
);
}

View File

@ -0,0 +1,60 @@
import { useEffect, useMemo, useState } from "react";
import { Box, Button, Card, CardContent, Stack, TextField, Typography } from "@mui/material";
import { adminListUsers, adminUpdateUser } from "../api/client";
export default function AdminUsersPage() {
const [users, setUsers] = useState<any[]>([]);
const [query, setQuery] = useState("");
const load = async (q?: string) => {
const data = await adminListUsers(q);
setUsers(data);
};
useEffect(() => {
load();
}, []);
const filtered = useMemo(() => users, [users]);
return (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h4">Users</Typography>
<Stack direction="row" spacing={1}>
<TextField size="small" label="Search by email" value={query} onChange={(e) => setQuery(e.target.value)} />
<Button variant="outlined" onClick={() => load(query)}>Search</Button>
</Stack>
</Stack>
<Stack spacing={2}>
{filtered.map((user) => (
<Card key={user.id}>
<CardContent>
<Stack direction={{ xs: "column", md: "row" }} justifyContent="space-between" alignItems={{ xs: "flex-start", md: "center" }} spacing={2}>
<Box>
<Typography variant="h6">{user.email}</Typography>
<Typography variant="body2" color="text.secondary">Role: {user.role}</Typography>
<Typography variant="body2" color="text.secondary">Active: {user.is_active ? "Yes" : "No"}</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Button variant="outlined" onClick={async () => {
await adminUpdateUser(user.id, { role: user.role === "admin" ? "learner" : "admin" });
await load(query);
}}>
Make {user.role === "admin" ? "Learner" : "Admin"}
</Button>
<Button variant="outlined" color={user.is_active ? "error" : "primary"} onClick={async () => {
await adminUpdateUser(user.id, { is_active: !user.is_active });
await load(query);
}}>
{user.is_active ? "Deactivate" : "Activate"}
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
))}
</Stack>
</Box>
);
}

View File

@ -0,0 +1,208 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import {
Box,
Card,
CardContent,
Chip,
Divider,
LinearProgress,
Stack,
Typography,
Button,
} from "@mui/material";
import LockIcon from "@mui/icons-material/Lock";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { completeContent, getCourse, submitQuiz, getUploadUrl } from "../api/client";
import { CourseWithModules, ModuleWithProgress } from "../api/types";
export default function CoursePlayerPage() {
const { id } = useParams();
const courseId = Number(id);
const [course, setCourse] = useState<CourseWithModules | null>(null);
const [selected, setSelected] = useState<ModuleWithProgress | null>(null);
const [notice, setNotice] = useState<string | null>(null);
useEffect(() => {
getCourse(courseId)
.then((data) => {
setCourse(data);
setSelected(data.modules[0]);
})
.catch((err) => setNotice(err.message));
}, [courseId]);
const progressPercent = useMemo(() => {
if (!course) return 0;
const completed = course.modules.filter((m) => m.status === "completed").length;
return (completed / course.modules.length) * 100;
}, [course]);
const courseCompleted = useMemo(() => {
if (!course) return false;
const allCompleted = course.modules.every((m) => m.status === "completed");
if (!allCompleted) return false;
const final = course.modules[course.modules.length - 1];
if (final.type === "quiz") {
const pass = (final.score || 0) >= (final.pass_score || 80);
return pass;
}
return true;
}, [course]);
const isFinalExam = useMemo(() => {
if (!course || !selected) return false;
const last = course.modules[course.modules.length - 1];
return last.id === selected.id && selected.type === "quiz";
}, [course, selected]);
if (!course) {
return <Typography color="warning.main">{notice || "Loading..."}</Typography>;
}
return (
<Stack spacing={2}>
<Card>
<CardContent>
<Stack spacing={1}>
<Typography variant="h4">{course.title}</Typography>
<Typography color="text.secondary">{course.description}</Typography>
<LinearProgress variant="determinate" value={progressPercent} />
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="caption">{Math.round(progressPercent)}% complete</Typography>
{courseCompleted && <Chip label="Course Completed" color="success" />}
</Stack>
</Stack>
</CardContent>
</Card>
{courseCompleted && (
<Card sx={{ borderLeft: "6px solid #2e7d32" }}>
<CardContent>
<Typography variant="h6" color="success.main">Course Completed</Typography>
<Typography variant="body2">You have completed the final exam and finished this course.</Typography>
</CardContent>
</Card>
)}
<Box sx={{ display: "grid", gridTemplateColumns: "320px 1fr", gap: 2 }}>
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 2 }}>Modules</Typography>
<Stack spacing={1}>
{course.modules.map((module) => (
<Card
key={module.id}
variant="outlined"
sx={{
p: 1.5,
cursor: module.status === "locked" ? "not-allowed" : "pointer",
opacity: module.status === "locked" ? 0.6 : 1,
}}
onClick={() => {
if (module.status === "locked") {
setNotice("Complete previous module to unlock");
return;
}
setNotice(null);
setSelected(module);
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle1">{module.order_index}. {module.title}</Typography>
{module.type === "quiz" && (
<Chip
label={module.id === course.modules[course.modules.length - 1].id ? "Final exam" : "Quiz"}
size="small"
sx={{ mt: 0.5 }}
/>
)}
</Box>
{module.status === "locked" ? <LockIcon /> : module.status === "completed" ? <CheckCircleIcon color="success" /> : <PlayArrowIcon />}
</Stack>
</Card>
))}
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
{notice && <Typography color="warning.main">{notice}</Typography>}
{selected && (
<Box>
<Typography variant="h5" sx={{ mb: 1 }}>{selected.title}</Typography>
{isFinalExam && <Chip label="Final exam" color="warning" sx={{ mb: 2 }} />}
<Divider sx={{ mb: 2 }} />
{selected.type === "content" ? (
<>
<Typography sx={{ mb: 2 }}>{selected.content_text}</Typography>
{selected.upload_id && (
<Button
variant="outlined"
href={getUploadUrl(selected.upload_id)}
target="_blank"
sx={{ mb: 2 }}
>
View Presentation
</Button>
)}
<Button variant="contained" onClick={async () => {
await completeContent(selected.id);
const data = await getCourse(courseId);
setCourse(data);
const updated = data.modules.find((m) => m.id === selected.id) || null;
setSelected(updated);
}}>Mark as complete</Button>
</>
) : (
<QuizModule module={selected} onSubmit={async (answers) => {
const result = await submitQuiz(selected.id, answers);
const data = await getCourse(courseId);
setCourse(data);
const updated = data.modules.find((m) => m.id === selected.id) || null;
setSelected(updated);
setNotice(`Score: ${result.score.toFixed(1)}% (${result.passed ? "Passed" : "Failed"})`);
}} />
)}
</Box>
)}
</CardContent>
</Card>
</Box>
</Stack>
);
}
function QuizModule({ module, onSubmit }: { module: ModuleWithProgress; onSubmit: (answers: Record<number, number>) => Promise<void>; }) {
const [answers, setAnswers] = useState<Record<number, number>>({});
return (
<Box>
<Stack spacing={2}>
{module.questions.map((q) => (
<Box key={q.id}>
<Typography variant="subtitle1">{q.prompt}</Typography>
<Stack sx={{ mt: 1 }} spacing={1}>
{q.choices.map((choice) => (
<label key={choice.id} style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="radio"
name={`q-${q.id}`}
checked={answers[q.id] === choice.id}
onChange={() => setAnswers((prev) => ({ ...prev, [q.id]: choice.id }))}
/>
{choice.text}
</label>
))}
</Stack>
</Box>
))}
<Button variant="contained" onClick={() => onSubmit(answers)}>Submit Quiz</Button>
{module.pass_score && <Typography variant="caption" color="text.secondary">Pass score: {module.pass_score}%</Typography>}
</Stack>
</Box>
);
}

View File

@ -0,0 +1,63 @@
import { useEffect, useMemo, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { Box, Button, Card, CardContent, Chip, LinearProgress, Stack, Typography } from "@mui/material";
import { listCourses, enroll, getProgress } from "../api/client";
import { CourseOut, ProgressOut } from "../api/types";
export default function CoursesPage() {
const [courses, setCourses] = useState<CourseOut[]>([]);
const [progress, setProgress] = useState<ProgressOut | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
listCourses().then(setCourses).catch((err) => setError(err.message));
getProgress().then(setProgress).catch(() => null);
}, []);
const progressMap = useMemo(() => {
const map: Record<number, { percent: number; status: string }> = {};
progress?.courses.forEach((c) => {
map[c.course_id] = { percent: c.percent, status: c.status };
});
return map;
}, [progress]);
return (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}>
<Typography variant="h4">Courses</Typography>
{progress && <Chip label="Progress tracked" color="primary" variant="outlined" />}
</Stack>
{error && <Typography color="error">{error}</Typography>}
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 2 }}>
{courses.map((course) => {
const info = progressMap[course.id] || { percent: 0, status: "" };
const isCompleted = info.percent >= 100 || info.status === "passed";
return (
<Card key={course.id} sx={{ height: "100%" }}>
<CardContent>
<Stack spacing={1.5}>
<Typography variant="h6">{course.title}</Typography>
<Typography variant="body2" color="text.secondary">{course.description}</Typography>
<LinearProgress variant="determinate" value={info.percent} />
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="caption">{Math.round(info.percent)}% complete</Typography>
{isCompleted && <Chip label="Completed" color="success" size="small" />}
</Stack>
<Stack direction="row" spacing={1}>
<Button component={RouterLink} to={`/courses/${course.id}`} variant="contained">Open</Button>
<Button variant="outlined" onClick={async () => {
await enroll(course.id);
const updated = await getProgress();
setProgress(updated);
}}>Enroll</Button>
</Stack>
</Stack>
</CardContent>
</Card>
);
})}
</Box>
</Box>
);
}

View File

@ -0,0 +1,48 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { setTokens, setUserRole, getMe } from "../api/client";
import { Box, CircularProgress, Typography } from "@mui/material";
export default function GoogleCallbackPage() {
const navigate = useNavigate();
useEffect(() => {
const handleCallback = async () => {
console.log("Full URL:", window.location.href);
const params = new URLSearchParams(window.location.search);
const access = params.get("access_token");
const refresh = params.get("refresh_token");
console.log("Access token present:", !!access);
console.log("Refresh token present:", !!refresh);
if (access && refresh) {
try {
console.log("Setting tokens...");
setTokens({ access_token: access, refresh_token: refresh, token_type: "bearer" });
console.log("Fetching user info...");
const me = await getMe();
console.log("User info received:", me);
setUserRole(me.role);
console.log("Navigating to /courses");
navigate("/courses", { replace: true });
} catch (error) {
console.error("OAuth callback error:", error);
navigate("/login", { replace: true });
}
} else {
console.error("Missing tokens in URL params");
navigate("/login", { replace: true });
}
};
handleCallback();
}, [navigate]);
return (
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, mt: 8 }}>
<CircularProgress />
<Typography>Signing you in...</Typography>
</Box>
);
}

View File

@ -0,0 +1,47 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, Card, CardContent, Divider, Stack, TextField, Typography } from "@mui/material";
import { login } from "../api/client";
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
return (
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Card sx={{ maxWidth: 420, width: "100%", boxShadow: 3 }}>
<CardContent>
<Typography variant="h5" gutterBottom>Welcome back</Typography>
{error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>}
<Stack spacing={2}>
<TextField label="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
<TextField label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<Button variant="contained" onClick={async () => {
try {
setError(null);
await login(email, password);
navigate("/courses");
} catch (err: any) {
setError(err.message || "Login failed");
}
}}>Login</Button>
</Stack>
<Divider sx={{ my: 3 }} />
<Button
variant="outlined"
fullWidth
onClick={() => {
window.location.href = `${API_URL}/auth/google/login`;
}}
>
Continue with Google
</Button>
</CardContent>
</Card>
</Box>
);
}

View File

@ -0,0 +1,35 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, Card, CardContent, Stack, TextField, Typography } from "@mui/material";
import { register } from "../api/client";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
return (
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Card sx={{ maxWidth: 420, width: "100%", boxShadow: 3 }}>
<CardContent>
<Typography variant="h5" gutterBottom>Create an account</Typography>
{error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>}
<Stack spacing={2}>
<TextField label="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
<TextField label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<Button variant="contained" onClick={async () => {
try {
setError(null);
await register(email, password);
navigate("/login");
} catch (err: any) {
setError(err.message || "Register failed");
}
}}>Create account</Button>
</Stack>
</CardContent>
</Card>
</Box>
);
}

14
frontend/src/styles.css Normal file
View File

@ -0,0 +1,14 @@
:root {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
color-scheme: light;
}
body {
margin: 0;
background: #f6f8fb;
color: #1f2933;
}
#root {
min-height: 100vh;
}

17
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

10
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true,
},
});