Create gamified kids learning API with FastAPI and SQLite

- Set up project structure with FastAPI and SQLite
- Implement user authentication with JWT
- Create models for learning content (subjects, lessons, quizzes)
- Add progress tracking and gamification features
- Implement comprehensive API documentation
- Add error handling and validation
- Set up proper logging and health check endpoint
This commit is contained in:
Automated Action 2025-06-17 18:25:16 +00:00
parent 2bdb5c3112
commit 53d9909fb6
52 changed files with 4149 additions and 2 deletions

183
README.md
View File

@ -1,3 +1,182 @@
# FastAPI Application # Kids Learning Gamification API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A FastAPI-based backend API for a gamified learning platform for kids. The API provides endpoints for managing learning content, tracking user progress, and implementing gamification features like achievements and points.
## Features
- **User Authentication**: Secure JWT-based authentication with registration and login
- **Content Management**: Manage learning subjects, lessons, quizzes, questions, and answers
- **Progress Tracking**: Track user progress through lessons and quizzes
- **Gamification**: Award points for completed lessons and quizzes, implement achievements
- **Health Checks**: Comprehensive health check endpoint for monitoring
- **Documentation**: Interactive API documentation via Swagger UI and ReDoc
## Technology Stack
- **Python**: 3.9+
- **FastAPI**: High-performance web framework
- **SQLite**: Lightweight database
- **SQLAlchemy**: SQL toolkit and ORM
- **Alembic**: Database migration tool
- **Pydantic**: Data validation and settings management
- **JWT**: Token-based authentication
- **Ruff**: Linting tool
## Setup and Installation
### Prerequisites
- Python 3.9 or higher
- pip package manager
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd userauthenticationservice-mt157i
```
2. Create a virtual environment:
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Set up environment variables (optional):
```bash
export SECRET_KEY="your-secret-key" # For production, use a secure random key
export ENVIRONMENT="development" # Options: development, production
```
### Database Setup
The application uses SQLite by default with a database file stored at `/app/storage/db/db.sqlite`. The directory will be created automatically if it doesn't exist.
To initialize the database with the required tables:
```bash
# Initialize Alembic (if you're starting a new project)
alembic init alembic
# Run migrations
alembic upgrade head
```
### Running the Application
Run the development server:
```bash
uvicorn main:app --reload
```
The API will be available at http://localhost:8000.
## API Documentation
Once the application is running, you can access the auto-generated API documentation:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
- OpenAPI Schema: http://localhost:8000/openapi.json
## API Endpoints
The API is organized into several modules:
### Authentication
- `POST /api/v1/auth/register`: Register a new user
- `POST /api/v1/auth/login`: Login and get access token
- `POST /api/v1/auth/test-token`: Test authentication token
### Users
- `GET /api/v1/users/me`: Get current user information
- `PUT /api/v1/users/me`: Update current user information
- `GET /api/v1/users/{user_id}`: Get user by ID (superusers only)
- `PUT /api/v1/users/{user_id}`: Update user by ID (superusers only)
### Content Management
- **Subjects**
- `GET /api/v1/content/subjects`: List all subjects
- `POST /api/v1/content/subjects`: Create a new subject
- `GET /api/v1/content/subjects/{subject_id}`: Get a subject by ID
- `PUT /api/v1/content/subjects/{subject_id}`: Update a subject
- `DELETE /api/v1/content/subjects/{subject_id}`: Delete a subject
- **Lessons**
- `GET /api/v1/content/subjects/{subject_id}/lessons`: List lessons for a subject
- `POST /api/v1/content/lessons`: Create a new lesson
- `GET /api/v1/content/lessons/{lesson_id}`: Get a lesson by ID
- `PUT /api/v1/content/lessons/{lesson_id}`: Update a lesson
- `DELETE /api/v1/content/lessons/{lesson_id}`: Delete a lesson
- **Quizzes**
- `GET /api/v1/content/lessons/{lesson_id}/quizzes`: List quizzes for a lesson
- `POST /api/v1/content/quizzes`: Create a new quiz
- `GET /api/v1/content/quizzes/{quiz_id}`: Get a quiz by ID
- `PUT /api/v1/content/quizzes/{quiz_id}`: Update a quiz
- `DELETE /api/v1/content/quizzes/{quiz_id}`: Delete a quiz
- **Questions and Answers**
- `GET /api/v1/content/quizzes/{quiz_id}/questions`: List questions for a quiz
- `POST /api/v1/content/questions`: Create a new question with answers
- `GET /api/v1/content/questions/{question_id}`: Get a question by ID
- `PUT /api/v1/content/questions/{question_id}`: Update a question
- `DELETE /api/v1/content/questions/{question_id}`: Delete a question
### Progress Tracking
- `GET /api/v1/progress/user/{user_id}/stats`: Get a user's progress statistics
- `GET /api/v1/progress/user/{user_id}/lessons`: Get a user's progress for all lessons
- `GET /api/v1/progress/user/{user_id}/lessons/{lesson_id}`: Get a user's progress for a specific lesson
- `POST /api/v1/progress/user/{user_id}/lessons/{lesson_id}/progress`: Update a user's progress for a lesson
- `POST /api/v1/progress/user/{user_id}/lessons/{lesson_id}/complete`: Mark a lesson as completed
- `POST /api/v1/progress/user/{user_id}/questions/{question_id}/answer`: Submit an answer to a question
- `GET /api/v1/progress/user/{user_id}/questions/{question_id}/answers`: Get a user's answers for a question
- `GET /api/v1/progress/user/{user_id}/quizzes/{quiz_id}/results`: Get a user's quiz results
### Achievements
- `GET /api/v1/achievements`: List all achievements
- `POST /api/v1/achievements`: Create a new achievement (superusers only)
- `GET /api/v1/achievements/{achievement_id}`: Get an achievement by ID
- `PUT /api/v1/achievements/{achievement_id}`: Update an achievement (superusers only)
- `DELETE /api/v1/achievements/{achievement_id}`: Delete an achievement (superusers only)
- `GET /api/v1/achievements/user/{user_id}`: Get a user's achievements
- `POST /api/v1/achievements/user/{user_id}/award/{achievement_id}`: Award an achievement to a user (superusers only)
- `POST /api/v1/achievements/user/{user_id}/check`: Check and award achievements based on user's progress
### Health Check
- `GET /health`: Health check endpoint that returns the status of the service and its dependencies
## Environment Variables
The following environment variables can be configured:
| Variable | Description | Default |
|----------|-------------|---------|
| `SECRET_KEY` | Secret key for JWT token generation | Auto-generated |
| `ENVIRONMENT` | Environment (development, production) | development |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | Expiration time for access tokens | 11520 (8 days) |
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/my-feature`
3. Commit your changes: `git commit -am 'Add new feature'`
4. Push to the branch: `git push origin feature/my-feature`
5. Submit a pull request
## License
This project is licensed under the MIT License.

85
alembic.ini Normal file
View File

@ -0,0 +1,85 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks=black
# black.type=console_scripts
# black.entrypoint=black
# black.options=-l 79
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@
# Application package initialization

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

@ -0,0 +1 @@
# API package initialization

74
app/api/deps.py Normal file
View File

@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import ValidationError
from app import models, schemas
from app.core.config import settings
from app.core import security
from app.db.session import get_db
if TYPE_CHECKING:
from sqlalchemy.orm import Session
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
) -> models.User:
"""
Validate access token and return current user.
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
token_data = schemas.TokenPayload(**payload)
if datetime.fromtimestamp(token_data.exp, tz=timezone.utc) < datetime.now(tz=timezone.utc):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"},
)
except (JWTError, ValidationError) as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
) from e
user = db.query(models.User).filter(models.User.id == token_data.sub).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
def get_current_active_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
"""
Get current active user.
"""
if not current_user.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user")
return current_user
def get_current_active_superuser(
current_user: models.User = Depends(get_current_user),
) -> models.User:
"""
Get current active superuser.
"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="The user doesn't have enough privileges"
)
return current_user

1
app/api/v1/__init__.py Normal file
View File

@ -0,0 +1 @@
# API v1 package initialization

12
app/api/v1/api.py Normal file
View File

@ -0,0 +1,12 @@
from __future__ import annotations
from fastapi import APIRouter
from app.api.v1.endpoints import achievements, auth, content, progress, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(content.router, prefix="/content", tags=["content"])
api_router.include_router(progress.router, prefix="/progress", tags=["progress"])
api_router.include_router(achievements.router, prefix="/achievements", tags=["achievements"])

View File

@ -0,0 +1 @@
# Endpoints module initialization

View File

