diff --git a/README.md b/README.md index e8acfba..b631fb2 100644 --- a/README.md +++ b/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 + 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. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..25c0be5 --- /dev/null +++ b/alembic.ini @@ -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 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f3180e1 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Application package initialization diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..6758a96 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API package initialization diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..708777f --- /dev/null +++ b/app/api/deps.py @@ -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 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..9712b72 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1 @@ +# API v1 package initialization diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..6c1e0ab --- /dev/null +++ b/app/api/v1/api.py @@ -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"]) diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..ebaf455 --- /dev/null +++ b/app/api/v1/endpoints/__init__.py @@ -0,0 +1 @@ +# Endpoints module initialization diff --git a/app/api/v1/endpoints/achievements.py b/app/api/v1/endpoints/achievements.py new file mode 100644 index 0000000..b754a9f --- /dev/null +++ b/app/api/v1/endpoints/achievements.py @@ -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 diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..94bdbdf --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -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 diff --git a/app/api/v1/endpoints/content.py b/app/api/v1/endpoints/content.py new file mode 100644 index 0000000..1f02670 --- /dev/null +++ b/app/api/v1/endpoints/content.py @@ -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) diff --git a/app/api/v1/endpoints/progress.py b/app/api/v1/endpoints/progress.py new file mode 100644 index 0000000..d1bf6d4 --- /dev/null +++ b/app/api/v1/endpoints/progress.py @@ -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 diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..618ac75 --- /dev/null +++ b/app/api/v1/endpoints/users.py @@ -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 diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..cab174e --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# Core package initialization diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..e83a336 --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..567ce7b --- /dev/null +++ b/app/core/exceptions.py @@ -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" diff --git a/app/core/middleware.py b/app/core/middleware.py new file mode 100644 index 0000000..9f33bed --- /dev/null +++ b/app/core/middleware.py @@ -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 diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..77289ed --- /dev/null +++ b/app/core/security.py @@ -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) diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..b476390 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +# Database package initialization diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..fbbff30 --- /dev/null +++ b/app/db/base.py @@ -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 diff --git a/app/db/base_class.py b/app/db/base_class.py new file mode 100644 index 0000000..88e7dbb --- /dev/null +++ b/app/db/base_class.py @@ -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() diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..22858ed --- /dev/null +++ b/app/db/session.py @@ -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() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..02d4c13 --- /dev/null +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/achievement.py b/app/models/achievement.py new file mode 100644 index 0000000..9ae9919 --- /dev/null +++ b/app/models/achievement.py @@ -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'), + ) diff --git a/app/models/content.py b/app/models/content.py new file mode 100644 index 0000000..fa5d8cf --- /dev/null +++ b/app/models/content.py @@ -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") diff --git a/app/models/progress.py b/app/models/progress.py new file mode 100644 index 0000000..e171ff8 --- /dev/null +++ b/app/models/progress.py @@ -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'), + ) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..0e9069a --- /dev/null +++ b/app/models/user.py @@ -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) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e3f0395 --- /dev/null +++ b/app/schemas/__init__.py @@ -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 diff --git a/app/schemas/achievement.py b/app/schemas/achievement.py new file mode 100644 index 0000000..c43b91c --- /dev/null +++ b/app/schemas/achievement.py @@ -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 diff --git a/app/schemas/content.py b/app/schemas/content.py new file mode 100644 index 0000000..d1ce9a7 --- /dev/null +++ b/app/schemas/content.py @@ -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] = [] diff --git a/app/schemas/progress.py b/app/schemas/progress.py new file mode 100644 index 0000000..28191db --- /dev/null +++ b/app/schemas/progress.py @@ -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 diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..af744af --- /dev/null +++ b/app/schemas/token.py @@ -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 diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..bef2f2e --- /dev/null +++ b/app/schemas/user.py @@ -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 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..54fa46a --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Services package initialization diff --git a/app/services/achievement.py b/app/services/achievement.py new file mode 100644 index 0000000..e75a002 --- /dev/null +++ b/app/services/achievement.py @@ -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) diff --git a/app/services/lesson.py b/app/services/lesson.py new file mode 100644 index 0000000..e30c824 --- /dev/null +++ b/app/services/lesson.py @@ -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) diff --git a/app/services/progress.py b/app/services/progress.py new file mode 100644 index 0000000..28dc505 --- /dev/null +++ b/app/services/progress.py @@ -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) diff --git a/app/services/question.py b/app/services/question.py new file mode 100644 index 0000000..6b30839 --- /dev/null +++ b/app/services/question.py @@ -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) diff --git a/app/services/quiz.py b/app/services/quiz.py new file mode 100644 index 0000000..232e32d --- /dev/null +++ b/app/services/quiz.py @@ -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) diff --git a/app/services/subject.py b/app/services/subject.py new file mode 100644 index 0000000..b81902e --- /dev/null +++ b/app/services/subject.py @@ -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) diff --git a/app/services/user.py b/app/services/user.py new file mode 100644 index 0000000..c5b22ee --- /dev/null +++ b/app/services/user.py @@ -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) diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..84095a6 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +# Utils package initialization diff --git a/app/utils/db.py b/app/utils/db.py new file mode 100644 index 0000000..fc6cd63 --- /dev/null +++ b/app/utils/db.py @@ -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 diff --git a/app/utils/validation.py b/app/utils/validation.py new file mode 100644 index 0000000..2295138 --- /dev/null +++ b/app/utils/validation.py @@ -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") diff --git a/main.py b/main.py new file mode 100644 index 0000000..ab66a9f --- /dev/null +++ b/main.py @@ -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) diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..17e4857 --- /dev/null +++ b/migrations/__init__.py @@ -0,0 +1 @@ +# Migrations package initialization \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..c4f2909 --- /dev/null +++ b/migrations/env.py @@ -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() \ No newline at end of file diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1e4564e --- /dev/null +++ b/migrations/script.py.mako @@ -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"} \ No newline at end of file diff --git a/migrations/versions/001_initial_schema.py b/migrations/versions/001_initial_schema.py new file mode 100644 index 0000000..5727016 --- /dev/null +++ b/migrations/versions/001_initial_schema.py @@ -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) \ No newline at end of file diff --git a/migrations/versions/__init__.py b/migrations/versions/__init__.py new file mode 100644 index 0000000..1266868 --- /dev/null +++ b/migrations/versions/__init__.py @@ -0,0 +1 @@ +# Migrations versions package initialization \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0ee1ef1 --- /dev/null +++ b/pyproject.toml @@ -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" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2f6dee4 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file