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:
parent
2bdb5c3112
commit
53d9909fb6
183
README.md
183
README.md
@ -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
85
alembic.ini
Normal 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
1
app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Application package initialization
|
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# API package initialization
|
74
app/api/deps.py
Normal file
74
app/api/deps.py
Normal 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
1
app/api/v1/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# API v1 package initialization
|
12
app/api/v1/api.py
Normal file
12
app/api/v1/api.py
Normal 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"])
|
1
app/api/v1/endpoints/__init__.py
Normal file
1
app/api/v1/endpoints/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Endpoints module initialization
|
197
app/api/v1/endpoints/achievements.py
Normal file
197
app/api/v1/endpoints/achievements.py
Normal 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
|
80
app/api/v1/endpoints/auth.py
Normal file
80
app/api/v1/endpoints/auth.py
Normal 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
|
515
app/api/v1/endpoints/content.py
Normal file
515
app/api/v1/endpoints/content.py
Normal 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)
|
279
app/api/v1/endpoints/progress.py
Normal file
279
app/api/v1/endpoints/progress.py
Normal 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
|
129
app/api/v1/endpoints/users.py
Normal file
129
app/api/v1/endpoints/users.py
Normal 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
1
app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Core package initialization
|
51
app/core/config.py
Normal file
51
app/core/config.py
Normal 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
80
app/core/exceptions.py
Normal 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
48
app/core/middleware.py
Normal 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
38
app/core/security.py
Normal 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
1
app/db/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Database package initialization
|
9
app/db/base.py
Normal file
9
app/db/base.py
Normal 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
16
app/db/base_class.py
Normal 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
23
app/db/session.py
Normal 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
7
app/models/__init__.py
Normal 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
57
app/models/achievement.py
Normal 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
120
app/models/content.py
Normal 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
66
app/models/progress.py
Normal 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
33
app/models/user.py
Normal 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
37
app/schemas/__init__.py
Normal 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
|
68
app/schemas/achievement.py
Normal file
68
app/schemas/achievement.py
Normal 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
173
app/schemas/content.py
Normal 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
81
app/schemas/progress.py
Normal 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
15
app/schemas/token.py
Normal 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
49
app/schemas/user.py
Normal 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
1
app/services/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Services package initialization
|
176
app/services/achievement.py
Normal file
176
app/services/achievement.py
Normal 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
59
app/services/lesson.py
Normal 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
347
app/services/progress.py
Normal 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
93
app/services/question.py
Normal 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
36
app/services/quiz.py
Normal 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
33
app/services/subject.py
Normal 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
107
app/services/user.py
Normal 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
1
app/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Utils package initialization
|
79
app/utils/db.py
Normal file
79
app/utils/db.py
Normal 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
88
app/utils/validation.py
Normal 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
187
main.py
Normal 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
1
migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Migrations package initialization
|
83
migrations/env.py
Normal file
83
migrations/env.py
Normal 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
24
migrations/script.py.mako
Normal 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"}
|
245
migrations/versions/001_initial_schema.py
Normal file
245
migrations/versions/001_initial_schema.py
Normal 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)
|
1
migrations/versions/__init__.py
Normal file
1
migrations/versions/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Migrations versions package initialization
|
45
pyproject.toml
Normal file
45
pyproject.toml
Normal 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
14
requirements.txt
Normal 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
|
Loading…
x
Reference in New Issue
Block a user