Create Bible Quiz App API with FastAPI and SQLite

- Set up project structure with FastAPI and SQLite
- Create models for users, questions, and quizzes
- Implement Alembic migrations with seed data
- Add user authentication with JWT
- Implement question management endpoints
- Implement quiz creation and management
- Add quiz-taking and scoring functionality
- Set up API documentation and health check endpoint
- Update README with comprehensive documentation
This commit is contained in:
Automated Action 2025-06-03 15:46:44 +00:00
parent 32e5a996b2
commit 1754fec627
39 changed files with 2842 additions and 2 deletions

170
README.md
View File

@ -1,3 +1,169 @@
# FastAPI Application
# Bible Quiz App API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
A FastAPI-based REST API for a Bible Quiz application with user management, questions, and quiz features.
## Features
- **User Management**: Registration, authentication, and profile management
- **Questions Management**: Create and manage Bible-related questions with multiple-choice options
- **Quiz Management**: Create quizzes from questions with customizable settings
- **Quiz Taking**: Take quizzes and receive scores
- **Bible Data**: Pre-loaded with Bible books and categorization
## Tech Stack
- **Framework**: FastAPI (Python)
- **Database**: SQLite
- **ORM**: SQLAlchemy
- **Migrations**: Alembic
- **Authentication**: JWT (JSON Web Tokens)
- **Password Hashing**: Bcrypt
## API Documentation
The API documentation is available at:
- Swagger UI: `/docs`
- ReDoc: `/redoc`
- OpenAPI JSON: `/openapi.json`
## Setup and Installation
### Prerequisites
- Python 3.8+
- pip (Python package manager)
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd biblequizappapi
```
2. Create and activate a virtual environment (optional but recommended):
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Setup environment variables (create a `.env` file in the root directory):
```
SECRET_KEY=your_secret_key_here
ACCESS_TOKEN_EXPIRE_MINUTES=60
```
### Database Setup
1. Initialize the database and run migrations:
```bash
alembic upgrade head
```
### Running the API
1. Start the server:
```bash
uvicorn main:app --reload
```
2. The API will be available at `http://localhost:8000`
## Environment Variables
| Variable | Description | Default Value |
|----------------------------|--------------------------------------------|---------------------------|
| SECRET_KEY | Secret key for JWT token generation | "change_this_secret_key_in_production" |
| ACCESS_TOKEN_EXPIRE_MINUTES| JWT token expiration time in minutes | 60 |
## API Endpoints
### Health Check
- `GET /health` - Check API health
### Authentication
- `POST /api/v1/auth/register` - Register a new user
- `POST /api/v1/auth/login` - Login with username and password (OAuth2 form)
- `POST /api/v1/auth/login/json` - Login with username and password (JSON)
- `GET /api/v1/auth/me` - Get current user information
### Users
- `GET /api/v1/users/` - List users (admin only)
- `POST /api/v1/users/` - Create user (admin only)
- `GET /api/v1/users/{user_id}` - Get user details
- `PUT /api/v1/users/{user_id}` - Update user
### Questions
- `GET /api/v1/questions/` - List questions (with filtering options)
- `POST /api/v1/questions/` - Create question (admin only)
- `GET /api/v1/questions/{question_id}` - Get question details
- `PUT /api/v1/questions/{question_id}` - Update question (admin only)
- `DELETE /api/v1/questions/{question_id}` - Delete question (admin only)
- `GET /api/v1/questions/categories/` - List question categories
- `GET /api/v1/questions/difficulties/` - List question difficulties
- `GET /api/v1/questions/bible-books/` - List Bible books
### Quizzes
- `GET /api/v1/quizzes/` - List quizzes (public and user's own)
- `POST /api/v1/quizzes/` - Create quiz
- `GET /api/v1/quizzes/{quiz_id}` - Get quiz details
- `PUT /api/v1/quizzes/{quiz_id}` - Update quiz
- `DELETE /api/v1/quizzes/{quiz_id}` - Delete quiz
- `GET /api/v1/quizzes/attempts/` - List user's quiz attempts
- `POST /api/v1/quizzes/{quiz_id}/start` - Start a new quiz attempt
- `GET /api/v1/quizzes/attempts/{attempt_id}` - Get quiz attempt details
- `POST /api/v1/quizzes/attempts/{attempt_id}/submit/{question_id}` - Submit answer for a quiz question
- `POST /api/v1/quizzes/attempts/{attempt_id}/complete` - Complete quiz and get score
## Development
### Code Structure
```
.
├── alembic.ini # Alembic configuration file
├── app/ # Application package
│ ├── api/ # API endpoints
│ │ ├── deps.py # Dependencies (auth, etc.)
│ │ └── v1/ # API version 1
│ │ ├── api.py # API router
│ │ └── endpoints/ # Endpoint modules
│ ├── core/ # Core functionality
│ │ ├── config.py # Configuration settings
│ │ └── security.py # Security utilities
│ ├── db/ # Database
│ │ └── session.py # DB session management
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ └── services/ # Business logic
├── migrations/ # Alembic migrations
├── main.py # Application entry point
└── requirements.txt # Project dependencies
```
### Running Tests
```bash
pytest
```
## License
[MIT License](LICENSE)

85
alembic.ini Normal file
View File

@ -0,0 +1,85 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# SQLite URL
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

0
app/__init__.py Normal file
View File

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

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

@ -0,0 +1,73 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import ALGORITHM
from app.db.session import get_db
from app.models.user import User
from app.schemas.token import TokenPayload
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),
) -> User:
"""
Get current user from JWT token
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[ALGORITHM]
)
token_data = TokenPayload(**payload)
except (JWTError, ValidationError):
raise credentials_exception
user = db.query(User).filter(User.id == token_data.sub).first()
if not user:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
return user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> 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_admin(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get current active admin user
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges",
)
return current_user

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

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

@ -0,0 +1,10 @@
from fastapi import APIRouter
from app.api.v1.endpoints import users, questions, quizzes, auth
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(questions.router, prefix="/questions", tags=["Questions"])
api_router.include_router(quizzes.router, prefix="/quizzes", tags=["Quizzes"])

View File

View File

@ -0,0 +1,118 @@
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.api.deps import get_current_user
from app.core.config import settings
from app.core.security import create_access_token
from app.db.session import get_db
from app.models.user import User
from app.schemas.auth import Login
from app.schemas.token import Token
from app.schemas.user import User as UserSchema, UserCreate
from app.services import user as user_service
router = APIRouter()
@router.post("/login", response_model=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, username=form_data.username, password=form_data.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username 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",
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": create_access_token(
user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}
@router.post("/login/json", response_model=Token)
def login_access_token_json(
login_data: Login,
db: Session = Depends(get_db),
) -> Any:
"""
JSON-based login, get an access token for future requests
"""
user = user_service.authenticate(
db, username=login_data.username, password=login_data.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username 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",
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": create_access_token(
user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}
@router.post("/register", response_model=UserSchema)
def register_user(
user_in: UserCreate,
db: Session = Depends(get_db),
) -> 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.get("/me", response_model=UserSchema)
def read_users_me(
current_user: User = Depends(get_current_user),
) -> Any:
"""
Get current user
"""
return current_user

View File

@ -0,0 +1,179 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_active_admin, get_current_active_user
from app.db.session import get_db
from app.models.user import User
from app.schemas.bible_book import BibleBook, Testament
from app.schemas.question import (
Question,
QuestionCategory,
QuestionCreate,
QuestionDifficulty,
QuestionUpdate,
)
from app.services import question as question_service
router = APIRouter()
@router.get("/", response_model=List[Question])
def read_questions(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
category_id: Optional[int] = None,
difficulty_id: Optional[int] = None,
bible_book_id: Optional[int] = None,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve questions with optional filtering.
"""
questions = question_service.get_multi(
db,
skip=skip,
limit=limit,
category_id=category_id,
difficulty_id=difficulty_id,
bible_book_id=bible_book_id,
)
return questions
@router.post("/", response_model=Question)
def create_question(
*,
db: Session = Depends(get_db),
question_in: QuestionCreate,
current_user: User = Depends(get_current_active_admin),
) -> Any:
"""
Create new question with options. Admin only.
"""
# Validate that at least one option is marked as correct
if not any(option.is_correct for option in question_in.options):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one option must be marked as correct",
)
question = question_service.create(db, obj_in=question_in)
return question
@router.get("/{question_id}", response_model=Question)
def read_question(
*,
db: Session = Depends(get_db),
question_id: int,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Get question by ID.
"""
question = question_service.get_by_id(db, question_id=question_id)
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found",
)
return question
@router.put("/{question_id}", response_model=Question)
def update_question(
*,
db: Session = Depends(get_db),
question_id: int,
question_in: QuestionUpdate,
current_user: User = Depends(get_current_active_admin),
) -> Any:
"""
Update a question. Admin only.
"""
question = question_service.get_by_id(db, question_id=question_id)
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found",
)
# If options are being updated, validate that at least one is correct
if question_in.options:
# Identify which options have is_correct field set
options_with_is_correct = [
opt for opt in question_in.options
if opt.is_correct is not None
]
# If any options have is_correct field set, at least one must be True
if options_with_is_correct and not any(opt.is_correct for opt in options_with_is_correct):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one option must be marked as correct",
)
question = question_service.update(db, db_obj=question, obj_in=question_in)
return question
@router.delete("/{question_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_question(
*,
db: Session = Depends(get_db),
question_id: int,
current_user: User = Depends(get_current_active_admin),
) -> Any:
"""
Delete a question. Admin only.
"""
question = question_service.get_by_id(db, question_id=question_id)
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found",
)
question_service.remove(db, question_id=question_id)
return None
# Endpoints for categories, difficulties, and Bible books
@router.get("/categories/", response_model=List[QuestionCategory])
def read_categories(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve all question categories.
"""
return question_service.get_all_categories(db)
@router.get("/difficulties/", response_model=List[QuestionDifficulty])
def read_difficulties(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve all question difficulties.
"""
return question_service.get_all_difficulties(db)
@router.get("/bible-books/", response_model=List[BibleBook])
def read_bible_books(
db: Session = Depends(get_db),
testament: Optional[Testament] = None,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve all Bible books, optionally filtered by testament.
"""
if testament:
return question_service.get_bible_books_by_testament(db, testament=testament.value)
return question_service.get_all_bible_books(db)

View File

@ -0,0 +1,319 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_active_user
from app.db.session import get_db
from app.models.user import User
from app.schemas.quiz import (
Quiz,
QuizAttempt,
QuizCreate,
QuizSubmitAnswer,
QuizUpdate,
QuizWithoutDetails,
)
from app.services import quiz as quiz_service
router = APIRouter()
@router.get("/", response_model=List[QuizWithoutDetails])
def read_quizzes(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve all quizzes accessible to the current user.
Includes all public quizzes and the user's own quizzes.
"""
# Get public quizzes
public_quizzes = quiz_service.get_public_quizzes(db, skip=skip, limit=limit)
# Get user's quizzes
user_quizzes = quiz_service.get_user_quizzes(db, user_id=current_user.id, skip=skip, limit=limit)
# Combine and deduplicate (using dict comprehension)
quizzes = {quiz.id: quiz for quiz in public_quizzes + user_quizzes}.values()
# Add question count
result = []
for quiz in quizzes:
setattr(quiz, "question_count", len(quiz.questions))
result.append(quiz)
return result
@router.post("/", response_model=Quiz)
def create_quiz(
*,
db: Session = Depends(get_db),
quiz_in: QuizCreate,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Create new quiz.
"""
quiz = quiz_service.create(db, obj_in=quiz_in, user_id=current_user.id)
return quiz
@router.get("/{quiz_id}", response_model=Quiz)
def read_quiz(
*,
db: Session = Depends(get_db),
quiz_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Get quiz by ID.
"""
quiz = quiz_service.get_by_id(db, quiz_id=quiz_id)
if not quiz:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz not found",
)
# Check if the user can access this quiz
if not quiz.is_public and quiz.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to access this quiz",
)
return quiz
@router.put("/{quiz_id}", response_model=Quiz)
def update_quiz(
*,
db: Session = Depends(get_db),
quiz_id: int = Path(..., gt=0),
quiz_in: QuizUpdate,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Update a quiz.
"""
quiz = quiz_service.get_by_id(db, quiz_id=quiz_id)
if not quiz:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz not found",
)
# Only the quiz owner can update it
if quiz.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to update this quiz",
)
quiz = quiz_service.update(db, db_obj=quiz, obj_in=quiz_in)
return quiz
@router.delete("/{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: User = Depends(get_current_active_user),
) -> Any:
"""
Delete a quiz.
"""
quiz = quiz_service.get_by_id(db, quiz_id=quiz_id)
if not quiz:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz not found",
)
# Only the quiz owner can delete it
if quiz.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to delete this quiz",
)
quiz_service.remove(db, quiz_id=quiz_id)
return None
# Quiz attempt endpoints
@router.get("/attempts/", response_model=List[QuizAttempt])
def read_user_attempts(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
quiz_id: Optional[int] = None,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve user's quiz attempts.
"""
attempts = quiz_service.get_user_attempts(
db,
user_id=current_user.id,
quiz_id=quiz_id,
skip=skip,
limit=limit,
)
return attempts
@router.post("/{quiz_id}/start", response_model=QuizAttempt)
def start_quiz_attempt(
*,
db: Session = Depends(get_db),
quiz_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Start a new quiz attempt.
"""
quiz = quiz_service.get_by_id(db, quiz_id=quiz_id)
if not quiz:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz not found",
)
# Check if the user can access this quiz
if not quiz.is_public and quiz.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to access this quiz",
)
attempt = quiz_service.create_attempt(
db,
obj_in={"quiz_id": quiz_id, "status": "started"},
user_id=current_user.id,
)
return attempt
@router.get("/attempts/{attempt_id}", response_model=QuizAttempt)
def read_quiz_attempt(
*,
db: Session = Depends(get_db),
attempt_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Get quiz attempt by ID.
"""
attempt = quiz_service.get_attempt_by_id(db, attempt_id=attempt_id)
if not attempt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz attempt not found",
)
# Only the attempt owner can access it
if attempt.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to access this quiz attempt",
)
return attempt
@router.post("/attempts/{attempt_id}/submit/{question_id}", response_model=dict)
def submit_quiz_answer(
*,
db: Session = Depends(get_db),
attempt_id: int = Path(..., gt=0),
question_id: int = Path(..., gt=0),
answer_in: QuizSubmitAnswer,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Submit an answer for a quiz question.
"""
attempt = quiz_service.get_attempt_by_id(db, attempt_id=attempt_id)
if not attempt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz attempt not found",
)
# Only the attempt owner can submit answers
if attempt.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to submit answers for this attempt",
)
# Check if the attempt is still in progress
if attempt.status != "started":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot submit answers for an attempt with status '{attempt.status}'",
)
answer = quiz_service.submit_answer(
db,
attempt_id=attempt_id,
question_id=question_id,
selected_option_id=answer_in.selected_option_id,
)
if not answer:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid question or option ID",
)
return {"status": "success", "is_correct": answer.is_correct}
@router.post("/attempts/{attempt_id}/complete", response_model=dict)
def complete_quiz_attempt(
*,
db: Session = Depends(get_db),
attempt_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Complete a quiz attempt and calculate the final score.
"""
attempt = quiz_service.get_attempt_by_id(db, attempt_id=attempt_id)
if not attempt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz attempt not found",
)
# Only the attempt owner can complete it
if attempt.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to complete this attempt",
)
# Check if the attempt is still in progress
if attempt.status != "started":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot complete an attempt with status '{attempt.status}'",
)
attempt, score = quiz_service.complete_attempt(db, attempt_id=attempt_id)
return {
"status": "completed",
"score": score,
"passed": score >= attempt.quiz.pass_percentage,
"pass_percentage": attempt.quiz.pass_percentage,
}

View File

@ -0,0 +1,117 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_active_admin, get_current_active_user
from app.db.session import get_db
from app.models.user import User
from app.schemas.user import User as UserSchema
from app.schemas.user import UserCreate, UserUpdate
from app.services import user as user_service
router = APIRouter()
@router.get("/", response_model=List[UserSchema])
def read_users(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_admin),
) -> Any:
"""
Retrieve users. Admin only.
"""
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.post("/", response_model=UserSchema)
def create_user(
*,
db: Session = Depends(get_db),
user_in: UserCreate,
current_user: User = Depends(get_current_active_admin),
) -> Any:
"""
Create new user. Admin only.
"""
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.get("/{user_id}", response_model=UserSchema)
def read_user_by_id(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Get a specific user by id.
"""
user = user_service.get_by_id(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Regular users can only see their own profile
if user.id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to access this user",
)
return user
@router.put("/{user_id}", response_model=UserSchema)
def update_user(
*,
db: Session = Depends(get_db),
user_id: int,
user_in: UserUpdate,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Update a user.
"""
user = user_service.get_by_id(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Regular users can only update their own profile
# Admins can change is_admin status
if user.id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to update this user",
)
if not current_user.is_admin and user_in.is_admin is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Regular users can't change admin status",
)
user = user_service.update(db, db_obj=user, obj_in=user_in)
return user

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

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

@ -0,0 +1,33 @@
import os
from pathlib import Path
from pydantic import BaseSettings, validator
class Settings(BaseSettings):
# Base settings
API_V1_STR: str = "/api/v1"
PROJECT_NAME: str = "Bible Quiz App API"
PROJECT_DESCRIPTION: str = "API for a Bible Quiz application with questions, quizzes, and user management"
VERSION: str = "0.1.0"
# Security settings
SECRET_KEY: str = os.getenv("SECRET_KEY", "change_this_secret_key_in_production")
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
# Database settings
DB_DIR: Path = Path("/app/storage/db")
@validator("DB_DIR", pre=True)
def create_db_dir(cls, v: Path) -> Path:
v.mkdir(parents=True, exist_ok=True)
return v
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

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

@ -0,0 +1,42 @@
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def create_access_token(
subject: Union[str, Any], expires_delta: Optional[timedelta] = None
) -> str:
"""
Create 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=ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify password against hash
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash password
"""
return pwd_context.hash(password)

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

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

@ -0,0 +1,25 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(
settings.SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""
Dependency for getting DB session
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1,32 @@
# Import models for Alembic discovery
# These imports are used by Alembic for migrations
# isort: off
from app.models.user import User
from app.models.question import (
BibleBook,
Question,
QuestionCategory,
QuestionDifficulty,
QuestionOption,
)
from app.models.quiz import (
Quiz,
QuizAttempt,
QuizQuestion,
QuizQuestionAnswer,
)
# Define which models should be available when importing from app.models
__all__ = [
"User",
"BibleBook",
"Question",
"QuestionCategory",
"QuestionDifficulty",
"QuestionOption",
"Quiz",
"QuizAttempt",
"QuizQuestion",
"QuizQuestionAnswer",
]

73
app/models/question.py Normal file
View File

@ -0,0 +1,73 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.db.session import Base
class BibleBook(Base):
__tablename__ = "bible_books"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
testament = Column(Enum("Old", "New", name="testament_enum"), nullable=False)
# Relationships
questions = relationship("Question", back_populates="bible_book")
class QuestionDifficulty(Base):
__tablename__ = "question_difficulties"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False) # Easy, Medium, Hard
description = Column(String)
# Relationships
questions = relationship("Question", back_populates="difficulty")
class QuestionCategory(Base):
__tablename__ = "question_categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(String)
# Relationships
questions = relationship("Question", back_populates="category")
class Question(Base):
__tablename__ = "questions"
id = Column(Integer, primary_key=True, index=True)
text = Column(Text, nullable=False)
bible_reference = Column(String) # Format: Book Chapter:Verse, e.g., "John 3:16"
explanation = Column(Text)
bible_book_id = Column(Integer, ForeignKey("bible_books.id"))
difficulty_id = Column(Integer, ForeignKey("question_difficulties.id"))
category_id = Column(Integer, ForeignKey("question_categories.id"))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
bible_book = relationship("BibleBook", back_populates="questions")
difficulty = relationship("QuestionDifficulty", back_populates="questions")
category = relationship("QuestionCategory", back_populates="questions")
options = relationship("QuestionOption", back_populates="question", cascade="all, delete-orphan")
quiz_questions = relationship("QuizQuestion", back_populates="question")
class QuestionOption(Base):
__tablename__ = "question_options"
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("questions.id"), nullable=False)
text = Column(Text, nullable=False)
is_correct = Column(Boolean, default=False, nullable=False)
explanation = Column(Text)
# Relationships
question = relationship("Question", back_populates="options")
user_answers = relationship("QuizQuestionAnswer", back_populates="selected_option")

71
app/models/quiz.py Normal file
View File

@ -0,0 +1,71 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.db.session import Base
class Quiz(Base):
__tablename__ = "quizzes"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
description = Column(Text)
user_id = Column(Integer, ForeignKey("users.id"))
is_public = Column(Boolean, default=False)
time_limit_minutes = Column(Integer, default=10)
pass_percentage = Column(Float, default=70.0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="quizzes")
quiz_questions = relationship("QuizQuestion", back_populates="quiz", cascade="all, delete-orphan")
quiz_attempts = relationship("QuizAttempt", back_populates="quiz")
class QuizQuestion(Base):
__tablename__ = "quiz_questions"
id = Column(Integer, primary_key=True, index=True)
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
question_id = Column(Integer, ForeignKey("questions.id"), nullable=False)
position = Column(Integer, default=0) # For ordering questions
# Relationships
quiz = relationship("Quiz", back_populates="quiz_questions")
question = relationship("Question", back_populates="quiz_questions")
answers = relationship("QuizQuestionAnswer", back_populates="quiz_question")
class QuizAttempt(Base):
__tablename__ = "quiz_attempts"
id = Column(Integer, primary_key=True, index=True)
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
status = Column(Enum("started", "completed", "abandoned", name="attempt_status_enum"), default="started")
score = Column(Float)
started_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime)
# Relationships
quiz = relationship("Quiz", back_populates="quiz_attempts")
user = relationship("User", back_populates="quiz_attempts")
answers = relationship("QuizQuestionAnswer", back_populates="quiz_attempt", cascade="all, delete-orphan")
class QuizQuestionAnswer(Base):
__tablename__ = "quiz_question_answers"
id = Column(Integer, primary_key=True, index=True)
quiz_attempt_id = Column(Integer, ForeignKey("quiz_attempts.id"), nullable=False)
quiz_question_id = Column(Integer, ForeignKey("quiz_questions.id"), nullable=False)
selected_option_id = Column(Integer, ForeignKey("question_options.id"))
is_correct = Column(Boolean)
answered_at = Column(DateTime, default=datetime.utcnow)
# Relationships
quiz_attempt = relationship("QuizAttempt", back_populates="answers")
quiz_question = relationship("QuizQuestion", back_populates="answers")
selected_option = relationship("QuestionOption", back_populates="user_answers")

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

@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
from app.db.session import Base
class User(Base):
__tablename__ = "users"
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)
hashed_password = Column(String, nullable=False)
full_name = Column(String)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
quizzes = relationship("Quiz", back_populates="user")
quiz_attempts = relationship("QuizAttempt", back_populates="user")

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

6
app/schemas/auth.py Normal file
View File

@ -0,0 +1,6 @@
from pydantic import BaseModel
class Login(BaseModel):
username: str
password: str

34
app/schemas/bible_book.py Normal file
View File

@ -0,0 +1,34 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel
class Testament(str, Enum):
old = "Old"
new = "New"
class BibleBookBase(BaseModel):
name: str
testament: Testament
class BibleBookCreate(BibleBookBase):
pass
class BibleBookUpdate(BibleBookBase):
name: Optional[str] = None
testament: Optional[Testament] = None
class BibleBookInDBBase(BibleBookBase):
id: int
class Config:
orm_mode = True
class BibleBook(BibleBookInDBBase):
pass

115
app/schemas/question.py Normal file
View File

@ -0,0 +1,115 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from app.schemas.bible_book import BibleBook
class QuestionDifficultyBase(BaseModel):
name: str
description: Optional[str] = None
class QuestionDifficultyCreate(QuestionDifficultyBase):
pass
class QuestionDifficultyUpdate(QuestionDifficultyBase):
name: Optional[str] = None
class QuestionDifficultyInDBBase(QuestionDifficultyBase):
id: int
class Config:
orm_mode = True
class QuestionDifficulty(QuestionDifficultyInDBBase):
pass
class QuestionCategoryBase(BaseModel):
name: str
description: Optional[str] = None
class QuestionCategoryCreate(QuestionCategoryBase):
pass
class QuestionCategoryUpdate(QuestionCategoryBase):
name: Optional[str] = None
class QuestionCategoryInDBBase(QuestionCategoryBase):
id: int
class Config:
orm_mode = True
class QuestionCategory(QuestionCategoryInDBBase):
pass
class QuestionOptionBase(BaseModel):
text: str
is_correct: bool = False
explanation: Optional[str] = None
class QuestionOptionCreate(QuestionOptionBase):
pass
class QuestionOptionUpdate(QuestionOptionBase):
text: Optional[str] = None
is_correct: Optional[bool] = None
class QuestionOptionInDBBase(QuestionOptionBase):
id: int
question_id: int
class Config:
orm_mode = True
class QuestionOption(QuestionOptionInDBBase):
pass
class QuestionBase(BaseModel):
text: str
bible_reference: Optional[str] = None
explanation: Optional[str] = None
bible_book_id: Optional[int] = None
difficulty_id: Optional[int] = None
category_id: Optional[int] = None
class QuestionCreate(QuestionBase):
options: List[QuestionOptionCreate] = Field(..., min_items=2)
class QuestionUpdate(QuestionBase):
text: Optional[str] = None
options: Optional[List[QuestionOptionUpdate]] = None
class QuestionInDBBase(QuestionBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class Question(QuestionInDBBase):
bible_book: Optional[BibleBook] = None
difficulty: Optional[QuestionDifficulty] = None
category: Optional[QuestionCategory] = None
options: List[QuestionOption] = []

158
app/schemas/quiz.py Normal file
View File

@ -0,0 +1,158 @@
from datetime import datetime
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field, validator
from app.schemas.question import Question
from app.schemas.user import User
class QuizQuestionBase(BaseModel):
question_id: int
position: int = 0
class QuizQuestionCreate(QuizQuestionBase):
pass
class QuizQuestionUpdate(QuizQuestionBase):
question_id: Optional[int] = None
position: Optional[int] = None
class QuizQuestionInDBBase(QuizQuestionBase):
id: int
quiz_id: int
class Config:
orm_mode = True
class QuizQuestion(QuizQuestionInDBBase):
question: Optional[Question] = None
class QuizBase(BaseModel):
title: str
description: Optional[str] = None
is_public: bool = False
time_limit_minutes: int = 10
pass_percentage: float = 70.0
class QuizCreate(QuizBase):
questions: List[QuizQuestionCreate] = Field(..., min_items=1)
@validator("questions")
def validate_unique_questions(cls, v):
question_ids = [q.question_id for q in v]
if len(question_ids) != len(set(question_ids)):
raise ValueError("Quiz cannot contain duplicate questions")
return v
class QuizUpdate(QuizBase):
title: Optional[str] = None
description: Optional[str] = None
is_public: Optional[bool] = None
time_limit_minutes: Optional[int] = None
pass_percentage: Optional[float] = None
questions: Optional[List[QuizQuestionCreate]] = None
@validator("questions")
def validate_unique_questions(cls, v):
if v is not None:
question_ids = [q.question_id for q in v]
if len(question_ids) != len(set(question_ids)):
raise ValueError("Quiz cannot contain duplicate questions")
return v
class QuizInDBBase(QuizBase):
id: int
user_id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class Quiz(QuizInDBBase):
user: Optional[User] = None
questions: List[QuizQuestion] = []
class QuizWithoutDetails(QuizInDBBase):
user: Optional[User] = None
question_count: int
class AttemptStatus(str, Enum):
started = "started"
completed = "completed"
abandoned = "abandoned"
class QuizAnswerBase(BaseModel):
quiz_question_id: int
selected_option_id: Optional[int] = None
class QuizAnswerCreate(QuizAnswerBase):
pass
class QuizAnswerUpdate(QuizAnswerBase):
selected_option_id: Optional[int] = None
class QuizAnswerInDBBase(QuizAnswerBase):
id: int
quiz_attempt_id: int
is_correct: Optional[bool] = None
answered_at: datetime
class Config:
orm_mode = True
class QuizAnswer(QuizAnswerInDBBase):
pass
class QuizAttemptBase(BaseModel):
quiz_id: int
status: AttemptStatus = AttemptStatus.started
score: Optional[float] = None
started_at: datetime = Field(default_factory=datetime.utcnow)
completed_at: Optional[datetime] = None
class QuizAttemptCreate(QuizAttemptBase):
pass
class QuizAttemptUpdate(QuizAttemptBase):
status: Optional[AttemptStatus] = None
score: Optional[float] = None
completed_at: Optional[datetime] = None
class QuizAttemptInDBBase(QuizAttemptBase):
id: int
user_id: int
class Config:
orm_mode = True
class QuizAttempt(QuizAttemptInDBBase):
quiz: Optional[Quiz] = None
answers: List[QuizAnswer] = []
class QuizSubmitAnswer(BaseModel):
selected_option_id: int

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

@ -0,0 +1,16 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
sub: Optional[int] = None
class TokenData(BaseModel):
username: Optional[str] = None

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

@ -0,0 +1,44 @@
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_admin: bool = False
full_name: Optional[str] = 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
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
orm_mode = True
# Additional properties to return via API
class User(UserInDBBase):
pass
# Additional properties stored in DB
class UserInDB(UserInDBBase):
hashed_password: str

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

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

@ -0,0 +1,153 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.question import (
BibleBook,
Question,
QuestionCategory,
QuestionDifficulty,
QuestionOption,
)
from app.schemas.question import (
QuestionCreate,
QuestionUpdate,
)
def get_by_id(db: Session, question_id: int) -> Optional[Question]:
"""
Get question by ID
"""
return db.query(Question).filter(Question.id == question_id).first()
def get_multi(
db: Session,
*,
skip: int = 0,
limit: int = 100,
category_id: Optional[int] = None,
difficulty_id: Optional[int] = None,
bible_book_id: Optional[int] = None,
) -> List[Question]:
"""
Get multiple questions with optional filters
"""
query = db.query(Question)
if category_id is not None:
query = query.filter(Question.category_id == category_id)
if difficulty_id is not None:
query = query.filter(Question.difficulty_id == difficulty_id)
if bible_book_id is not None:
query = query.filter(Question.bible_book_id == bible_book_id)
return query.offset(skip).limit(limit).all()
def create(db: Session, *, obj_in: QuestionCreate) -> Question:
"""
Create new question with options
"""
# Extract options from input
options_data = obj_in.options
obj_in_dict = obj_in.dict(exclude={"options"})
# Create question
db_obj = Question(**obj_in_dict)
db.add(db_obj)
db.flush() # Flush to get the ID without committing
# Create options for the question
for option_data in options_data:
option = QuestionOption(
question_id=db_obj.id,
**option_data.dict(),
)
db.add(option)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
db: Session,
*,
db_obj: Question,
obj_in: QuestionUpdate
) -> Question:
"""
Update question and options
"""
# Update question fields
update_data = obj_in.dict(exclude={"options"}, exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
# Update options if provided
if obj_in.options is not None:
# Update existing options or create new ones
existing_options = {opt.id: opt for opt in db_obj.options}
for option_data in obj_in.options:
if hasattr(option_data, "id") and option_data.id in existing_options:
# Update existing option
option = existing_options[option_data.id]
for field, value in option_data.dict(exclude_unset=True).items():
setattr(option, field, value)
else:
# Create new option
option = QuestionOption(
question_id=db_obj.id,
**option_data.dict(exclude={"id"}, exclude_unset=True),
)
db.add(option)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def remove(db: Session, *, question_id: int) -> None:
"""
Delete question (and its options via cascade)
"""
question = db.query(Question).filter(Question.id == question_id).first()
if question:
db.delete(question)
db.commit()
# Functions for managing categories, difficulties, and bible books
def get_all_categories(db: Session) -> List[QuestionCategory]:
"""
Get all question categories
"""
return db.query(QuestionCategory).all()
def get_all_difficulties(db: Session) -> List[QuestionDifficulty]:
"""
Get all question difficulties
"""
return db.query(QuestionDifficulty).all()
def get_all_bible_books(db: Session) -> List[BibleBook]:
"""
Get all Bible books
"""
return db.query(BibleBook).all()
def get_bible_books_by_testament(db: Session, testament: str) -> List[BibleBook]:
"""
Get Bible books by testament (Old or New)
"""
return db.query(BibleBook).filter(BibleBook.testament == testament).all()

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

@ -0,0 +1,331 @@
from datetime import datetime
from typing import List, Optional, Tuple
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from app.models.quiz import (
Quiz,
QuizAttempt,
QuizQuestion,
QuizQuestionAnswer,
)
from app.schemas.quiz import (
AttemptStatus,
QuizAttemptCreate,
QuizAttemptUpdate,
QuizCreate,
QuizUpdate,
)
def get_by_id(db: Session, quiz_id: int) -> Optional[Quiz]:
"""
Get quiz by ID
"""
return (
db.query(Quiz)
.options(joinedload(Quiz.questions).joinedload(QuizQuestion.question))
.filter(Quiz.id == quiz_id)
.first()
)
def get_multi(
db: Session,
*,
user_id: Optional[int] = None,
is_public: Optional[bool] = None,
skip: int = 0,
limit: int = 100,
) -> List[Quiz]:
"""
Get multiple quizzes with optional filters
"""
query = db.query(Quiz)
if user_id is not None:
query = query.filter(Quiz.user_id == user_id)
if is_public is not None:
query = query.filter(Quiz.is_public == is_public)
return query.offset(skip).limit(limit).all()
def get_public_quizzes(
db: Session,
*,
skip: int = 0,
limit: int = 100,
) -> List[Quiz]:
"""
Get public quizzes
"""
return get_multi(db, is_public=True, skip=skip, limit=limit)
def get_user_quizzes(
db: Session,
*,
user_id: int,
skip: int = 0,
limit: int = 100,
) -> List[Quiz]:
"""
Get quizzes created by a specific user
"""
return get_multi(db, user_id=user_id, skip=skip, limit=limit)
def create(db: Session, *, obj_in: QuizCreate, user_id: int) -> Quiz:
"""
Create new quiz with questions
"""
# Extract questions from input
questions_data = obj_in.questions
obj_in_dict = obj_in.dict(exclude={"questions"})
# Create quiz
db_obj = Quiz(user_id=user_id, **obj_in_dict)
db.add(db_obj)
db.flush() # Flush to get the ID without committing
# Create questions for the quiz
for question_data in questions_data:
question = QuizQuestion(
quiz_id=db_obj.id,
**question_data.dict(),
)
db.add(question)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
db: Session,
*,
db_obj: Quiz,
obj_in: QuizUpdate
) -> Quiz:
"""
Update quiz and questions
"""
# Update quiz fields
update_data = obj_in.dict(exclude={"questions"}, exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
# Update questions if provided
if obj_in.questions is not None:
# Remove existing questions
db.query(QuizQuestion).filter(QuizQuestion.quiz_id == db_obj.id).delete()
# Create new questions
for question_data in obj_in.questions:
question = QuizQuestion(
quiz_id=db_obj.id,
**question_data.dict(),
)
db.add(question)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def remove(db: Session, *, quiz_id: int) -> None:
"""
Delete quiz (and its questions via cascade)
"""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if quiz:
db.delete(quiz)
db.commit()
# Quiz attempt functions
def get_attempt_by_id(db: Session, attempt_id: int) -> Optional[QuizAttempt]:
"""
Get quiz attempt by ID
"""
return (
db.query(QuizAttempt)
.options(
joinedload(QuizAttempt.quiz),
joinedload(QuizAttempt.answers)
)
.filter(QuizAttempt.id == attempt_id)
.first()
)
def get_user_attempts(
db: Session,
*,
user_id: int,
quiz_id: Optional[int] = None,
skip: int = 0,
limit: int = 100,
) -> List[QuizAttempt]:
"""
Get quiz attempts by a specific user
"""
query = db.query(QuizAttempt).filter(QuizAttempt.user_id == user_id)
if quiz_id is not None:
query = query.filter(QuizAttempt.quiz_id == quiz_id)
return query.order_by(QuizAttempt.started_at.desc()).offset(skip).limit(limit).all()
def create_attempt(
db: Session,
*,
obj_in: QuizAttemptCreate,
user_id: int
) -> QuizAttempt:
"""
Create a new quiz attempt
"""
db_obj = QuizAttempt(
user_id=user_id,
**obj_in.dict(),
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update_attempt(
db: Session,
*,
db_obj: QuizAttempt,
obj_in: QuizAttemptUpdate
) -> QuizAttempt:
"""
Update a quiz attempt
"""
update_data = obj_in.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def submit_answer(
db: Session,
*,
attempt_id: int,
question_id: int,
selected_option_id: int,
) -> Optional[QuizQuestionAnswer]:
"""
Submit an answer for a quiz question
"""
# Get the quiz question
quiz_question = (
db.query(QuizQuestion)
.join(Quiz)
.join(QuizAttempt)
.filter(
QuizAttempt.id == attempt_id,
QuizQuestion.question_id == question_id,
)
.first()
)
if not quiz_question:
return None
# Check if an answer already exists
existing_answer = (
db.query(QuizQuestionAnswer)
.filter(
QuizQuestionAnswer.quiz_attempt_id == attempt_id,
QuizQuestionAnswer.quiz_question_id == quiz_question.id,
)
.first()
)
if existing_answer:
# Update existing answer
existing_answer.selected_option_id = selected_option_id
existing_answer.answered_at = datetime.utcnow()
db.add(existing_answer)
db.commit()
db.refresh(existing_answer)
return existing_answer
# Check if the selected option belongs to the question
from app.models.question import QuestionOption
option = (
db.query(QuestionOption)
.join(QuizQuestion.question)
.filter(
QuestionOption.id == selected_option_id,
QuizQuestion.id == quiz_question.id,
)
.first()
)
if not option:
return None
# Create new answer
answer = QuizQuestionAnswer(
quiz_attempt_id=attempt_id,
quiz_question_id=quiz_question.id,
selected_option_id=selected_option_id,
is_correct=option.is_correct,
)
db.add(answer)
db.commit()
db.refresh(answer)
return answer
def complete_attempt(db: Session, *, attempt_id: int) -> Tuple[QuizAttempt, float]:
"""
Complete a quiz attempt and calculate score
"""
attempt = get_attempt_by_id(db, attempt_id=attempt_id)
if not attempt:
return None, 0
# Calculate the score
total_questions = (
db.query(func.count(QuizQuestion.id))
.filter(QuizQuestion.quiz_id == attempt.quiz_id)
.scalar()
)
correct_answers = (
db.query(func.count(QuizQuestionAnswer.id))
.filter(
QuizQuestionAnswer.quiz_attempt_id == attempt_id,
QuizQuestionAnswer.is_correct == True, # noqa: E712
)
.scalar()
)
# Calculate score as percentage
score = (correct_answers / total_questions) * 100 if total_questions > 0 else 0
# Update the attempt
attempt.status = AttemptStatus.completed
attempt.score = score
attempt.completed_at = datetime.utcnow()
db.add(attempt)
db.commit()
db.refresh(attempt)
return attempt, score

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

@ -0,0 +1,91 @@
from typing import Optional
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
def get_by_email(db: Session, email: str) -> Optional[User]:
"""
Get user by email
"""
return db.query(User).filter(User.email == email).first()
def get_by_username(db: Session, username: str) -> Optional[User]:
"""
Get user by username
"""
return db.query(User).filter(User.username == username).first()
def get_by_id(db: Session, user_id: int) -> Optional[User]:
"""
Get user by ID
"""
return db.query(User).filter(User.id == user_id).first()
def create(db: Session, obj_in: UserCreate) -> User:
"""
Create 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,
is_active=obj_in.is_active,
is_admin=obj_in.is_admin,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(db: Session, db_obj: User, obj_in: UserUpdate) -> User:
"""
Update user
"""
update_data = obj_in.dict(exclude_unset=True)
if update_data.get("password"):
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
for field, value in update_data.items():
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def authenticate(db: Session, username: str, password: str) -> Optional[User]:
"""
Authenticate user by username and password
"""
user = get_by_username(db, username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active(user: User) -> bool:
"""
Check if user is active
"""
return user.is_active
def is_admin(user: User) -> bool:
"""
Check if user is admin
"""
return user.is_admin

52
main.py Normal file
View File

@ -0,0 +1,52 @@
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from app.api.v1.api import api_router
from app.core.config import settings
app = FastAPI(
title=settings.PROJECT_NAME,
description=settings.PROJECT_DESCRIPTION,
version=settings.VERSION,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
)
# Set up CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix="/api/v1")
# Health check endpoint
@app.get("/health", tags=["Health"])
async def health_check():
return {"status": "ok"}
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=settings.PROJECT_NAME,
version=settings.VERSION,
description=settings.PROJECT_DESCRIPTION,
routes=app.routes,
)
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration with SQLite.

87
migrations/env.py Normal file
View File

@ -0,0 +1,87 @@
from __future__ import with_statement
import os
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Add the parent directory to the Python path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
# Import models and database connection
from app.db.session import Base
from app.models import * # noqa
# 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
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, # Key configuration for SQLite
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

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

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

View File

@ -0,0 +1,205 @@
"""Initial tables
Revision ID: 001
Revises:
Create Date: 2023-07-15 14:20:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create users table
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('is_admin', sa.Boolean(), nullable=True, default=False),
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_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
# Create bible_books table
op.create_table(
'bible_books',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('testament', sa.Enum('Old', 'New', name='testament_enum'), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_bible_books_id'), 'bible_books', ['id'], unique=False)
op.create_index(op.f('ix_bible_books_name'), 'bible_books', ['name'], unique=False)
# Create question_difficulties table
op.create_table(
'question_difficulties',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_question_difficulties_id'), 'question_difficulties', ['id'], unique=False)
op.create_index(op.f('ix_question_difficulties_name'), 'question_difficulties', ['name'], unique=True)
# Create question_categories table
op.create_table(
'question_categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_question_categories_id'), 'question_categories', ['id'], unique=False)
op.create_index(op.f('ix_question_categories_name'), 'question_categories', ['name'], unique=True)
# Create questions table
op.create_table(
'questions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('bible_reference', sa.String(), nullable=True),
sa.Column('explanation', sa.Text(), nullable=True),
sa.Column('bible_book_id', sa.Integer(), nullable=True),
sa.Column('difficulty_id', sa.Integer(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['bible_book_id'], ['bible_books.id'], ),
sa.ForeignKeyConstraint(['category_id'], ['question_categories.id'], ),
sa.ForeignKeyConstraint(['difficulty_id'], ['question_difficulties.id'], ),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_questions_id'), 'questions', ['id'], unique=False)
# Create quizzes table
op.create_table(
'quizzes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('is_public', sa.Boolean(), nullable=True, default=False),
sa.Column('time_limit_minutes', sa.Integer(), nullable=True, default=10),
sa.Column('pass_percentage', sa.Float(), nullable=True, default=70.0),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_quizzes_id'), 'quizzes', ['id'], unique=False)
# Create question_options table
op.create_table(
'question_options',
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=False, default=False),
sa.Column('explanation', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_question_options_id'), 'question_options', ['id'], unique=False)
# Create quiz_questions table
op.create_table(
'quiz_questions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('quiz_id', sa.Integer(), nullable=False),
sa.Column('question_id', sa.Integer(), nullable=False),
sa.Column('position', sa.Integer(), nullable=True, default=0),
sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ),
sa.ForeignKeyConstraint(['quiz_id'], ['quizzes.id'], ),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_quiz_questions_id'), 'quiz_questions', ['id'], unique=False)
# Create quiz_attempts table
op.create_table(
'quiz_attempts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('quiz_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('started', 'completed', 'abandoned', name='attempt_status_enum'), nullable=True, default='started'),
sa.Column('score', sa.Float(), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['quiz_id'], ['quizzes.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_quiz_attempts_id'), 'quiz_attempts', ['id'], unique=False)
# Create quiz_question_answers table
op.create_table(
'quiz_question_answers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('quiz_attempt_id', sa.Integer(), nullable=False),
sa.Column('quiz_question_id', sa.Integer(), nullable=False),
sa.Column('selected_option_id', sa.Integer(), nullable=True),
sa.Column('is_correct', sa.Boolean(), nullable=True),
sa.Column('answered_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['quiz_attempt_id'], ['quiz_attempts.id'], ),
sa.ForeignKeyConstraint(['quiz_question_id'], ['quiz_questions.id'], ),
sa.ForeignKeyConstraint(['selected_option_id'], ['question_options.id'], ),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_quiz_question_answers_id'), 'quiz_question_answers', ['id'], unique=False)
def downgrade():
# Drop all tables in reverse order
op.drop_index(op.f('ix_quiz_question_answers_id'), table_name='quiz_question_answers')
op.drop_table('quiz_question_answers')
op.drop_index(op.f('ix_quiz_attempts_id'), table_name='quiz_attempts')
op.drop_table('quiz_attempts')
op.drop_index(op.f('ix_quiz_questions_id'), table_name='quiz_questions')
op.drop_table('quiz_questions')
op.drop_index(op.f('ix_question_options_id'), table_name='question_options')
op.drop_table('question_options')
op.drop_index(op.f('ix_quizzes_id'), table_name='quizzes')
op.drop_table('quizzes')
op.drop_index(op.f('ix_questions_id'), table_name='questions')
op.drop_table('questions')
op.drop_index(op.f('ix_question_categories_name'), table_name='question_categories')
op.drop_index(op.f('ix_question_categories_id'), table_name='question_categories')
op.drop_table('question_categories')
op.drop_index(op.f('ix_question_difficulties_name'), table_name='question_difficulties')
op.drop_index(op.f('ix_question_difficulties_id'), table_name='question_difficulties')
op.drop_table('question_difficulties')
op.drop_index(op.f('ix_bible_books_name'), table_name='bible_books')
op.drop_index(op.f('ix_bible_books_id'), table_name='bible_books')
op.drop_table('bible_books')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# Drop enum types
op.execute("DROP TYPE testament_enum")
op.execute("DROP TYPE attempt_status_enum")

View File

@ -0,0 +1,147 @@
"""Seed initial data
Revision ID: 002
Revises: 001
Create Date: 2023-07-15 14:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
# revision identifiers, used by Alembic.
revision = '002'
down_revision = '001'
branch_labels = None
depends_on = None
def upgrade():
# Seed bible books data
bible_books = table(
'bible_books',
column('id', sa.Integer),
column('name', sa.String),
column('testament', sa.Enum('Old', 'New', name='testament_enum')),
)
op.bulk_insert(
bible_books,
[
# Old Testament books
{'id': 1, 'name': 'Genesis', 'testament': 'Old'},
{'id': 2, 'name': 'Exodus', 'testament': 'Old'},
{'id': 3, 'name': 'Leviticus', 'testament': 'Old'},
{'id': 4, 'name': 'Numbers', 'testament': 'Old'},
{'id': 5, 'name': 'Deuteronomy', 'testament': 'Old'},
{'id': 6, 'name': 'Joshua', 'testament': 'Old'},
{'id': 7, 'name': 'Judges', 'testament': 'Old'},
{'id': 8, 'name': 'Ruth', 'testament': 'Old'},
{'id': 9, 'name': '1 Samuel', 'testament': 'Old'},
{'id': 10, 'name': '2 Samuel', 'testament': 'Old'},
{'id': 11, 'name': '1 Kings', 'testament': 'Old'},
{'id': 12, 'name': '2 Kings', 'testament': 'Old'},
{'id': 13, 'name': '1 Chronicles', 'testament': 'Old'},
{'id': 14, 'name': '2 Chronicles', 'testament': 'Old'},
{'id': 15, 'name': 'Ezra', 'testament': 'Old'},
{'id': 16, 'name': 'Nehemiah', 'testament': 'Old'},
{'id': 17, 'name': 'Esther', 'testament': 'Old'},
{'id': 18, 'name': 'Job', 'testament': 'Old'},
{'id': 19, 'name': 'Psalms', 'testament': 'Old'},
{'id': 20, 'name': 'Proverbs', 'testament': 'Old'},
{'id': 21, 'name': 'Ecclesiastes', 'testament': 'Old'},
{'id': 22, 'name': 'Song of Solomon', 'testament': 'Old'},
{'id': 23, 'name': 'Isaiah', 'testament': 'Old'},
{'id': 24, 'name': 'Jeremiah', 'testament': 'Old'},
{'id': 25, 'name': 'Lamentations', 'testament': 'Old'},
{'id': 26, 'name': 'Ezekiel', 'testament': 'Old'},
{'id': 27, 'name': 'Daniel', 'testament': 'Old'},
{'id': 28, 'name': 'Hosea', 'testament': 'Old'},
{'id': 29, 'name': 'Joel', 'testament': 'Old'},
{'id': 30, 'name': 'Amos', 'testament': 'Old'},
{'id': 31, 'name': 'Obadiah', 'testament': 'Old'},
{'id': 32, 'name': 'Jonah', 'testament': 'Old'},
{'id': 33, 'name': 'Micah', 'testament': 'Old'},
{'id': 34, 'name': 'Nahum', 'testament': 'Old'},
{'id': 35, 'name': 'Habakkuk', 'testament': 'Old'},
{'id': 36, 'name': 'Zephaniah', 'testament': 'Old'},
{'id': 37, 'name': 'Haggai', 'testament': 'Old'},
{'id': 38, 'name': 'Zechariah', 'testament': 'Old'},
{'id': 39, 'name': 'Malachi', 'testament': 'Old'},
# New Testament books
{'id': 40, 'name': 'Matthew', 'testament': 'New'},
{'id': 41, 'name': 'Mark', 'testament': 'New'},
{'id': 42, 'name': 'Luke', 'testament': 'New'},
{'id': 43, 'name': 'John', 'testament': 'New'},
{'id': 44, 'name': 'Acts', 'testament': 'New'},
{'id': 45, 'name': 'Romans', 'testament': 'New'},
{'id': 46, 'name': '1 Corinthians', 'testament': 'New'},
{'id': 47, 'name': '2 Corinthians', 'testament': 'New'},
{'id': 48, 'name': 'Galatians', 'testament': 'New'},
{'id': 49, 'name': 'Ephesians', 'testament': 'New'},
{'id': 50, 'name': 'Philippians', 'testament': 'New'},
{'id': 51, 'name': 'Colossians', 'testament': 'New'},
{'id': 52, 'name': '1 Thessalonians', 'testament': 'New'},
{'id': 53, 'name': '2 Thessalonians', 'testament': 'New'},
{'id': 54, 'name': '1 Timothy', 'testament': 'New'},
{'id': 55, 'name': '2 Timothy', 'testament': 'New'},
{'id': 56, 'name': 'Titus', 'testament': 'New'},
{'id': 57, 'name': 'Philemon', 'testament': 'New'},
{'id': 58, 'name': 'Hebrews', 'testament': 'New'},
{'id': 59, 'name': 'James', 'testament': 'New'},
{'id': 60, 'name': '1 Peter', 'testament': 'New'},
{'id': 61, 'name': '2 Peter', 'testament': 'New'},
{'id': 62, 'name': '1 John', 'testament': 'New'},
{'id': 63, 'name': '2 John', 'testament': 'New'},
{'id': 64, 'name': '3 John', 'testament': 'New'},
{'id': 65, 'name': 'Jude', 'testament': 'New'},
{'id': 66, 'name': 'Revelation', 'testament': 'New'},
]
)
# Seed question difficulties
difficulties = table(
'question_difficulties',
column('id', sa.Integer),
column('name', sa.String),
column('description', sa.String),
)
op.bulk_insert(
difficulties,
[
{'id': 1, 'name': 'Easy', 'description': 'Basic knowledge questions suitable for beginners'},
{'id': 2, 'name': 'Medium', 'description': 'Intermediate level questions requiring good Bible knowledge'},
{'id': 3, 'name': 'Hard', 'description': 'Advanced questions for those with deep Biblical understanding'},
]
)
# Seed question categories
categories = table(
'question_categories',
column('id', sa.Integer),
column('name', sa.String),
column('description', sa.String),
)
op.bulk_insert(
categories,
[
{'id': 1, 'name': 'People', 'description': 'Questions about Biblical characters'},
{'id': 2, 'name': 'Places', 'description': 'Questions about geographical locations in the Bible'},
{'id': 3, 'name': 'Events', 'description': 'Questions about significant events in the Bible'},
{'id': 4, 'name': 'Teachings', 'description': 'Questions about Biblical teachings and doctrines'},
{'id': 5, 'name': 'Prophecies', 'description': 'Questions about Biblical prophecies'},
{'id': 6, 'name': 'Parables', 'description': 'Questions about parables in the Bible'},
{'id': 7, 'name': 'Miracles', 'description': 'Questions about miracles in the Bible'},
]
)
def downgrade():
# Remove seeded data
op.execute("DELETE FROM question_categories")
op.execute("DELETE FROM question_difficulties")
op.execute("DELETE FROM bible_books")

10
requirements.txt Normal file
View File

@ -0,0 +1,10 @@
fastapi>=0.68.0,<0.69.0
pydantic>=1.8.0,<2.0.0
uvicorn>=0.15.0,<0.16.0
SQLAlchemy>=1.4.0,<1.5.0
alembic>=1.7.0,<1.8.0
python-jose[cryptography]>=3.3.0,<3.4.0
passlib[bcrypt]>=1.7.4,<1.8.0
python-multipart>=0.0.5,<0.0.6
email-validator>=1.1.3,<1.2.0
ruff>=0.0.270,<0.1.0