@ -0,0 +1,197 @@
from __future__ import annotations
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlalchemy.orm import Session
from app import models, schemas
from app.api import deps
from app.db.session import get_db
from app.models.achievement import AchievementType
from app.services.achievement import achievement, user_achievement
router = APIRouter()
@router.get("/", response_model=List[schemas.Achievement])
def read_achievements(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
achievement_type: AchievementType = Query(None, description="Filter by achievement type"),
active_only: bool = Query(True, description="Filter only active achievements"),
) -> Any:
"""
Retrieve achievements.
"""
if achievement_type:
achievements = achievement.get_by_type(
db, achievement_type=achievement_type, skip=skip, limit=limit
)
elif active_only:
achievements = achievement.get_active(db, skip=skip, limit=limit)
else:
achievements = achievement.get_multi(db, skip=skip, limit=limit)
return achievements
@router.post("/", response_model=schemas.Achievement)
def create_achievement(
*,
db: Session = Depends(get_db),
achievement_in: schemas.AchievementCreate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Create new achievement. Only for superusers.
"""
achievement_obj = achievement.create(db, obj_in=achievement_in)
return achievement_obj
@router.get("/{achievement_id}", response_model=schemas.Achievement)
def read_achievement(
*,
db: Session = Depends(get_db),
achievement_id: int = Path(..., gt=0),
) -> Any:
"""
Get achievement by ID.
"""
achievement_obj = achievement.get(db, id=achievement_id)
if not achievement_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Achievement not found",
)
return achievement_obj
@router.put("/{achievement_id}", response_model=schemas.Achievement)
def update_achievement(
*,
db: Session = Depends(get_db),
achievement_id: int = Path(..., gt=0),
achievement_in: schemas.AchievementUpdate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Update achievement. Only for superusers.
"""
achievement_obj = achievement.get(db, id=achievement_id)
if not achievement_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Achievement not found",
)
achievement_obj = achievement.update(db, db_obj=achievement_obj, obj_in=achievement_in)
return achievement_obj
@router.delete("/{achievement_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_achievement(
*,
db: Session = Depends(get_db),
achievement_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> None:
"""
Delete achievement. Only for superusers.
"""
achievement_obj = achievement.get(db, id=achievement_id)
if not achievement_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Achievement not found",
)
achievement.remove(db, id=achievement_id)
@router.get("/user/{user_id}", response_model=List[schemas.UserAchievement])
def read_user_achievements(
*,
db: Session = Depends(get_db),
user_id: int = Path(..., gt=0),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get a user's achievements.
"""
# Regular users can only get their own achievements
if user_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
user_achievements = user_achievement.get_by_user(db, user_id=user_id, skip=skip, limit=limit)
return user_achievements
@router.post("/user/{user_id}/award/{achievement_id}", response_model=schemas.UserAchievement)
def award_achievement_to_user(
*,
db: Session = Depends(get_db),
user_id: int = Path(..., gt=0),
achievement_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Award an achievement to a user. Only for superusers.
"""
# Check if achievement exists
achievement_obj = achievement.get(db, id=achievement_id)
if not achievement_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Achievement not found",
)
# Check if user exists
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user_achievement_obj = user_achievement.award_achievement(
db, user_id=user_id, achievement_id=achievement_id
)
return user_achievement_obj
@router.post("/user/{user_id}/check", response_model=List[schemas.UserAchievement])
def check_user_achievements(
*,
db: Session = Depends(get_db),
user_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Check and award achievements based on user's progress.
"""
# Regular users can only check their own achievements
if user_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# Check if user exists
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
new_achievements = user_achievement.check_achievements(db, user_id=user_id)
return new_achievements

View File

@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app import schemas
from app.api import deps
from app.core import security
from app.core.config import settings
from app.db.session import get_db
from app.services.user import user as user_service
router = APIRouter()
@router.post("/login", response_model=schemas.Token)
def login_access_token(
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
"""
user = user_service.authenticate(db, email=form_data.username, password=form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user_service.is_active(user):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": security.create_access_token(user.id, expires_delta=access_token_expires),
"token_type": "bearer",
}
@router.post("/register", response_model=schemas.User)
def register_user(
*,
db: Session = Depends(get_db),
user_in: schemas.UserCreate,
) -> Any:
"""
Register a new user.
"""
user = user_service.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists",
)
user = user_service.get_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this username already exists",
)
user = user_service.create(db, obj_in=user_in)
return user
@router.post("/test-token", response_model=schemas.User)
def test_token(current_user: schemas.User = Depends(deps.get_current_user)) -> Any:
"""
Test access token.
"""
return current_user

View File

