first commit
This commit is contained in:
commit
132997c164
36
.env.example
Normal file
36
.env.example
Normal 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
91
.github/copilot-instructions.md
vendored
Normal 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
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
__pycache__
|
||||||
|
.venv
|
||||||
|
test/
|
||||||
178
README.md
Normal file
178
README.md
Normal 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
17
backend/Dockerfile
Normal 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
35
backend/alembic.ini
Normal 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
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
137
backend/app/achievements.py
Normal file
137
backend/app/achievements.py
Normal 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
37
backend/app/auth.py
Normal 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])
|
||||||
29
backend/app/core/config.py
Normal file
29
backend/app/core/config.py
Normal 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
12
backend/app/db.py
Normal 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
42
backend/app/deps.py
Normal 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
49
backend/app/main.py
Normal 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
229
backend/app/models.py
Normal 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")
|
||||||
|
|
||||||
|
|
||||||
3
backend/app/routers/__init__.py
Normal file
3
backend/app/routers/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from app.routers import auth, admin, learner
|
||||||
|
|
||||||
|
__all__ = ["auth", "admin", "learner"]
|
||||||
570
backend/app/routers/admin.py
Normal file
570
backend/app/routers/admin.py
Normal 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
183
backend/app/routers/auth.py
Normal 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)
|
||||||
|
|
||||||
282
backend/app/routers/learner.py
Normal file
282
backend/app/routers/learner.py
Normal 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
276
backend/app/schemas.py
Normal 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
111
backend/app/scripts/seed.py
Normal 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()
|
||||||
68
backend/app/services/progression.py
Normal file
68
backend/app/services/progression.py
Normal 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
|
||||||
28
backend/app/services/quiz.py
Normal file
28
backend/app/services/quiz.py
Normal 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
57
backend/migrations/env.py
Normal 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()
|
||||||
120
backend/migrations/versions/0001_initial.py
Normal file
120
backend/migrations/versions/0001_initial.py
Normal 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")
|
||||||
60
backend/migrations/versions/0002_groups_oauth.py
Normal file
60
backend/migrations/versions/0002_groups_oauth.py
Normal 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")
|
||||||
75
backend/migrations/versions/0003_uploads_achievements.py
Normal file
75
backend/migrations/versions/0003_uploads_achievements.py
Normal 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
13
backend/requirements.txt
Normal 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
55
docker-compose.yml
Normal 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
12
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
2351
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal 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
89
frontend/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
frontend/src/ThemeProvider.tsx
Normal file
57
frontend/src/ThemeProvider.tsx
Normal 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
290
frontend/src/api/client.ts
Normal 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
69
frontend/src/api/types.ts
Normal 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
17
frontend/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
|
||||||
84
frontend/src/pages/AchievementsPage.tsx
Normal file
84
frontend/src/pages/AchievementsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
frontend/src/pages/AdminCourseEditor.tsx
Normal file
93
frontend/src/pages/AdminCourseEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
frontend/src/pages/AdminDashboard.tsx
Normal file
54
frontend/src/pages/AdminDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
frontend/src/pages/AdminGroupsPage.tsx
Normal file
94
frontend/src/pages/AdminGroupsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/src/pages/AdminModuleEdit.tsx
Normal file
134
frontend/src/pages/AdminModuleEdit.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/pages/AdminModulesPage.tsx
Normal file
67
frontend/src/pages/AdminModulesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
frontend/src/pages/AdminStatsPage.tsx
Normal file
207
frontend/src/pages/AdminStatsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/pages/AdminUsersPage.tsx
Normal file
60
frontend/src/pages/AdminUsersPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
frontend/src/pages/CoursePlayerPage.tsx
Normal file
208
frontend/src/pages/CoursePlayerPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
frontend/src/pages/CoursesPage.tsx
Normal file
63
frontend/src/pages/CoursesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/pages/GoogleCallbackPage.tsx
Normal file
48
frontend/src/pages/GoogleCallbackPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
frontend/src/pages/LoginPage.tsx
Normal file
47
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/pages/RegisterPage.tsx
Normal file
35
frontend/src/pages/RegisterPage.tsx
Normal 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
14
frontend/src/styles.css
Normal 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
17
frontend/tsconfig.json
Normal 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
10
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user