@ -0,0 +1,515 @@
from __future__ import annotations
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlalchemy.orm import Session
from app import models, schemas
from app.api import deps
from app.db.session import get_db
from app.services.lesson import lesson as lesson_service
from app.services.question import question as question_service
from app.services.quiz import quiz as quiz_service
from app.services.subject import subject as subject_service
router = APIRouter()
# Subject endpoints
@router.get("/subjects", response_model=List[schemas.Subject])
def read_subjects(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
active_only: bool = Query(True, description="Filter only active subjects"),
) -> Any:
"""
Retrieve subjects.
"""
if active_only:
subjects = subject_service.get_active(db, skip=skip, limit=limit)
else:
subjects = subject_service.get_multi(db, skip=skip, limit=limit)
return subjects
@router.post("/subjects", response_model=schemas.Subject)
def create_subject(
*,
db: Session = Depends(get_db),
subject_in: schemas.SubjectCreate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Create new subject. Only for superusers.
"""
subject = subject_service.get_by_name(db, name=subject_in.name)
if subject:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Subject with this name already exists",
)
subject = subject_service.create(db, obj_in=subject_in)
return subject
@router.get("/subjects/{subject_id}", response_model=schemas.Subject)
def read_subject(
*,
db: Session = Depends(get_db),
subject_id: int = Path(..., gt=0),
) -> Any:
"""
Get subject by ID.
"""
subject = subject_service.get(db, id=subject_id)
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found",
)
return subject
@router.put("/subjects/{subject_id}", response_model=schemas.Subject)
def update_subject(
*,
db: Session = Depends(get_db),
subject_id: int = Path(..., gt=0),
subject_in: schemas.SubjectUpdate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Update subject. Only for superusers.
"""
subject = subject_service.get(db, id=subject_id)
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found",
)
# Check if name is being changed and already exists
if subject_in.name is not None and subject_in.name != subject.name:
existing_subject = subject_service.get_by_name(db, name=subject_in.name)
if existing_subject:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Subject with this name already exists",
)
subject = subject_service.update(db, db_obj=subject, obj_in=subject_in)
return subject
@router.delete(
"/subjects/{subject_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None
)
def delete_subject(
*,
db: Session = Depends(get_db),
subject_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> None:
"""
Delete subject. Only for superusers.
"""
subject = subject_service.get(db, id=subject_id)
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found",
)
subject_service.remove(db, id=subject_id)
# Lesson endpoints
@router.get("/subjects/{subject_id}/lessons", response_model=List[schemas.Lesson])
def read_lessons_by_subject(
*,
db: Session = Depends(get_db),
subject_id: int = Path(..., gt=0),
skip: int = 0,
limit: int = 100,
active_only: bool = Query(True, description="Filter only active lessons"),
) -> Any:
"""
Retrieve lessons for a subject.
"""
subject = subject_service.get(db, id=subject_id)
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found",
)
if active_only:
lessons = lesson_service.get_active_by_subject(
db, subject_id=subject_id, skip=skip, limit=limit
)
else:
lessons = lesson_service.get_by_subject(db, subject_id=subject_id, skip=skip, limit=limit)
return lessons
@router.post("/lessons", response_model=schemas.Lesson)
def create_lesson(
*,
db: Session = Depends(get_db),
lesson_in: schemas.LessonCreate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Create new lesson. Only for superusers.
"""
# Check if subject exists
subject = subject_service.get(db, id=lesson_in.subject_id)
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found",
)
lesson = lesson_service.create(db, obj_in=lesson_in)
return lesson
@router.get("/lessons/{lesson_id}", response_model=schemas.Lesson)
def read_lesson(
*,
db: Session = Depends(get_db),
lesson_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get lesson by ID.
"""
lesson = lesson_service.get(db, id=lesson_id)
if not lesson:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Lesson not found",
)
return lesson
@router.put("/lessons/{lesson_id}", response_model=schemas.Lesson)
def update_lesson(
*,
db: Session = Depends(get_db),
lesson_id: int = Path(..., gt=0),
lesson_in: schemas.LessonUpdate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Update lesson. Only for superusers.
"""
lesson = lesson_service.get(db, id=lesson_id)
if not lesson:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Lesson not found",
)
# Check if subject exists if it's being changed
if lesson_in.subject_id is not None and lesson_in.subject_id != lesson.subject_id:
subject = subject_service.get(db, id=lesson_in.subject_id)
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found",
)
lesson = lesson_service.update(db, db_obj=lesson, obj_in=lesson_in)
return lesson
@router.delete("/lessons/{lesson_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_lesson(
*,
db: Session = Depends(get_db),
lesson_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> None:
"""
Delete lesson. Only for superusers.
"""
lesson = lesson_service.get(db, id=lesson_id)
if not lesson:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Lesson not found",
)
lesson_service.remove(db, id=lesson_id)
# Quiz endpoints
@router.get("/lessons/{lesson_id}/quizzes", response_model=List[schemas.Quiz])
def read_quizzes_by_lesson(
*,
db: Session = Depends(get_db),
lesson_id: int = Path(..., gt=0),
skip: int = 0,
limit: int = 100,
active_only: bool = Query(True, description="Filter only active quizzes"),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve quizzes for a lesson.
"""
lesson = lesson_service.get(db, id=lesson_id)
if not lesson:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Lesson not found",
)
if active_only:
quizzes = quiz_service.get_active_by_lesson(db, lesson_id=lesson_id, skip=skip, limit=limit)
else:
quizzes = quiz_service.get_by_lesson(db, lesson_id=lesson_id, skip=skip, limit=limit)
return quizzes
@router.post("/quizzes", response_model=schemas.Quiz)
def create_quiz(
*,
db: Session = Depends(get_db),
quiz_in: schemas.QuizCreate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Create new quiz. Only for superusers.
"""
# Check if lesson exists
lesson = lesson_service.get(db, id=quiz_in.lesson_id)
if not lesson:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Lesson not found",
)
quiz = quiz_service.create(db, obj_in=quiz_in)
return quiz
@router.get("/quizzes/{quiz_id}", response_model=schemas.Quiz)
def read_quiz(
*,
db: Session = Depends(get_db),
quiz_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get quiz by ID.
"""
quiz = quiz_service.get(db, id=quiz_id)
if not quiz:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz not found",
)
return quiz
@router.put("/quizzes/{quiz_id}", response_model=schemas.Quiz)
def update_quiz(
*,
db: Session = Depends(get_db),
quiz_id: int = Path(..., gt=0),
quiz_in: schemas.QuizUpdate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Update quiz. Only for superusers.
"""
quiz = quiz_service.get(db, id=quiz_id)
if not quiz:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz not found",
)
# Check if lesson exists if it's being changed
if quiz_in.lesson_id is not None and quiz_in.lesson_id != quiz.lesson_id:
lesson = lesson_service.get(db, id=quiz_in.lesson_id)
if not lesson:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Lesson not found",
)
quiz = quiz_service.update(db, db_obj=quiz, obj_in=quiz_in)
return quiz
@router.delete("/quizzes/{quiz_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_quiz(
*,
db: Session = Depends(get_db),
quiz_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> None:
"""
Delete quiz. Only for superusers.
"""
quiz = quiz_service.get(db, id=quiz_id)
if not quiz:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz not found",
)
quiz_service.remove(db, id=quiz_id)
# Question endpoints
@router.get("/quizzes/{quiz_id}/questions", response_model=List[schemas.Question])
def read_questions_by_quiz(
*,
db: Session = Depends(get_db),
quiz_id: int = Path(..., gt=0),
skip: int = 0,
limit: int = 100,
active_only: bool = Query(True, description="Filter only active questions"),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve questions for a quiz.
"""
quiz = quiz_service.get(db, id=quiz_id)
if not quiz:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz not found",
)
if active_only:
questions = question_service.get_active_by_quiz(db, quiz_id=quiz_id, skip=skip, limit=limit)
else:
questions = question_service.get_by_quiz(db, quiz_id=quiz_id, skip=skip, limit=limit)
return questions
@router.post("/questions", response_model=schemas.Question)
def create_question(
*,
db: Session = Depends(get_db),
question_in: schemas.QuestionCreate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Create new question with answers. Only for superusers.
"""
# Check if quiz exists
quiz = quiz_service.get(db, id=question_in.quiz_id)
if not quiz:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz not found",
)
# Check if at least one answer is marked as correct
has_correct_answer = False
for answer in question_in.answers:
if answer.is_correct:
has_correct_answer = True
break
if not has_correct_answer:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one answer must be marked as correct",
)
question = question_service.create_with_answers(db, obj_in=question_in)
return question
@router.get("/questions/{question_id}", response_model=schemas.Question)
def read_question(
*,
db: Session = Depends(get_db),
question_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get question by ID.
"""
question = question_service.get(db, id=question_id)
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found",
)
return question
@router.put("/questions/{question_id}", response_model=schemas.Question)
def update_question(
*,
db: Session = Depends(get_db),
question_id: int = Path(..., gt=0),
question_in: schemas.QuestionUpdate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Update question with answers. Only for superusers.
"""
question = question_service.get(db, id=question_id)
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found",
)
# Check if quiz exists if it's being changed
if question_in.quiz_id is not None and question_in.quiz_id != question.quiz_id:
quiz = quiz_service.get(db, id=question_in.quiz_id)
if not quiz:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz not found",
)
# Check if at least one answer is marked as correct if answers are being updated
if question_in.answers:
has_correct_answer = False
for answer in question_in.answers:
if answer.is_correct:
has_correct_answer = True
break
if not has_correct_answer:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one answer must be marked as correct",
)
question = question_service.update_with_answers(db, db_obj=question, obj_in=question_in)
return question
@router.delete(
"/questions/{question_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None
)
def delete_question(
*,
db: Session = Depends(get_db),
question_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> None:
"""
Delete question. Only for superusers.
"""
question = question_service.get(db, id=question_id)
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found",
)
question_service.remove(db, id=question_id)

View File

@ -0,0 +1,279 @@
from __future__ import annotations
from typing import Any, Dict, List
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status
from sqlalchemy.orm import Session
from app import models, schemas
from app.api import deps
from app.db.session import get_db
from app.models.progress import ProgressStatus
from app.services.lesson import lesson as lesson_service
from app.services.progress import user_answer, user_progress
from app.services.question import question as question_service
router = APIRouter()
@router.get("/user/{user_id}/stats", response_model=Dict[str, Any])
def get_user_stats(
*,
db: Session = Depends(get_db),
user_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get a user's progress statistics.
"""
# Regular users can only get their own stats
if user_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
stats = user_progress.get_user_progress_stats(db, user_id=user_id)
return stats
@router.get("/user/{user_id}/lessons", response_model=List[schemas.UserProgress])
def get_user_lesson_progress(
*,
db: Session = Depends(get_db),
user_id: int = Path(..., gt=0),
status: ProgressStatus = Query(None, description="Filter by progress status"),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get a user's progress for all lessons.
"""
# Regular users can only get their own progress
if user_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
if status:
progress_items = user_progress.get_by_user_status(
db, user_id=user_id, status=status, skip=skip, limit=limit
)
else:
progress_items = user_progress.get_by_user(db, user_id=user_id, skip=skip, limit=limit)
return progress_items
@router.get("/user/{user_id}/lessons/{lesson_id}", response_model=schemas.UserProgress)
def get_user_lesson_progress_by_id(
*,
db: Session = Depends(get_db),
user_id: int = Path(..., gt=0),
lesson_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get a user's progress for a specific lesson.
"""
# Regular users can only get their own progress
if user_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# Check if lesson exists
lesson = lesson_service.get(db, id=lesson_id)
if not lesson:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Lesson not found",
)
progress = user_progress.get_by_user_lesson(db, user_id=user_id, lesson_id=lesson_id)
if not progress:
# Return default progress if none exists
return {
"id": None,
"user_id": user_id,
"lesson_id": lesson_id,
"status": ProgressStatus.NOT_STARTED,
"progress_percentage": 0,
"points_earned": 0,
"completed_at": None,
"created_at": None,
"updated_at": None,
}
return progress
@router.post("/user/{user_id}/lessons/{lesson_id}/progress", response_model=schemas.UserProgress)
def update_user_lesson_progress(
*,
db: Session = Depends(get_db),
user_id: int = Path(..., gt=0),
lesson_id: int = Path(..., gt=0),
progress_percentage: float = Body(..., ge=0, le=100),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update a user's progress for a lesson.
"""
# Regular users can only update their own progress
if user_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# Check if lesson exists
lesson = lesson_service.get(db, id=lesson_id)
if not lesson:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Lesson not found",
)
progress = user_progress.update_progress(
db, user_id=user_id, lesson_id=lesson_id, progress_percentage=progress_percentage
)
return progress
@router.post("/user/{user_id}/lessons/{lesson_id}/complete", response_model=schemas.UserProgress)
def complete_user_lesson(
*,
db: Session = Depends(get_db),
user_id: int = Path(..., gt=0),
lesson_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Mark a lesson as completed for a user and award points.
"""
# Regular users can only update their own progress
if user_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# Check if lesson exists
lesson = lesson_service.get(db, id=lesson_id)
if not lesson:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Lesson not found",
)
progress = user_progress.complete_lesson(
db, user_id=user_id, lesson_id=lesson_id, points_earned=lesson.points
)
return progress
@router.post("/user/{user_id}/questions/{question_id}/answer", response_model=schemas.UserAnswer)
def submit_answer(
*,
db: Session = Depends(get_db),
user_id: int = Path(..., gt=0),
question_id: int = Path(..., gt=0),
answer_id: int = Body(..., gt=0),
time_taken_seconds: int = Body(None, ge=0),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Submit an answer to a question.
"""
# Regular users can only submit their own answers
if user_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# Check if question exists
question = question_service.get(db, id=question_id)
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found",
)
try:
user_answer_obj = user_answer.submit_answer(
db,
user_id=user_id,
question_id=question_id,
answer_id=answer_id,
time_taken_seconds=time_taken_seconds,
)
return user_answer_obj
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.get(
"/user/{user_id}/questions/{question_id}/answers", response_model=List[schemas.UserAnswer]
)
def get_user_question_answers(
*,
db: Session = Depends(get_db),
user_id: int = Path(..., gt=0),
question_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get a user's answers for a specific question.
"""
# Regular users can only get their own answers
if user_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# Check if question exists
question = question_service.get(db, id=question_id)
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found",
)
answers = user_answer.get_by_user_question(db, user_id=user_id, question_id=question_id)
return answers
@router.get("/user/{user_id}/quizzes/{quiz_id}/results", response_model=Dict[str, Any])
def get_quiz_results(
*,
db: Session = Depends(get_db),
user_id: int = Path(..., gt=0),
quiz_id: int = Path(..., gt=0),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get results for a user's quiz attempt.
"""
# Regular users can only get their own results
if user_id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
results = user_answer.get_quiz_results(db, user_id=user_id, quiz_id=quiz_id)
return results

View File

@ -0,0 +1,129 @@
from __future__ import annotations
from typing import Any, List
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from pydantic import EmailStr
from sqlalchemy.orm import Session
from app import models, schemas
from app.api import deps
from app.db.session import get_db
from app.services.user import user as user_service
router = APIRouter()
@router.get("/", response_model=List[schemas.User])
def read_users(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Retrieve users. Only for superusers.
"""
users = user_service.get_multi(db, skip=skip, limit=limit)
return users
@router.get("/me", response_model=schemas.User)
def read_user_me(
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get current user.
"""
return current_user
@router.put("/me", response_model=schemas.User)
def update_user_me(
*,
db: Session = Depends(get_db),
full_name: str = Body(None),
email: EmailStr = Body(None),
password: str = Body(None),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update own user.
"""
current_user_data = jsonable_encoder(current_user)
user_in = schemas.UserUpdate(**current_user_data)
if full_name is not None:
user_in.full_name = full_name
if email is not None:
# Check if email is already taken
if email != current_user.email:
user = user_service.get_by_email(db, email=email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
user_in.email = email
if password is not None:
user_in.password = password
user = user_service.update(db, db_obj=current_user, obj_in=user_in)
return user
@router.get("/{user_id}", response_model=schemas.User)
def read_user_by_id(
user_id: int,
current_user: models.User = Depends(deps.get_current_active_user),
db: Session = Depends(get_db),
) -> Any:
"""
Get a specific user by id.
"""
user = user_service.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Regular users can only get their own info
if user.id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
return user
@router.put("/{user_id}", response_model=schemas.User)
def update_user(
*,
db: Session = Depends(get_db),
user_id: int,
user_in: schemas.UserUpdate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Update a user. Only for superusers.
"""
user = user_service.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Check if email is already taken by another user
if user_in.email is not None and user_in.email != user.email:
existing_user = user_service.get_by_email(db, email=user_in.email)
if existing_user and existing_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
user = user_service.update(db, db_obj=user, obj_in=user_in)
return user

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

@ -0,0 +1 @@
# Core package initialization

51
app/core/config.py Normal file
View File

@ -0,0 +1,51 @@
from __future__ import annotations
import os
import secrets
from typing import List, Optional, Union
from pydantic import AnyHttpUrl, EmailStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = os.environ.get("SECRET_KEY", secrets.token_urlsafe(32))
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
# BACKEND_CORS_ORIGINS is a comma-separated list of origins
# e.g: "http://localhost,http://localhost:4200,http://localhost:3000"
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@field_validator("BACKEND_CORS_ORIGINS", mode="before")
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
if isinstance(v, (list, str)):
return v
raise ValueError(v)
PROJECT_NAME: str = "Kids Learning Gamification API"
PROJECT_DESCRIPTION: str = "API for a gamified learning platform for kids"
VERSION: str = "0.1.0"
# Database configuration
SQLALCHEMY_DATABASE_URI: Optional[str] = None
# JWT token configuration
ALGORITHM: str = "HS256"
# Email configuration
EMAILS_ENABLED: bool = False
EMAILS_FROM_NAME: Optional[str] = None
EMAILS_FROM_EMAIL: Optional[EmailStr] = None
SMTP_HOST: Optional[str] = None
SMTP_PORT: Optional[int] = None
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
SMTP_TLS: bool = True
model_config = SettingsConfigDict(case_sensitive=True)
settings = Settings()

80
app/core/exceptions.py Normal file
View File

@ -0,0 +1,80 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import HTTPException, status
class BaseAPIException(HTTPException):
"""
Base class for API exceptions.
"""
status_code: int
detail: str
headers: Optional[Dict[str, Any]] = None
def __init__(
self,
detail: str = None,
headers: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(
status_code=self.status_code,
detail=detail or self.detail,
headers=headers or self.headers,
)
class NotFoundException(BaseAPIException):
"""
Exception raised when a resource is not found.
"""
status_code = status.HTTP_404_NOT_FOUND
detail = "Resource not found"
class ForbiddenException(BaseAPIException):
"""
Exception raised when a user doesn't have permission to access a resource.
"""
status_code = status.HTTP_403_FORBIDDEN
detail = "Not enough permissions"
class UnauthorizedException(BaseAPIException):
"""
Exception raised when authentication fails.
"""
status_code = status.HTTP_401_UNAUTHORIZED
detail = "Authentication failed"
headers = {"WWW-Authenticate": "Bearer"}
class BadRequestException(BaseAPIException):
"""
Exception raised for invalid requests.
"""
status_code = status.HTTP_400_BAD_REQUEST
detail = "Invalid request"
class ConflictException(BaseAPIException):
"""
Exception raised when there's a conflict with the current state of the resource.
"""
status_code = status.HTTP_409_CONFLICT
detail = "Conflict with current state"
class ValidationException(BadRequestException):
"""
Exception raised for validation errors.
"""
detail = "Validation error"

48
app/core/middleware.py Normal file
View File

@ -0,0 +1,48 @@
from __future__ import annotations
import time
import uuid
from typing import Callable
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""
Middleware for logging requests and responses.
"""
def __init__(self, app: ASGIApp, logger: Callable = print):
super().__init__(app)
self.logger = logger
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4())
request.state.request_id = request_id
# Log request
start_time = time.time()
self.logger(f"Request {request_id} started: {request.method} {request.url.path}")
try:
# Process request
response = await call_next(request)
# Log response
process_time = time.time() - start_time
self.logger(
f"Request {request_id} completed: {response.status_code} ({process_time:.4f}s)"
)
# Add custom headers
response.headers["X-Request-ID"] = request_id
response.headers["X-Process-Time"] = str(process_time)
return response
except Exception as e:
# Log exception
process_time = time.time() - start_time
self.logger(f"Request {request_id} failed: {e!s} ({process_time:.4f}s)")
raise

38
app/core/security.py Normal file
View File

@ -0,0 +1,38 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str:
"""
Create a JWT access token.
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against a hash.
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password for storing.
"""
return pwd_context.hash(password)

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

@ -0,0 +1 @@
# Database package initialization

9
app/db/base.py Normal file
View File

@ -0,0 +1,9 @@
# Import all the models, so that Base has them before being
# imported by Alembic
from __future__ import annotations
from app.db.base_class import Base # noqa
from app.models.achievement import Achievement, UserAchievement # noqa
from app.models.content import Answer, Lesson, Question, Quiz, Subject # noqa
from app.models.progress import UserAnswer, UserProgress # noqa
from app.models.user import User # noqa

16
app/db/base_class.py Normal file
View File

@ -0,0 +1,16 @@
from __future__ import annotations
from typing import Any
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
id: Any
__name__: str
# Generate __tablename__ automatically
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()

23
app/db/session.py Normal file
View File

@ -0,0 +1,23 @@
from __future__ import annotations
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Create database directory if it doesn't exist
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

7
app/models/__init__.py Normal file
View File

@ -0,0 +1,7 @@
# Models package initialization
from __future__ import annotations
from app.models.achievement import Achievement, AchievementType, UserAchievement
from app.models.content import Answer, DifficultyLevel, Lesson, Question, Quiz, Subject
from app.models.progress import ProgressStatus, UserAnswer, UserProgress
from app.models.user import User

57
app/models/achievement.py Normal file
View File

@ -0,0 +1,57 @@
from __future__ import annotations
import enum
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class AchievementType(enum.Enum):
COMPLETION = "completion" # Complete lessons/quizzes
STREAK = "streak" # Maintain a daily learning streak
PERFORMANCE = "performance" # Get high scores or perfect quizzes
MILESTONE = "milestone" # Reach specific milestones (points, levels)
SPECIAL = "special" # Special or seasonal achievements
class Achievement(Base):
"""
Achievement model for defining available achievements in the system.
"""
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=False)
type = Column(Enum(AchievementType), nullable=False)
image_url = Column(String, nullable=True)
points = Column(Integer, default=0) # Points awarded for earning this achievement
criteria = Column(Text, nullable=False) # JSON string with criteria for earning
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user_achievements = relationship("UserAchievement", back_populates="achievement")
class UserAchievement(Base):
"""
UserAchievement model for tracking achievements earned by users.
"""
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
achievement_id = Column(Integer, ForeignKey("achievement.id"), nullable=False)
earned_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="achievements")
achievement = relationship("Achievement", back_populates="user_achievements")
# This ensures that a user can only earn a specific achievement once
__table_args__ = (
# UniqueConstraint('user_id', 'achievement_id', name='user_achievement_uc'),
)

120
app/models/content.py Normal file
View File

@ -0,0 +1,120 @@
from __future__ import annotations
import enum
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class DifficultyLevel(enum.Enum):
EASY = "easy"
MEDIUM = "medium"
HARD = "hard"
class Subject(Base):
"""
Subject model representing a learning area (e.g., Math, Science, Language).
"""
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
image_url = Column(String, nullable=True)
order = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
lessons = relationship("Lesson", back_populates="subject", cascade="all, delete-orphan")
class Lesson(Base):
"""
Lesson model representing a specific learning unit within a subject.
"""
id = Column(Integer, primary_key=True, index=True)
subject_id = Column(Integer, ForeignKey("subject.id"), nullable=False)
title = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
content = Column(Text, nullable=False)
image_url = Column(String, nullable=True)
order = Column(Integer, default=0)
difficulty = Column(Enum(DifficultyLevel), default=DifficultyLevel.EASY)
points = Column(Integer, default=10) # Points awarded for completing the lesson
duration_minutes = Column(Integer, default=15) # Estimated time to complete the lesson
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
subject = relationship("Subject", back_populates="lessons")
quizzes = relationship("Quiz", back_populates="lesson", cascade="all, delete-orphan")
progress = relationship("UserProgress", back_populates="lesson")
class Quiz(Base):
"""
Quiz model representing a set of questions related to a lesson.
"""
id = Column(Integer, primary_key=True, index=True)
lesson_id = Column(Integer, ForeignKey("lesson.id"), nullable=False)
title = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
time_limit_minutes = Column(Integer, default=10) # Time limit to complete the quiz
pass_percentage = Column(Integer, default=70) # Percentage required to pass
points = Column(Integer, default=20) # Points awarded for passing the quiz
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
lesson = relationship("Lesson", back_populates="quizzes")
questions = relationship("Question", back_populates="quiz", cascade="all, delete-orphan")
class Question(Base):
"""
Question model representing a single quiz question.
"""
id = Column(Integer, primary_key=True, index=True)
quiz_id = Column(Integer, ForeignKey("quiz.id"), nullable=False)
text = Column(Text, nullable=False)
image_url = Column(String, nullable=True)
explanation = Column(Text, nullable=True) # Explanation of the correct answer
points = Column(Integer, default=5) # Points for this question
order = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
quiz = relationship("Quiz", back_populates="questions")
answers = relationship("Answer", back_populates="question", cascade="all, delete-orphan")
user_answers = relationship("UserAnswer", back_populates="question")
class Answer(Base):
"""
Answer model representing a possible answer to a question.
"""
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("question.id"), nullable=False)
text = Column(Text, nullable=False)
is_correct = Column(Boolean, default=False)
explanation = Column(Text, nullable=True)
order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
question = relationship("Question", back_populates="answers")
user_answers = relationship("UserAnswer", back_populates="answer")

66
app/models/progress.py Normal file
View File

@ -0,0 +1,66 @@
from __future__ import annotations
import enum
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class ProgressStatus(enum.Enum):
NOT_STARTED = "not_started"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
class UserProgress(Base):
"""
UserProgress model for tracking user progress through lessons.
"""
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
lesson_id = Column(Integer, ForeignKey("lesson.id"), nullable=False)
status = Column(Enum(ProgressStatus), default=ProgressStatus.NOT_STARTED)
progress_percentage = Column(Float, default=0.0)
completed_at = Column(DateTime, nullable=True)
points_earned = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="progress")
lesson = relationship("Lesson", back_populates="progress")
# This ensures that a user can only have one progress record per lesson
__table_args__ = (
# UniqueConstraint('user_id', 'lesson_id', name='user_lesson_progress_uc'),
)
class UserAnswer(Base):
"""
UserAnswer model for tracking user answers to quiz questions.
"""
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
question_id = Column(Integer, ForeignKey("question.id"), nullable=False)
answer_id = Column(Integer, ForeignKey("answer.id"), nullable=False)
is_correct = Column(Boolean, default=False)
points_earned = Column(Integer, default=0)
attempt_number = Column(Integer, default=1)
time_taken_seconds = Column(Integer, nullable=True) # Time taken to answer
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="answers")
question = relationship("Question", back_populates="user_answers")
answer = relationship("Answer", back_populates="user_answers")
# This allows tracking of multiple attempts
__table_args__ = (
# UniqueConstraint('user_id', 'question_id', 'attempt_number', name='user_question_attempt_uc'),
)

33
app/models/user.py Normal file
View File

@ -0,0 +1,33 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class User(Base):
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
full_name = Column(String, nullable=True)
hashed_password = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
date_of_birth = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
progress = relationship("UserProgress", back_populates="user", cascade="all, delete-orphan")
achievements = relationship(
"UserAchievement", back_populates="user", cascade="all, delete-orphan"
)
answers = relationship("UserAnswer", back_populates="user", cascade="all, delete-orphan")
# Total points accumulated by the user
points = Column(Integer, default=0)
# Current level of the user
level = Column(Integer, default=1)

37
app/schemas/__init__.py Normal file
View File

@ -0,0 +1,37 @@
# Schemas package initialization
from __future__ import annotations
from app.schemas.achievement import (
Achievement,
AchievementCreate,
AchievementUpdate,
UserAchievement,
UserAchievementCreate,
)
from app.schemas.content import (
Answer,
AnswerCreate,
AnswerUpdate,
Lesson,
LessonCreate,
LessonUpdate,
Question,
QuestionCreate,
QuestionUpdate,
Quiz,
QuizCreate,
QuizUpdate,
Subject,
SubjectCreate,
SubjectUpdate,
)
from app.schemas.progress import (
UserAnswer,
UserAnswerCreate,
UserAnswerUpdate,
UserProgress,
UserProgressCreate,
UserProgressUpdate,
)
from app.schemas.token import Token, TokenPayload
from app.schemas.user import User, UserCreate, UserInDB, UserUpdate

View File

@ -0,0 +1,68 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from app.models.achievement import AchievementType
# Achievement schemas
class AchievementBase(BaseModel):
name: str
description: str
type: AchievementType
image_url: Optional[str] = None
points: int = 0
criteria: str # JSON string with criteria for earning
is_active: bool = True
class AchievementCreate(AchievementBase):
pass
class AchievementUpdate(AchievementBase):
name: Optional[str] = None
description: Optional[str] = None
type: Optional[AchievementType] = None
criteria: Optional[str] = None
class AchievementInDBBase(AchievementBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class Achievement(AchievementInDBBase):
pass
# UserAchievement schemas
class UserAchievementBase(BaseModel):
user_id: int
achievement_id: int
class UserAchievementCreate(UserAchievementBase):
pass
class UserAchievementInDBBase(UserAchievementBase):
id: int
earned_at: datetime
class Config:
from_attributes = True
class UserAchievement(UserAchievementInDBBase):
achievement: Achievement
class Config:
from_attributes = True

173
app/schemas/content.py Normal file
View File

@ -0,0 +1,173 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel
from app.models.content import DifficultyLevel
# Subject schemas
class SubjectBase(BaseModel):
name: str
description: Optional[str] = None
image_url: Optional[str] = None
order: Optional[int] = 0
is_active: bool = True
class SubjectCreate(SubjectBase):
pass
class SubjectUpdate(SubjectBase):
name: Optional[str] = None
class SubjectInDBBase(SubjectBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class Subject(SubjectInDBBase):
pass
# Lesson schemas
class LessonBase(BaseModel):
subject_id: int
title: str
description: Optional[str] = None
content: str
image_url: Optional[str] = None
order: Optional[int] = 0
difficulty: DifficultyLevel = DifficultyLevel.EASY
points: int = 10
duration_minutes: int = 15
is_active: bool = True
class LessonCreate(LessonBase):
pass
class LessonUpdate(LessonBase):
subject_id: Optional[int] = None
title: Optional[str] = None
content: Optional[str] = None
class LessonInDBBase(LessonBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class Lesson(LessonInDBBase):
pass
# Quiz schemas
class QuizBase(BaseModel):
lesson_id: int
title: str
description: Optional[str] = None
time_limit_minutes: int = 10
pass_percentage: int = 70
points: int = 20
is_active: bool = True
class QuizCreate(QuizBase):
pass
class QuizUpdate(QuizBase):
lesson_id: Optional[int] = None
title: Optional[str] = None
class QuizInDBBase(QuizBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class Quiz(QuizInDBBase):
pass
# Answer schemas
class AnswerBase(BaseModel):
text: str
is_correct: bool
explanation: Optional[str] = None
order: int = 0
class AnswerCreate(AnswerBase):
pass
class AnswerUpdate(AnswerBase):
text: Optional[str] = None
is_correct: Optional[bool] = None
class AnswerInDBBase(AnswerBase):
id: int
question_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class Answer(AnswerInDBBase):
pass
# Question schemas
class QuestionBase(BaseModel):
quiz_id: int
text: str
image_url: Optional[str] = None
explanation: Optional[str] = None
points: int = 5
order: int = 0
is_active: bool = True
class QuestionCreate(QuestionBase):
answers: List[AnswerCreate]
class QuestionUpdate(QuestionBase):
quiz_id: Optional[int] = None
text: Optional[str] = None
answers: Optional[List[AnswerUpdate]] = None
class QuestionInDBBase(QuestionBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class Question(QuestionInDBBase):
answers: List[Answer] = []

81
app/schemas/progress.py Normal file
View File

@ -0,0 +1,81 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from app.models.progress import ProgressStatus
# UserProgress schemas
class UserProgressBase(BaseModel):
user_id: int
lesson_id: int
status: ProgressStatus = ProgressStatus.NOT_STARTED
progress_percentage: float = 0.0
points_earned: int = 0
class UserProgressCreate(UserProgressBase):
pass
class UserProgressUpdate(UserProgressBase):
user_id: Optional[int] = None
lesson_id: Optional[int] = None
status: Optional[ProgressStatus] = None
progress_percentage: Optional[float] = None
points_earned: Optional[int] = None
completed_at: Optional[datetime] = None
class UserProgressInDBBase(UserProgressBase):
id: int
completed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class UserProgress(UserProgressInDBBase):
pass
# UserAnswer schemas
class UserAnswerBase(BaseModel):
user_id: int
question_id: int
answer_id: int
is_correct: bool = False
points_earned: int = 0
attempt_number: int = 1
time_taken_seconds: Optional[int] = None
class UserAnswerCreate(UserAnswerBase):
pass
class UserAnswerUpdate(UserAnswerBase):
user_id: Optional[int] = None
question_id: Optional[int] = None
answer_id: Optional[int] = None
is_correct: Optional[bool] = None
points_earned: Optional[int] = None
attempt_number: Optional[int] = None
time_taken_seconds: Optional[int] = None
class UserAnswerInDBBase(UserAnswerBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class UserAnswer(UserAnswerInDBBase):
pass

15
app/schemas/token.py Normal file
View File

@ -0,0 +1,15 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
sub: Optional[int] = None
exp: int

49
app/schemas/user.py Normal file
View File

@ -0,0 +1,49 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
# Shared properties
class UserBase(BaseModel):
email: Optional[EmailStr] = None
username: Optional[str] = None
is_active: Optional[bool] = True
is_superuser: bool = False
full_name: Optional[str] = None
date_of_birth: Optional[datetime] = None
# Properties to receive via API on creation
class UserCreate(UserBase):
email: EmailStr
username: str
password: str
# Properties to receive via API on update
class UserUpdate(UserBase):
password: Optional[str] = None
class UserInDBBase(UserBase):
id: Optional[int] = None
points: int = 0
level: int = 1
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class User(UserInDBBase):
pass
# Additional properties stored in DB
class UserInDB(UserInDBBase):
hashed_password: str

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

@ -0,0 +1 @@
# Services package initialization

176
app/services/achievement.py Normal file
View File

@ -0,0 +1,176 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import List, Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.models.achievement import Achievement, AchievementType, UserAchievement
from app.models.progress import ProgressStatus, UserProgress
from app.schemas.achievement import AchievementCreate, AchievementUpdate, UserAchievementCreate
from app.services.user import user as user_service
from app.utils.db import CRUDBase
class CRUDAchievement(CRUDBase[Achievement, AchievementCreate, AchievementUpdate]):
def get_by_type(
self, db: Session, *, achievement_type: AchievementType, skip: int = 0, limit: int = 100
) -> List[Achievement]:
"""
Get achievements by type.
"""
return (
db.query(Achievement)
.filter(Achievement.type == achievement_type)
.offset(skip)
.limit(limit)
.all()
)
def get_active(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[Achievement]:
"""
Get active achievements.
"""
return (
db.query(Achievement)
.filter(Achievement.is_active == True)
.offset(skip)
.limit(limit)
.all()
)
class CRUDUserAchievement(CRUDBase[UserAchievement, UserAchievementCreate, UserAchievementCreate]):
def get_by_user(
self, db: Session, *, user_id: int, skip: int = 0, limit: int = 100
) -> List[UserAchievement]:
"""
Get a user's achievements.
"""
return (
db.query(UserAchievement)
.filter(UserAchievement.user_id == user_id)
.offset(skip)
.limit(limit)
.all()
)
def get_by_user_achievement(
self, db: Session, *, user_id: int, achievement_id: int
) -> Optional[UserAchievement]:
"""
Get a specific user achievement.
"""
return (
db.query(UserAchievement)
.filter(
UserAchievement.user_id == user_id, UserAchievement.achievement_id == achievement_id
)
.first()
)
def award_achievement(
self, db: Session, *, user_id: int, achievement_id: int
) -> UserAchievement:
"""
Award an achievement to a user.
"""
# Check if user already has this achievement
existing = self.get_by_user_achievement(db, user_id=user_id, achievement_id=achievement_id)
if existing:
return existing
# Get the achievement to award points
achievement = db.query(Achievement).filter(Achievement.id == achievement_id).first()
# Create new user achievement
user_achievement = UserAchievement(
user_id=user_id, achievement_id=achievement_id, earned_at=datetime.utcnow()
)
db.add(user_achievement)
# Award points to user
if achievement and achievement.points > 0:
user_service.add_points(db, user_id=user_id, points=achievement.points)
db.commit()
db.refresh(user_achievement)
return user_achievement
def check_achievements(self, db: Session, *, user_id: int) -> List[UserAchievement]:
"""
Check and award achievements based on user's progress.
"""
# Get all active achievements
active_achievements = db.query(Achievement).filter(Achievement.is_active == True).all()
# Get user's current achievements
user_achievements = (
db.query(UserAchievement.achievement_id)
.filter(UserAchievement.user_id == user_id)
.all()
)
already_earned = [ua[0] for ua in user_achievements]
# Get user data for achievement criteria
user = user_service.get(db, id=user_id)
# Counters for various achievement criteria
completed_lessons_count = (
db.query(func.count(UserProgress.id))
.filter(
UserProgress.user_id == user_id, UserProgress.status == ProgressStatus.COMPLETED
)
.scalar()
or 0
)
# List to store newly awarded achievements
newly_awarded = []
# Check each achievement
for achievement in active_achievements:
# Skip if already earned
if achievement.id in already_earned:
continue
# Parse criteria
try:
criteria = json.loads(achievement.criteria)
except json.JSONDecodeError:
# Skip if criteria is invalid JSON
continue
# Check different types of achievements
awarded = False
if achievement.type == AchievementType.COMPLETION:
# Completion achievements (e.g., complete X lessons)
if criteria.get("type") == "lessons_completed" and "count" in criteria:
if completed_lessons_count >= criteria["count"]:
awarded = True
elif achievement.type == AchievementType.MILESTONE:
# Milestone achievements (e.g., reach level X, earn Y points)
if criteria.get("type") == "reach_level" and "level" in criteria:
if user.level >= criteria["level"]:
awarded = True
elif criteria.get("type") == "earn_points" and "points" in criteria:
if user.points >= criteria["points"]:
awarded = True
# Award the achievement if criteria met
if awarded:
user_achievement = self.award_achievement(
db, user_id=user_id, achievement_id=achievement.id
)
newly_awarded.append(user_achievement)
return newly_awarded
achievement = CRUDAchievement(Achievement)
user_achievement = CRUDUserAchievement(UserAchievement)

59
app/services/lesson.py Normal file
View File

@ -0,0 +1,59 @@
from __future__ import annotations
from typing import List
from sqlalchemy.orm import Session
from app.models.content import DifficultyLevel, Lesson
from app.schemas.content import LessonCreate, LessonUpdate
from app.utils.db import CRUDBase
class CRUDLesson(CRUDBase[Lesson, LessonCreate, LessonUpdate]):
def get_by_subject(
self, db: Session, *, subject_id: int, skip: int = 0, limit: int = 100
) -> List[Lesson]:
"""
Get lessons by subject.
"""
return (
db.query(Lesson)
.filter(Lesson.subject_id == subject_id)
.order_by(Lesson.order)
.offset(skip)
.limit(limit)
.all()
)
def get_active_by_subject(
self, db: Session, *, subject_id: int, skip: int = 0, limit: int = 100
) -> List[Lesson]:
"""
Get active lessons by subject.
"""
return (
db.query(Lesson)
.filter(Lesson.subject_id == subject_id, Lesson.is_active == True)
.order_by(Lesson.order)
.offset(skip)
.limit(limit)
.all()
)
def get_by_difficulty(
self, db: Session, *, difficulty: DifficultyLevel, skip: int = 0, limit: int = 100
) -> List[Lesson]:
"""
Get lessons by difficulty.
"""
return (
db.query(Lesson)
.filter(Lesson.difficulty == difficulty, Lesson.is_active == True)
.order_by(Lesson.order)
.offset(skip)
.limit(limit)
.all()
)
lesson = CRUDLesson(Lesson)

347
app/services/progress.py Normal file
View File

@ -0,0 +1,347 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from app.models.content import Answer, Question
from app.models.progress import ProgressStatus, UserAnswer, UserProgress
from app.models.user import User
from app.schemas.progress import UserAnswerCreate, UserProgressCreate, UserProgressUpdate
from app.services.user import user as user_service
from app.utils.db import CRUDBase
class CRUDUserProgress(CRUDBase[UserProgress, UserProgressCreate, UserProgressUpdate]):
def get_by_user_lesson(
self, db: Session, *, user_id: int, lesson_id: int
) -> Optional[UserProgress]:
"""
Get a user's progress for a specific lesson.
"""
return (
db.query(UserProgress)
.filter(UserProgress.user_id == user_id, UserProgress.lesson_id == lesson_id)
.first()
)
def get_by_user(
self, db: Session, *, user_id: int, skip: int = 0, limit: int = 100
) -> List[UserProgress]:
"""
Get a user's progress for all lessons.
"""
return (
db.query(UserProgress)
.filter(UserProgress.user_id == user_id)
.offset(skip)
.limit(limit)
.all()
)
def get_by_user_status(
self, db: Session, *, user_id: int, status: ProgressStatus, skip: int = 0, limit: int = 100
) -> List[UserProgress]:
"""
Get a user's progress filtered by status.
"""
return (
db.query(UserProgress)
.filter(UserProgress.user_id == user_id, UserProgress.status == status)
.offset(skip)
.limit(limit)
.all()
)
def update_progress(
self, db: Session, *, user_id: int, lesson_id: int, progress_percentage: float
) -> UserProgress:
"""
Update a user's progress for a lesson.
"""
db_obj = self.get_by_user_lesson(db, user_id=user_id, lesson_id=lesson_id)
if not db_obj:
# Create new progress record
db_obj = UserProgress(
user_id=user_id,
lesson_id=lesson_id,
status=ProgressStatus.IN_PROGRESS
if progress_percentage < 100
else ProgressStatus.COMPLETED,
progress_percentage=progress_percentage,
completed_at=datetime.utcnow() if progress_percentage >= 100 else None,
)
db.add(db_obj)
else:
# Update existing progress record
db_obj.progress_percentage = progress_percentage
# Update status based on progress
if progress_percentage >= 100 and db_obj.status != ProgressStatus.COMPLETED:
db_obj.status = ProgressStatus.COMPLETED
db_obj.completed_at = datetime.utcnow()
elif progress_percentage > 0 and db_obj.status == ProgressStatus.NOT_STARTED:
db_obj.status = ProgressStatus.IN_PROGRESS
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def complete_lesson(
self, db: Session, *, user_id: int, lesson_id: int, points_earned: int = None
) -> UserProgress:
"""
Mark a lesson as completed and award points to the user.
"""
db_obj = self.get_by_user_lesson(db, user_id=user_id, lesson_id=lesson_id)
if not db_obj:
# Create new completed progress record
db_obj = UserProgress(
user_id=user_id,
lesson_id=lesson_id,
status=ProgressStatus.COMPLETED,
progress_percentage=100.0,
completed_at=datetime.utcnow(),
points_earned=points_earned or 0,
)
db.add(db_obj)
else:
# Update existing progress record to completed
db_obj.status = ProgressStatus.COMPLETED
db_obj.progress_percentage = 100.0
db_obj.completed_at = datetime.utcnow()
if points_earned is not None:
db_obj.points_earned = points_earned
db.add(db_obj)
# Award points to user if specified
if points_earned:
user_service.add_points(db, user_id=user_id, points=points_earned)
db.commit()
db.refresh(db_obj)
return db_obj
def get_user_progress_stats(self, db: Session, *, user_id: int) -> Dict[str, Any]:
"""
Get statistics about a user's progress.
"""
# Get counts of lessons by status
completed_count = (
db.query(func.count(UserProgress.id))
.filter(
UserProgress.user_id == user_id, UserProgress.status == ProgressStatus.COMPLETED
)
.scalar()
or 0
)
in_progress_count = (
db.query(func.count(UserProgress.id))
.filter(
UserProgress.user_id == user_id, UserProgress.status == ProgressStatus.IN_PROGRESS
)
.scalar()
or 0
)
# Get total points earned from lessons
total_points = (
db.query(func.sum(UserProgress.points_earned))
.filter(UserProgress.user_id == user_id)
.scalar()
or 0
)
# Get user level and overall points
user = db.query(User).filter(User.id == user_id).first()
return {
"completed_lessons": completed_count,
"in_progress_lessons": in_progress_count,
"total_lessons_points": total_points,
"user_level": user.level if user else 1,
"user_points": user.points if user else 0,
}
class CRUDUserAnswer(CRUDBase[UserAnswer, UserAnswerCreate, UserAnswerCreate]):
def get_by_user_question_attempt(
self, db: Session, *, user_id: int, question_id: int, attempt_number: int
) -> Optional[UserAnswer]:
"""
Get a user's answer for a specific question attempt.
"""
return (
db.query(UserAnswer)
.filter(
UserAnswer.user_id == user_id,
UserAnswer.question_id == question_id,
UserAnswer.attempt_number == attempt_number,
)
.first()
)
def get_by_user_question(
self, db: Session, *, user_id: int, question_id: int
) -> List[UserAnswer]:
"""
Get all of a user's answers for a specific question.
"""
return (
db.query(UserAnswer)
.filter(UserAnswer.user_id == user_id, UserAnswer.question_id == question_id)
.order_by(UserAnswer.attempt_number)
.all()
)
def get_by_quiz(self, db: Session, *, user_id: int, quiz_id: int) -> List[UserAnswer]:
"""
Get a user's answers for all questions in a quiz.
"""
return (
db.query(UserAnswer)
.join(Question, UserAnswer.question_id == Question.id)
.filter(UserAnswer.user_id == user_id, Question.quiz_id == quiz_id)
.all()
)
def submit_answer(
self,
db: Session,
*,
user_id: int,
question_id: int,
answer_id: int,
time_taken_seconds: Optional[int] = None,
) -> UserAnswer:
"""
Submit a user's answer to a question.
"""
# Get the answer to check correctness
answer = db.query(Answer).filter(Answer.id == answer_id).first()
if not answer or answer.question_id != question_id:
raise ValueError("Invalid answer for the question")
# Calculate attempt number (previous attempts + 1)
attempt_count = (
db.query(func.count(UserAnswer.id))
.filter(UserAnswer.user_id == user_id, UserAnswer.question_id == question_id)
.scalar()
or 0
)
attempt_number = attempt_count + 1
# Calculate points based on correctness and attempt number
# First attempt correct: full points, subsequent attempts: reduced points
question = db.query(Question).filter(Question.id == question_id).first()
points_earned = 0
if answer.is_correct:
if attempt_number == 1:
# Full points for correct answer on first try
points_earned = question.points
else:
# Reduced points for correct answers on subsequent attempts
# Formula: points * (0.5)^(attempt_number-1)
# This gives 50% for 2nd attempt, 25% for 3rd, etc.
points_earned = int(question.points * (0.5 ** (attempt_number - 1)))
# Create the user answer record
user_answer = UserAnswer(
user_id=user_id,
question_id=question_id,
answer_id=answer_id,
is_correct=answer.is_correct,
points_earned=points_earned,
attempt_number=attempt_number,
time_taken_seconds=time_taken_seconds,
)
db.add(user_answer)
# Award points to user if answer is correct
if points_earned > 0:
user_service.add_points(db, user_id=user_id, points=points_earned)
db.commit()
db.refresh(user_answer)
return user_answer
def get_quiz_results(self, db: Session, *, user_id: int, quiz_id: int) -> Dict[str, Any]:
"""
Get a summary of a user's results for a quiz.
"""
# Get all questions for this quiz
questions = db.query(Question).filter(Question.quiz_id == quiz_id).all()
question_ids = [q.id for q in questions]
if not question_ids:
return {
"total_questions": 0,
"questions_answered": 0,
"correct_answers": 0,
"score_percentage": 0,
"points_earned": 0,
"passed": False,
}
# Get the user's most recent answers for each question
subquery = (
db.query(
UserAnswer.question_id, func.max(UserAnswer.attempt_number).label("max_attempt")
)
.filter(UserAnswer.user_id == user_id, UserAnswer.question_id.in_(question_ids))
.group_by(UserAnswer.question_id)
.subquery()
)
latest_answers = (
db.query(UserAnswer)
.join(
subquery,
and_(
UserAnswer.question_id == subquery.c.question_id,
UserAnswer.attempt_number == subquery.c.max_attempt,
),
)
.filter(UserAnswer.user_id == user_id)
.all()
)
# Calculate statistics
questions_answered = len(latest_answers)
correct_answers = sum(1 for a in latest_answers if a.is_correct)
points_earned = sum(a.points_earned for a in latest_answers)
# Calculate score percentage
score_percentage = 0
if questions_answered > 0:
score_percentage = round((correct_answers / len(questions)) * 100)
# Get quiz pass percentage
quiz = db.query(func.min(Question.quiz_id)).filter(Question.id.in_(question_ids)).scalar()
quiz_pass_percentage = 70 # Default pass percentage
# Check if user passed the quiz
passed = score_percentage >= quiz_pass_percentage
return {
"total_questions": len(questions),
"questions_answered": questions_answered,
"correct_answers": correct_answers,
"score_percentage": score_percentage,
"points_earned": points_earned,
"passed": passed,
}
user_progress = CRUDUserProgress(UserProgress)
user_answer = CRUDUserAnswer(UserAnswer)

93
app/services/question.py Normal file
View File

@ -0,0 +1,93 @@
from __future__ import annotations
from typing import List
from sqlalchemy.orm import Session
from app.models.content import Answer, Question
from app.schemas.content import QuestionCreate, QuestionUpdate
from app.utils.db import CRUDBase
class CRUDQuestion(CRUDBase[Question, QuestionCreate, QuestionUpdate]):
def get_by_quiz(
self, db: Session, *, quiz_id: int, skip: int = 0, limit: int = 100
) -> List[Question]:
"""
Get questions by quiz.
"""
return (
db.query(Question)
.filter(Question.quiz_id == quiz_id)
.order_by(Question.order)
.offset(skip)
.limit(limit)
.all()
)
def get_active_by_quiz(
self, db: Session, *, quiz_id: int, skip: int = 0, limit: int = 100
) -> List[Question]:
"""
Get active questions by quiz.
"""
return (
db.query(Question)
.filter(Question.quiz_id == quiz_id, Question.is_active == True)
.order_by(Question.order)
.offset(skip)
.limit(limit)
.all()
)
def create_with_answers(self, db: Session, *, obj_in: QuestionCreate) -> Question:
"""
Create a question with answers.
"""
# Extract answers from input
answers_data = obj_in.answers
question_data = obj_in.model_dump(exclude={"answers"})
# Create question
db_obj = Question(**question_data)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Create answers
for answer_data in answers_data:
answer = Answer(question_id=db_obj.id, **answer_data.model_dump())
db.add(answer)
db.commit()
db.refresh(db_obj)
return db_obj
def update_with_answers(
self, db: Session, *, db_obj: Question, obj_in: QuestionUpdate
) -> Question:
"""
Update a question with answers.
"""
# Update question
question_data = obj_in.model_dump(exclude={"answers"}, exclude_unset=True)
for field, value in question_data.items():
setattr(db_obj, field, value)
# Update answers if provided
if obj_in.answers:
# Delete existing answers
db.query(Answer).filter(Answer.question_id == db_obj.id).delete()
# Create new answers
for answer_data in obj_in.answers:
answer = Answer(question_id=db_obj.id, **answer_data.model_dump())
db.add(answer)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
question = CRUDQuestion(Question)

36
app/services/quiz.py Normal file
View File

@ -0,0 +1,36 @@
from __future__ import annotations
from typing import List
from sqlalchemy.orm import Session
from app.models.content import Quiz
from app.schemas.content import QuizCreate, QuizUpdate
from app.utils.db import CRUDBase
class CRUDQuiz(CRUDBase[Quiz, QuizCreate, QuizUpdate]):
def get_by_lesson(
self, db: Session, *, lesson_id: int, skip: int = 0, limit: int = 100
) -> List[Quiz]:
"""
Get quizzes by lesson.
"""
return db.query(Quiz).filter(Quiz.lesson_id == lesson_id).offset(skip).limit(limit).all()
def get_active_by_lesson(
self, db: Session, *, lesson_id: int, skip: int = 0, limit: int = 100
) -> List[Quiz]:
"""
Get active quizzes by lesson.
"""
return (
db.query(Quiz)
.filter(Quiz.lesson_id == lesson_id, Quiz.is_active == True)
.offset(skip)
.limit(limit)
.all()
)
quiz = CRUDQuiz(Quiz)

33
app/services/subject.py Normal file
View File

@ -0,0 +1,33 @@
from __future__ import annotations
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.content import Subject
from app.schemas.content import SubjectCreate, SubjectUpdate
from app.utils.db import CRUDBase
class CRUDSubject(CRUDBase[Subject, SubjectCreate, SubjectUpdate]):
def get_by_name(self, db: Session, *, name: str) -> Optional[Subject]:
"""
Get a subject by name.
"""
return db.query(Subject).filter(Subject.name == name).first()
def get_active(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[Subject]:
"""
Get active subjects.
"""
return (
db.query(Subject)
.filter(Subject.is_active == True)
.order_by(Subject.order)
.offset(skip)
.limit(limit)
.all()
)
subject = CRUDSubject(Subject)

107
app/services/user.py Normal file
View File

@ -0,0 +1,107 @@
from __future__ import annotations
from typing import Any, Dict, Optional, Union
from sqlalchemy.orm import Session
from app.core.security import get_password_hash, verify_password
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
from app.utils.db import CRUDBase
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
"""
Get a user by email.
"""
return db.query(User).filter(User.email == email).first()
def get_by_username(self, db: Session, *, username: str) -> Optional[User]:
"""
Get a user by username.
"""
return db.query(User).filter(User.username == username).first()
def create(self, db: Session, *, obj_in: UserCreate) -> User:
"""
Create a new user.
"""
db_obj = User(
email=obj_in.email,
username=obj_in.username,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
date_of_birth=obj_in.date_of_birth,
is_active=obj_in.is_active,
is_superuser=obj_in.is_superuser,
points=0,
level=1,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
) -> User:
"""
Update a user.
"""
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
# Handle password update
if update_data.get("password"):
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
return super().update(db, db_obj=db_obj, obj_in=update_data)
def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]:
"""
Authenticate a user by email and password.
"""
user = self.get_by_email(db, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active(self, user: User) -> bool:
"""
Check if a user is active.
"""
return user.is_active
def is_superuser(self, user: User) -> bool:
"""
Check if a user is a superuser.
"""
return user.is_superuser
def add_points(self, db: Session, *, user_id: int, points: int) -> User:
"""
Add points to a user's account and check for level up.
"""
user = self.get(db, id=user_id)
if not user:
return None
user.points += points
# Simple level up logic: 100 points per level
new_level = (user.points // 100) + 1
user.level = max(user.level, new_level)
db.commit()
db.refresh(user)
return user
user = CRUDUser(User)

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

@ -0,0 +1 @@
# Utils package initialization

79
app/utils/db.py Normal file
View File

@ -0,0 +1,79 @@
from __future__ import annotations
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db.base_class import Base
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
"""
CRUD operations base class.
"""
def __init__(self, model: Type[ModelType]):
"""
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
**Parameters**
* `model`: A SQLAlchemy model class
* `schema`: A Pydantic model (schema) class
"""
self.model = model
def get(self, db: Session, id: Any) -> Optional[ModelType]:
"""
Get a model instance by ID.
"""
return db.query(self.model).filter(self.model.id == id).first()
def get_multi(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[ModelType]:
"""
Get multiple model instances.
"""
return db.query(self.model).offset(skip).limit(limit).all()
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
"""
Create a new model instance.
"""
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data) # type: ignore
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self, db: Session, *, db_obj: ModelType, obj_in: Union[UpdateSchemaType, Dict[str, Any]]
) -> ModelType:
"""
Update a model instance.
"""
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def remove(self, db: Session, *, id: int) -> ModelType:
"""
Remove a model instance.
"""
obj = db.query(self.model).get(id)
db.delete(obj)
db.commit()
return obj

88
app/utils/validation.py Normal file
View File

@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Any, Dict, Type
from pydantic import BaseModel, ValidationError
from app.core.exceptions import ValidationException
def validate_model(model: Type[BaseModel], data: Dict[str, Any]) -> BaseModel:
"""
Validate data against a Pydantic model.
Args:
model: The Pydantic model to validate against
data: The data to validate
Returns:
The validated model instance
Raises:
ValidationException: If the data fails validation
"""
try:
return model(**data)
except ValidationError as e:
errors = e.errors()
error_details = []
for error in errors:
loc = ".".join(str(x) for x in error["loc"])
error_details.append(
{
"field": loc,
"message": error["msg"],
"type": error["type"],
}
)
raise ValidationException(detail={"errors": error_details})
def validate_password(password: str) -> None:
"""
Validate a password meets minimum requirements.
Args:
password: The password to validate
Raises:
ValidationException: If the password is invalid
"""
if len(password) < 8:
raise ValidationException(detail="Password must be at least 8 characters long")
if not any(char.isdigit() for char in password):
raise ValidationException(detail="Password must contain at least one digit")
if not any(char.isupper() for char in password):
raise ValidationException(detail="Password must contain at least one uppercase letter")
if not any(char.islower() for char in password):
raise ValidationException(detail="Password must contain at least one lowercase letter")
def validate_email(email: str) -> None:
"""
Validate an email address.
Args:
email: The email address to validate
Raises:
ValidationException: If the email is invalid
"""
# Simple check for @ symbol
if "@" not in email:
raise ValidationException(detail="Invalid email format")
# Split by @ and check parts
parts = email.split("@")
if len(parts) != 2 or not parts[0] or not parts[1]:
raise ValidationException(detail="Invalid email format")
# Check for domain with at least one dot
domain = parts[1]
if "." not in domain or domain.startswith(".") or domain.endswith("."):
raise ValidationException(detail="Invalid email domain")

187
main.py Normal file
View File

@ -0,0 +1,187 @@
#!/usr/bin/env python3
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict
import os
import traceback
import uvicorn
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.api.v1.api import api_router
from app.core.config import settings
from app.core.exceptions import BaseAPIException
app = FastAPI(
title=settings.PROJECT_NAME,
description=settings.PROJECT_DESCRIPTION,
version=settings.VERSION,
openapi_url="/openapi.json",
docs_url="/docs",
redoc_url="/redoc",
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add request logging middleware if not in production
if os.getenv("ENVIRONMENT", "development") != "production":
from app.core.middleware import RequestLoggingMiddleware
app.add_middleware(RequestLoggingMiddleware)
# Exception handlers
@app.exception_handler(BaseAPIException)
async def base_api_exception_handler(request: Request, exc: BaseAPIException) -> JSONResponse:
"""
Handle custom API exceptions.
"""
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": exc.status_code,
"message": exc.detail,
"type": exc.__class__.__name__,
}
},
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""
Handle request validation errors.
"""
errors = exc.errors()
error_details = []
for error in errors:
loc = ".".join(str(x) for x in error["loc"])
error_details.append(
{
"field": loc,
"message": error["msg"],
"type": error["type"],
}
)
return JSONResponse(
status_code=400,
content={
"error": {
"code": 400,
"message": "Validation Error",
"type": "ValidationError",
"details": error_details,
}
},
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
"""
Handle standard HTTP exceptions.
"""
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": exc.status_code,
"message": exc.detail,
"type": "HTTPException",
}
},
headers=exc.headers,
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""
Handle all other exceptions.
"""
# In production, you might want to log the error instead of returning the traceback
error_detail = str(exc)
if os.getenv("ENVIRONMENT", "development") == "development":
error_detail = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
return JSONResponse(
status_code=500,
content={
"error": {
"code": 500,
"message": "Internal Server Error",
"type": exc.__class__.__name__,
"detail": error_detail,
}
},
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_STR)
@app.get("/")
async def root() -> Dict[str, Any]:
"""
Root endpoint that returns basic service information.
"""
return {
"title": settings.PROJECT_NAME,
"description": settings.PROJECT_DESCRIPTION,
"version": settings.VERSION,
"docs_url": "/docs",
"redoc_url": "/redoc",
"openapi_url": "/openapi.json",
"health_check": "/health",
"api_base_url": settings.API_V1_STR,
}
@app.get("/health", tags=["health"])
async def health_check() -> Dict[str, Any]:
"""
Health check endpoint to verify the service is running correctly.
"""
try:
# Check database connection
from app.db.session import SessionLocal
db = SessionLocal()
db.execute("SELECT 1")
db.close()
db_status = "connected"
except Exception as e:
# Using a general exception handler with specific error handling
db_status = f"error: {e!s}"
return {
"status": "healthy",
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"version": settings.VERSION,
"database": db_status,
"environment": os.getenv("ENVIRONMENT", "development"),
}
if __name__ == "__main__":
# Using localhost for development, configurable for production
host = os.getenv("HOST", "127.0.0.1")
port = int(os.getenv("PORT", "8000"))
uvicorn.run("main:app", host=host, port=port, reload=True)

1
migrations/__init__.py Normal file
View File

@ -0,0 +1 @@
# Migrations package initialization

83
migrations/env.py Normal file
View File

@ -0,0 +1,83 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from app.db.base import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
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():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
is_sqlite = connection.dialect.name == 'sqlite'
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite, # Important for SQLite to allow column alterations
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,245 @@
"""Initial schema
Revision ID: 001
Revises:
Create Date: 2023-08-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create user table
op.create_table(
'user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('is_superuser', sa.Boolean(), nullable=True, default=False),
sa.Column('date_of_birth', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('points', sa.Integer(), nullable=True, default=0),
sa.Column('level', sa.Integer(), nullable=True, default=1),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True)
# Create subject table
op.create_table(
'subject',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('image_url', sa.String(), nullable=True),
sa.Column('order', sa.Integer(), nullable=True, default=0),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_subject_id'), 'subject', ['id'], unique=False)
op.create_index(op.f('ix_subject_name'), 'subject', ['name'], unique=False)
# Create lesson table
op.create_table(
'lesson',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('subject_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('image_url', sa.String(), nullable=True),
sa.Column('order', sa.Integer(), nullable=True, default=0),
sa.Column('difficulty', sa.Enum('easy', 'medium', 'hard', name='difficultylevel'), nullable=True),
sa.Column('points', sa.Integer(), nullable=True, default=10),
sa.Column('duration_minutes', sa.Integer(), nullable=True, default=15),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['subject_id'], ['subject.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_lesson_id'), 'lesson', ['id'], unique=False)
op.create_index(op.f('ix_lesson_title'), 'lesson', ['title'], unique=False)
# Create quiz table
op.create_table(
'quiz',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('lesson_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('time_limit_minutes', sa.Integer(), nullable=True, default=10),
sa.Column('pass_percentage', sa.Integer(), nullable=True, default=70),
sa.Column('points', sa.Integer(), nullable=True, default=20),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['lesson_id'], ['lesson.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_quiz_id'), 'quiz', ['id'], unique=False)
op.create_index(op.f('ix_quiz_title'), 'quiz', ['title'], unique=False)
# Create question table
op.create_table(
'question',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('quiz_id', sa.Integer(), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('image_url', sa.String(), nullable=True),
sa.Column('explanation', sa.Text(), nullable=True),
sa.Column('points', sa.Integer(), nullable=True, default=5),
sa.Column('order', sa.Integer(), nullable=True, default=0),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['quiz_id'], ['quiz.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_question_id'), 'question', ['id'], unique=False)
# Create answer table
op.create_table(
'answer',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('question_id', sa.Integer(), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('is_correct', sa.Boolean(), nullable=True, default=False),
sa.Column('explanation', sa.Text(), nullable=True),
sa.Column('order', sa.Integer(), nullable=True, default=0),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['question_id'], ['question.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_answer_id'), 'answer', ['id'], unique=False)
# Create achievement table
op.create_table(
'achievement',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('type', sa.Enum('completion', 'streak', 'performance', 'milestone', 'special', name='achievementtype'), nullable=False),
sa.Column('image_url', sa.String(), nullable=True),
sa.Column('points', sa.Integer(), nullable=True, default=0),
sa.Column('criteria', sa.Text(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_achievement_id'), 'achievement', ['id'], unique=False)
op.create_index(op.f('ix_achievement_name'), 'achievement', ['name'], unique=False)
# Create user_progress table
op.create_table(
'userprogress',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('lesson_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('not_started', 'in_progress', 'completed', name='progressstatus'), nullable=True),
sa.Column('progress_percentage', sa.Float(), nullable=True, default=0.0),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('points_earned', sa.Integer(), nullable=True, default=0),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['lesson_id'], ['lesson.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'lesson_id', name='user_lesson_progress_uc')
)
op.create_index(op.f('ix_userprogress_id'), 'userprogress', ['id'], unique=False)
# Create user_achievement table
op.create_table(
'userachievement',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('achievement_id', sa.Integer(), nullable=False),
sa.Column('earned_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['achievement_id'], ['achievement.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'achievement_id', name='user_achievement_uc')
)
op.create_index(op.f('ix_userachievement_id'), 'userachievement', ['id'], unique=False)
# Create user_answer table
op.create_table(
'useranswer',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('question_id', sa.Integer(), nullable=False),
sa.Column('answer_id', sa.Integer(), nullable=False),
sa.Column('is_correct', sa.Boolean(), nullable=True, default=False),
sa.Column('points_earned', sa.Integer(), nullable=True, default=0),
sa.Column('attempt_number', sa.Integer(), nullable=True, default=1),
sa.Column('time_taken_seconds', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['answer_id'], ['answer.id'], ),
sa.ForeignKeyConstraint(['question_id'], ['question.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'question_id', 'attempt_number', name='user_question_attempt_uc')
)
op.create_index(op.f('ix_useranswer_id'), 'useranswer', ['id'], unique=False)
def downgrade():
# Drop all tables in reverse order
op.drop_index(op.f('ix_useranswer_id'), table_name='useranswer')
op.drop_table('useranswer')
op.drop_index(op.f('ix_userachievement_id'), table_name='userachievement')
op.drop_table('userachievement')
op.drop_index(op.f('ix_userprogress_id'), table_name='userprogress')
op.drop_table('userprogress')
op.drop_index(op.f('ix_achievement_name'), table_name='achievement')
op.drop_index(op.f('ix_achievement_id'), table_name='achievement')
op.drop_table('achievement')
op.drop_index(op.f('ix_answer_id'), table_name='answer')
op.drop_table('answer')
op.drop_index(op.f('ix_question_id'), table_name='question')
op.drop_table('question')
op.drop_index(op.f('ix_quiz_title'), table_name='quiz')
op.drop_index(op.f('ix_quiz_id'), table_name='quiz')
op.drop_table('quiz')
op.drop_index(op.f('ix_lesson_title'), table_name='lesson')
op.drop_index(op.f('ix_lesson_id'), table_name='lesson')
op.drop_table('lesson')
op.drop_index(op.f('ix_subject_name'), table_name='subject')
op.drop_index(op.f('ix_subject_id'), table_name='subject')
op.drop_table('subject')
op.drop_index(op.f('ix_user_username'), table_name='user')
op.drop_index(op.f('ix_user_id'), table_name='user')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user')
# Drop the enum types
sa.Enum(name='difficultylevel').drop(op.get_bind(), checkfirst=False)
sa.Enum(name='achievementtype').drop(op.get_bind(), checkfirst=False)
sa.Enum(name='progressstatus').drop(op.get_bind(), checkfirst=False)

View File

@ -0,0 +1 @@
# Migrations versions package initialization

45
pyproject.toml Normal file
View File

@ -0,0 +1,45 @@
[tool.ruff]
# Enable all rules by default
lint.select = ["E", "F", "B", "I", "N", "UP", "ANN", "S", "BLE", "C4", "DTZ", "FA", "ISC", "ICN", "G", "INP", "PIE", "T20", "PYI", "PT", "Q", "RSE", "RET", "SLF", "SLOT", "SIM", "TID", "TCH", "ARG", "PTH", "TD", "FIX", "ERA", "PD", "PGH", "PL", "TRY", "FLY", "NPY", "AIR", "PERF", "FURB", "LOG", "RUF"]
# Exclude some directories
exclude = [
".git",
".venv",
"venv",
"__pycache__",
"alembic",
"migrations",
]
# Ignore specific rules
lint.ignore = [
"ANN101", # Missing type annotation for self
"ANN102", # Missing type annotation for cls
"E501", # Line too long
"PT", # Pytest-related rules, ignore if not using pytest
"INP001", # Implicit namespace package
"FA", # Flake8 annotations
"TD", # Todo related
"FIX", # Fixme related
]
# Line length
line-length = 100
# Target Python version
target-version = "py39"
# Organize imports
lint.isort.required-imports = ["from __future__ import annotations"]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # Ignore unused imports in __init__.py files
[tool.ruff.lint.mccabe]
max-complexity = 10
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "auto"

14
requirements.txt Normal file
View File

@ -0,0 +1,14 @@
fastapi>=0.100.0
uvicorn>=0.22.0
pydantic>=2.0.0
pydantic[email]>=2.0.0
sqlalchemy>=2.0.0
alembic>=1.11.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
ruff>=0.0.272
python-dotenv>=1.0.0
tenacity>=8.2.2
email-validator>=2.0.0
httpx>=0.24.1