From 6f0470e4751142a288ddbcb6f8c4d9cbcd6f94fe Mon Sep 17 00:00:00 2001 From: Automated Action Date: Thu, 5 Jun 2025 10:31:02 +0000 Subject: [PATCH] Implement Bible Quiz App API with FastAPI and SQLite - Setup project structure with FastAPI app - Create SQLAlchemy models for categories, questions, quizzes, and results - Implement API endpoints for all CRUD operations - Set up Alembic migrations for database schema management - Add comprehensive documentation in README.md --- README.md | 138 +++++++++++++++++- alembic.ini | 102 +++++++++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/v1/__init__.py | 0 app/api/v1/endpoints/__init__.py | 0 app/api/v1/endpoints/categories.py | 43 ++++++ app/api/v1/endpoints/questions.py | 49 +++++++ app/api/v1/endpoints/quizzes.py | 48 ++++++ app/api/v1/endpoints/results.py | 32 ++++ app/api/v1/router.py | 9 ++ app/core/__init__.py | 0 app/core/config.py | 25 ++++ app/db/__init__.py | 0 app/db/session.py | 25 ++++ app/models/__init__.py | 8 + app/models/category.py | 16 ++ app/models/question.py | 30 ++++ app/models/quiz.py | 23 +++ app/models/result.py | 21 +++ app/schemas/__init__.py | 13 ++ app/schemas/category.py | 18 +++ app/schemas/question.py | 35 +++++ app/schemas/quiz.py | 40 +++++ app/schemas/result.py | 24 +++ app/services/__init__.py | 0 app/services/category.py | 45 ++++++ app/services/question.py | 60 ++++++++ app/services/quiz.py | 82 +++++++++++ app/services/result.py | 29 ++++ main.py | 34 +++++ migrations/README | 13 ++ migrations/env.py | 88 +++++++++++ migrations/script.py.mako | 24 +++ .../54a3fedbad84_initial_migration.py | 98 +++++++++++++ requirements.txt | 10 ++ 36 files changed, 1180 insertions(+), 2 deletions(-) create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/endpoints/__init__.py create mode 100644 app/api/v1/endpoints/categories.py create mode 100644 app/api/v1/endpoints/questions.py create mode 100644 app/api/v1/endpoints/quizzes.py create mode 100644 app/api/v1/endpoints/results.py create mode 100644 app/api/v1/router.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/db/__init__.py create mode 100644 app/db/session.py create mode 100644 app/models/__init__.py create mode 100644 app/models/category.py create mode 100644 app/models/question.py create mode 100644 app/models/quiz.py create mode 100644 app/models/result.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/category.py create mode 100644 app/schemas/question.py create mode 100644 app/schemas/quiz.py create mode 100644 app/schemas/result.py create mode 100644 app/services/__init__.py create mode 100644 app/services/category.py create mode 100644 app/services/question.py create mode 100644 app/services/quiz.py create mode 100644 app/services/result.py create mode 100644 main.py create mode 100644 migrations/README create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/54a3fedbad84_initial_migration.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index e8acfba..88bec3b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,137 @@ -# FastAPI Application +# Bible Quiz App API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A RESTful API for a Bible quiz application built with FastAPI and SQLite. + +## Features + +- Comprehensive Bible quiz management system +- Categories management (e.g., Old Testament, New Testament, etc.) +- Questions and answers with difficulty levels and Bible references +- Quiz creation and management +- User quiz results tracking +- RESTful API with full CRUD operations +- SQLite database with SQLAlchemy ORM +- Alembic migrations for database versioning + +## API Endpoints + +### Health Check +- `GET /health`: Check if the API is running properly + +### Categories +- `GET /api/v1/categories`: Get all categories +- `GET /api/v1/categories/{category_id}`: Get a specific category +- `POST /api/v1/categories`: Create a new category +- `PUT /api/v1/categories/{category_id}`: Update a category +- `DELETE /api/v1/categories/{category_id}`: Delete a category + +### Questions +- `GET /api/v1/questions`: Get all questions (with optional filtering) +- `GET /api/v1/questions/{question_id}`: Get a specific question +- `POST /api/v1/questions`: Create a new question +- `PUT /api/v1/questions/{question_id}`: Update a question +- `DELETE /api/v1/questions/{question_id}`: Delete a question + +### Quizzes +- `GET /api/v1/quizzes`: Get all quizzes (with optional filtering) +- `GET /api/v1/quizzes/{quiz_id}`: Get a specific quiz with its questions +- `POST /api/v1/quizzes`: Create a new quiz +- `PUT /api/v1/quizzes/{quiz_id}`: Update a quiz +- `DELETE /api/v1/quizzes/{quiz_id}`: Delete a quiz + +### Results +- `GET /api/v1/results`: Get all quiz results +- `GET /api/v1/results/{result_id}`: Get a specific result +- `GET /api/v1/results/user/{user_id}`: Get all results for a specific user +- `POST /api/v1/results`: Create a new quiz result + +## Prerequisites + +- Python 3.8+ +- pip (Python package installer) + +## Installation + +1. Clone the repository: + ``` + git clone https://github.com/yourusername/biblequizappapi.git + cd biblequizappapi + ``` + +2. Install the required packages: + ``` + pip install -r requirements.txt + ``` + +3. Run database migrations: + ``` + alembic upgrade head + ``` + +4. Start the application: + ``` + uvicorn main:app --reload + ``` + +## Environment Variables + +The application uses the following environment variables: + +- `SECRET_KEY`: Secret key for tokens (defaults to "insecuresecretkey" if not provided) +- `ACCESS_TOKEN_EXPIRE_MINUTES`: Token expiration time in minutes (defaults to 10080 - 7 days) + +## API Documentation + +Once the application is running, you can access the Swagger UI documentation at: + +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` +- OpenAPI JSON: `http://localhost:8000/openapi.json` + +## Development + +### Project Structure + +``` +. +├── alembic.ini # Alembic configuration +├── app # Application package +│ ├── api # API endpoints +│ │ └── v1 # API version 1 +│ │ ├── endpoints # API endpoint modules +│ │ └── router.py # API router +│ ├── core # Core modules +│ │ └── config.py # Configuration settings +│ ├── db # Database modules +│ │ └── session.py # Database session +│ ├── models # SQLAlchemy models +│ ├── schemas # Pydantic schemas +│ └── services # Business logic services +├── main.py # Application entry point +├── migrations # Alembic migrations +│ ├── env.py # Alembic environment +│ ├── README # Migrations readme +│ ├── script.py.mako # Migration script template +│ └── versions # Migration versions +└── requirements.txt # Project dependencies +``` + +### Adding New Features + +1. Create/update SQLAlchemy models in `app/models/` +2. Create/update Pydantic schemas in `app/schemas/` +3. Create/update service functions in `app/services/` +4. Create/update API endpoints in `app/api/v1/endpoints/` +5. Add routes to the router in `app/api/v1/router.py` +6. Generate new migration with Alembic if needed: + ``` + alembic revision --autogenerate -m "your_migration_description" + ``` +7. Apply migrations: + ``` + alembic upgrade head + ``` + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..468a308 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,102 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# 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. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; The path separator used here should not be the +# same as the one specified for %%version_locations unless directory names +# in %%version_locations rely on this separator to distinguish directories. +# (This is really only useful for versions_locations that follow a recursive +# directory structure, which is not a common use case.) +# version_path_separator = : + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# SQLite URL example +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 REVISION_SCRIPT_FILENAME + +# 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..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/categories.py b/app/api/v1/endpoints/categories.py new file mode 100644 index 0000000..ddc378a --- /dev/null +++ b/app/api/v1/endpoints/categories.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from app.db.session import get_db +from app.schemas.category import CategoryCreate, CategoryResponse +from app.services.category import create_category, get_category, get_categories, update_category, delete_category + +router = APIRouter() + +@router.post("/", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED) +def create_category_endpoint(category_data: CategoryCreate, db: Session = Depends(get_db)): + """Create a new category""" + return create_category(db=db, category_data=category_data) + +@router.get("/{category_id}", response_model=CategoryResponse) +def get_category_endpoint(category_id: int, db: Session = Depends(get_db)): + """Get a specific category by ID""" + db_category = get_category(db=db, category_id=category_id) + if db_category is None: + raise HTTPException(status_code=404, detail="Category not found") + return db_category + +@router.get("/", response_model=List[CategoryResponse]) +def get_categories_endpoint(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """Get all categories""" + return get_categories(db=db, skip=skip, limit=limit) + +@router.put("/{category_id}", response_model=CategoryResponse) +def update_category_endpoint(category_id: int, category_data: CategoryCreate, db: Session = Depends(get_db)): + """Update a category""" + db_category = update_category(db=db, category_id=category_id, category_data=category_data) + if db_category is None: + raise HTTPException(status_code=404, detail="Category not found") + return db_category + +@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def delete_category_endpoint(category_id: int, db: Session = Depends(get_db)): + """Delete a category""" + success = delete_category(db=db, category_id=category_id) + if not success: + raise HTTPException(status_code=404, detail="Category not found") + return None \ No newline at end of file diff --git a/app/api/v1/endpoints/questions.py b/app/api/v1/endpoints/questions.py new file mode 100644 index 0000000..be86002 --- /dev/null +++ b/app/api/v1/endpoints/questions.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Optional + +from app.db.session import get_db +from app.schemas.question import QuestionCreate, QuestionResponse, QuestionUpdate +from app.services.question import create_question, get_question, get_questions, update_question, delete_question + +router = APIRouter() + +@router.post("/", response_model=QuestionResponse, status_code=status.HTTP_201_CREATED) +def create_question_endpoint(question_data: QuestionCreate, db: Session = Depends(get_db)): + """Create a new question""" + return create_question(db=db, question_data=question_data) + +@router.get("/{question_id}", response_model=QuestionResponse) +def get_question_endpoint(question_id: int, db: Session = Depends(get_db)): + """Get a specific question by ID""" + db_question = get_question(db=db, question_id=question_id) + if db_question is None: + raise HTTPException(status_code=404, detail="Question not found") + return db_question + +@router.get("/", response_model=List[QuestionResponse]) +def get_questions_endpoint( + skip: int = 0, + limit: int = 100, + category_id: Optional[int] = None, + difficulty: Optional[str] = None, + db: Session = Depends(get_db) +): + """Get all questions with optional filtering""" + return get_questions(db=db, skip=skip, limit=limit, category_id=category_id, difficulty=difficulty) + +@router.put("/{question_id}", response_model=QuestionResponse) +def update_question_endpoint(question_id: int, question_data: QuestionUpdate, db: Session = Depends(get_db)): + """Update a question""" + db_question = update_question(db=db, question_id=question_id, question_data=question_data) + if db_question is None: + raise HTTPException(status_code=404, detail="Question not found") + return db_question + +@router.delete("/{question_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def delete_question_endpoint(question_id: int, db: Session = Depends(get_db)): + """Delete a question""" + success = delete_question(db=db, question_id=question_id) + if not success: + raise HTTPException(status_code=404, detail="Question not found") + return None \ No newline at end of file diff --git a/app/api/v1/endpoints/quizzes.py b/app/api/v1/endpoints/quizzes.py new file mode 100644 index 0000000..ed910c7 --- /dev/null +++ b/app/api/v1/endpoints/quizzes.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Optional + +from app.db.session import get_db +from app.schemas.quiz import QuizCreate, QuizResponse, QuizWithQuestionsResponse, QuizUpdate +from app.services.quiz import create_quiz, get_quizzes, update_quiz, delete_quiz, get_quiz_with_questions + +router = APIRouter() + +@router.post("/", response_model=QuizResponse, status_code=status.HTTP_201_CREATED) +def create_quiz_endpoint(quiz_data: QuizCreate, db: Session = Depends(get_db)): + """Create a new quiz""" + return create_quiz(db=db, quiz_data=quiz_data) + +@router.get("/{quiz_id}", response_model=QuizWithQuestionsResponse) +def get_quiz_endpoint(quiz_id: int, db: Session = Depends(get_db)): + """Get a specific quiz by ID including its questions""" + db_quiz = get_quiz_with_questions(db=db, quiz_id=quiz_id) + if db_quiz is None: + raise HTTPException(status_code=404, detail="Quiz not found") + return db_quiz + +@router.get("/", response_model=List[QuizResponse]) +def get_quizzes_endpoint( + skip: int = 0, + limit: int = 100, + category_id: Optional[int] = None, + db: Session = Depends(get_db) +): + """Get all quizzes with optional filtering""" + return get_quizzes(db=db, skip=skip, limit=limit, category_id=category_id) + +@router.put("/{quiz_id}", response_model=QuizResponse) +def update_quiz_endpoint(quiz_id: int, quiz_data: QuizUpdate, db: Session = Depends(get_db)): + """Update a quiz""" + db_quiz = update_quiz(db=db, quiz_id=quiz_id, quiz_data=quiz_data) + if db_quiz is None: + raise HTTPException(status_code=404, detail="Quiz not found") + return db_quiz + +@router.delete("/{quiz_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def delete_quiz_endpoint(quiz_id: int, db: Session = Depends(get_db)): + """Delete a quiz""" + success = delete_quiz(db=db, quiz_id=quiz_id) + if not success: + raise HTTPException(status_code=404, detail="Quiz not found") + return None \ No newline at end of file diff --git a/app/api/v1/endpoints/results.py b/app/api/v1/endpoints/results.py new file mode 100644 index 0000000..3090d1c --- /dev/null +++ b/app/api/v1/endpoints/results.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from app.db.session import get_db +from app.schemas.result import ResultCreate, ResultResponse +from app.services.result import create_result, get_result, get_results, get_user_results + +router = APIRouter() + +@router.post("/", response_model=ResultResponse, status_code=status.HTTP_201_CREATED) +def create_result_endpoint(result_data: ResultCreate, db: Session = Depends(get_db)): + """Create a new quiz result""" + return create_result(db=db, result_data=result_data) + +@router.get("/{result_id}", response_model=ResultResponse) +def get_result_endpoint(result_id: int, db: Session = Depends(get_db)): + """Get a specific result by ID""" + db_result = get_result(db=db, result_id=result_id) + if db_result is None: + raise HTTPException(status_code=404, detail="Result not found") + return db_result + +@router.get("/", response_model=List[ResultResponse]) +def get_results_endpoint(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """Get all results""" + return get_results(db=db, skip=skip, limit=limit) + +@router.get("/user/{user_id}", response_model=List[ResultResponse]) +def get_user_results_endpoint(user_id: str, db: Session = Depends(get_db)): + """Get all results for a specific user""" + return get_user_results(db=db, user_id=user_id) \ No newline at end of file diff --git a/app/api/v1/router.py b/app/api/v1/router.py new file mode 100644 index 0000000..0e1176e --- /dev/null +++ b/app/api/v1/router.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter +from app.api.v1.endpoints import categories, questions, quizzes, results + +api_router = APIRouter(prefix="/api/v1") + +api_router.include_router(categories.router, prefix="/categories", tags=["Categories"]) +api_router.include_router(questions.router, prefix="/questions", tags=["Questions"]) +api_router.include_router(quizzes.router, prefix="/quizzes", tags=["Quizzes"]) +api_router.include_router(results.router, prefix="/results", tags=["Results"]) \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..a5e41fe --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,25 @@ +import os +from pathlib import Path +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + PROJECT_NAME: str = "Bible Quiz App API" + API_V1_STR: str = "/api/v1" + + # Database settings + DB_DIR: Path = Path("/app") / "storage" / "db" + + # Secret key for tokens + SECRET_KEY: str = os.environ.get("SECRET_KEY", "insecuresecretkey") + + # Token expiration time in minutes + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + +settings = Settings() + +# Ensure database directory exists +settings.DB_DIR.mkdir(parents=True, exist_ok=True) \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..aef355d --- /dev/null +++ b/app/db/session.py @@ -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 + +# Ensure database directory exists +settings.DB_DIR.mkdir(parents=True, exist_ok=True) + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{settings.DB_DIR}/db.sqlite" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..ab75856 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,8 @@ +"""Models package initialization.""" + +__all__ = ["Category", "Question", "question_quiz", "Quiz", "Result"] + +from app.models.category import Category +from app.models.question import Question, question_quiz +from app.models.quiz import Quiz +from app.models.result import Result \ No newline at end of file diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..51e9373 --- /dev/null +++ b/app/models/category.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.db.session import Base + + +class Category(Base): + __tablename__ = "categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), unique=True, index=True, nullable=False) + description = Column(Text, nullable=True) + + # Relationships + questions = relationship("Question", back_populates="category", cascade="all, delete-orphan") + quizzes = relationship("Quiz", back_populates="category", cascade="all, delete-orphan") \ No newline at end of file diff --git a/app/models/question.py b/app/models/question.py new file mode 100644 index 0000000..c7dc7bb --- /dev/null +++ b/app/models/question.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table +from sqlalchemy.orm import relationship + +from app.db.session import Base + +# Association table for many-to-many relationship between questions and quizzes +question_quiz = Table( + "question_quiz", + Base.metadata, + Column("question_id", Integer, ForeignKey("questions.id"), primary_key=True), + Column("quiz_id", Integer, ForeignKey("quizzes.id"), primary_key=True) +) + + +class Question(Base): + __tablename__ = "questions" + + id = Column(Integer, primary_key=True, index=True) + text = Column(Text, nullable=False) + answer = Column(String(255), nullable=False) + option1 = Column(String(255), nullable=True) + option2 = Column(String(255), nullable=True) + option3 = Column(String(255), nullable=True) + difficulty = Column(String(20), nullable=True) # easy, medium, hard + reference = Column(String(100), nullable=True) # Bible reference (e.g., "John 3:16") + category_id = Column(Integer, ForeignKey("categories.id")) + + # Relationships + category = relationship("Category", back_populates="questions") + quizzes = relationship("Quiz", secondary=question_quiz, back_populates="questions") \ No newline at end of file diff --git a/app/models/quiz.py b/app/models/quiz.py new file mode 100644 index 0000000..7a5a90c --- /dev/null +++ b/app/models/quiz.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship + +from app.db.session import Base +from app.models.question import question_quiz + + +class Quiz(Base): + __tablename__ = "quizzes" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + category_id = Column(Integer, ForeignKey("categories.id")) + difficulty = Column(String(20), nullable=True) # easy, medium, hard + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + category = relationship("Category", back_populates="quizzes") + questions = relationship("Question", secondary=question_quiz, back_populates="quizzes") + results = relationship("Result", back_populates="quiz", cascade="all, delete-orphan") \ No newline at end of file diff --git a/app/models/result.py b/app/models/result.py new file mode 100644 index 0000000..3e23f8a --- /dev/null +++ b/app/models/result.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Float +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship + +from app.db.session import Base + + +class Result(Base): + __tablename__ = "results" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(String(100), index=True, nullable=False) # External user ID + quiz_id = Column(Integer, ForeignKey("quizzes.id")) + score = Column(Float, nullable=False) # Percentage score + total_questions = Column(Integer, nullable=False) + correct_answers = Column(Integer, nullable=False) + time_taken = Column(Float, nullable=True) # Time taken in seconds + completed_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + quiz = relationship("Quiz", back_populates="results") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..7a0fed2 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,13 @@ +"""Schemas package initialization.""" + +__all__ = [ + "CategoryCreate", "CategoryResponse", + "QuestionCreate", "QuestionResponse", "QuestionUpdate", + "QuizCreate", "QuizResponse", "QuizWithQuestionsResponse", "QuizUpdate", + "ResultCreate", "ResultResponse" +] + +from app.schemas.category import CategoryCreate, CategoryResponse +from app.schemas.question import QuestionCreate, QuestionResponse, QuestionUpdate +from app.schemas.quiz import QuizCreate, QuizResponse, QuizWithQuestionsResponse, QuizUpdate +from app.schemas.result import ResultCreate, ResultResponse \ No newline at end of file diff --git a/app/schemas/category.py b/app/schemas/category.py new file mode 100644 index 0000000..83b1cb0 --- /dev/null +++ b/app/schemas/category.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class CategoryBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100, description="Name of the category") + description: Optional[str] = Field(None, description="Description of the category") + + +class CategoryCreate(CategoryBase): + pass + + +class CategoryResponse(CategoryBase): + id: int + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/question.py b/app/schemas/question.py new file mode 100644 index 0000000..b72e334 --- /dev/null +++ b/app/schemas/question.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class QuestionBase(BaseModel): + text: str = Field(..., min_length=1, description="Question text") + answer: str = Field(..., min_length=1, max_length=255, description="Correct answer") + option1: Optional[str] = Field(None, max_length=255, description="First option for multiple choice") + option2: Optional[str] = Field(None, max_length=255, description="Second option for multiple choice") + option3: Optional[str] = Field(None, max_length=255, description="Third option for multiple choice") + difficulty: Optional[str] = Field(None, max_length=20, description="Difficulty level (easy, medium, hard)") + reference: Optional[str] = Field(None, max_length=100, description="Bible reference (e.g., 'John 3:16')") + category_id: int = Field(..., description="ID of the category this question belongs to") + + +class QuestionCreate(QuestionBase): + pass + + +class QuestionUpdate(BaseModel): + text: Optional[str] = Field(None, min_length=1, description="Question text") + answer: Optional[str] = Field(None, min_length=1, max_length=255, description="Correct answer") + option1: Optional[str] = Field(None, max_length=255, description="First option for multiple choice") + option2: Optional[str] = Field(None, max_length=255, description="Second option for multiple choice") + option3: Optional[str] = Field(None, max_length=255, description="Third option for multiple choice") + difficulty: Optional[str] = Field(None, max_length=20, description="Difficulty level (easy, medium, hard)") + reference: Optional[str] = Field(None, max_length=100, description="Bible reference (e.g., 'John 3:16')") + category_id: Optional[int] = Field(None, description="ID of the category this question belongs to") + + +class QuestionResponse(QuestionBase): + id: int + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/quiz.py b/app/schemas/quiz.py new file mode 100644 index 0000000..c3e2664 --- /dev/null +++ b/app/schemas/quiz.py @@ -0,0 +1,40 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + +from app.schemas.question import QuestionResponse + + +class QuizBase(BaseModel): + title: str = Field(..., min_length=1, max_length=100, description="Title of the quiz") + description: Optional[str] = Field(None, description="Description of the quiz") + category_id: int = Field(..., description="ID of the category this quiz belongs to") + difficulty: Optional[str] = Field(None, max_length=20, description="Difficulty level (easy, medium, hard)") + + +class QuizCreate(QuizBase): + question_ids: List[int] = Field(..., description="IDs of questions to include in the quiz") + + +class QuizUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=100, description="Title of the quiz") + description: Optional[str] = Field(None, description="Description of the quiz") + category_id: Optional[int] = Field(None, description="ID of the category this quiz belongs to") + difficulty: Optional[str] = Field(None, max_length=20, description="Difficulty level (easy, medium, hard)") + question_ids: Optional[List[int]] = Field(None, description="IDs of questions to include in the quiz") + + +class QuizResponse(QuizBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class QuizWithQuestionsResponse(QuizResponse): + questions: List[QuestionResponse] + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/result.py b/app/schemas/result.py new file mode 100644 index 0000000..800dec9 --- /dev/null +++ b/app/schemas/result.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + + +class ResultBase(BaseModel): + user_id: str = Field(..., min_length=1, max_length=100, description="User ID") + quiz_id: int = Field(..., description="ID of the quiz taken") + score: float = Field(..., ge=0, le=100, description="Percentage score (0-100)") + total_questions: int = Field(..., gt=0, description="Total number of questions in the quiz") + correct_answers: int = Field(..., ge=0, description="Number of questions answered correctly") + time_taken: Optional[float] = Field(None, ge=0, description="Time taken to complete the quiz in seconds") + + +class ResultCreate(ResultBase): + pass + + +class ResultResponse(ResultBase): + id: int + completed_at: datetime + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/category.py b/app/services/category.py new file mode 100644 index 0000000..d876947 --- /dev/null +++ b/app/services/category.py @@ -0,0 +1,45 @@ +from sqlalchemy.orm import Session +from typing import List, Optional + +from app.models.category import Category +from app.schemas.category import CategoryCreate + + +def create_category(db: Session, category_data: CategoryCreate) -> Category: + """Create a new category.""" + db_category = Category(**category_data.model_dump()) + db.add(db_category) + db.commit() + db.refresh(db_category) + return db_category + + +def get_category(db: Session, category_id: int) -> Optional[Category]: + """Get a category by ID.""" + return db.query(Category).filter(Category.id == category_id).first() + + +def get_categories(db: Session, skip: int = 0, limit: int = 100) -> List[Category]: + """Get all categories with pagination.""" + return db.query(Category).offset(skip).limit(limit).all() + + +def update_category(db: Session, category_id: int, category_data: CategoryCreate) -> Optional[Category]: + """Update an existing category.""" + db_category = get_category(db, category_id) + if db_category: + for key, value in category_data.model_dump().items(): + setattr(db_category, key, value) + db.commit() + db.refresh(db_category) + return db_category + + +def delete_category(db: Session, category_id: int) -> bool: + """Delete a category.""" + db_category = get_category(db, category_id) + if db_category: + db.delete(db_category) + db.commit() + return True + return False \ No newline at end of file diff --git a/app/services/question.py b/app/services/question.py new file mode 100644 index 0000000..45ae986 --- /dev/null +++ b/app/services/question.py @@ -0,0 +1,60 @@ +from sqlalchemy.orm import Session +from typing import List, Optional + +from app.models.question import Question +from app.schemas.question import QuestionCreate, QuestionUpdate + + +def create_question(db: Session, question_data: QuestionCreate) -> Question: + """Create a new question.""" + db_question = Question(**question_data.model_dump()) + db.add(db_question) + db.commit() + db.refresh(db_question) + return db_question + + +def get_question(db: Session, question_id: int) -> Optional[Question]: + """Get a question by ID.""" + return db.query(Question).filter(Question.id == question_id).first() + + +def get_questions( + db: Session, + skip: int = 0, + limit: int = 100, + category_id: Optional[int] = None, + difficulty: Optional[str] = None +) -> List[Question]: + """Get all questions with optional filtering and pagination.""" + query = db.query(Question) + + if category_id: + query = query.filter(Question.category_id == category_id) + + if difficulty: + query = query.filter(Question.difficulty == difficulty) + + return query.offset(skip).limit(limit).all() + + +def update_question(db: Session, question_id: int, question_data: QuestionUpdate) -> Optional[Question]: + """Update an existing question.""" + db_question = get_question(db, question_id) + if db_question: + update_data = question_data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_question, key, value) + db.commit() + db.refresh(db_question) + return db_question + + +def delete_question(db: Session, question_id: int) -> bool: + """Delete a question.""" + db_question = get_question(db, question_id) + if db_question: + db.delete(db_question) + db.commit() + return True + return False \ No newline at end of file diff --git a/app/services/quiz.py b/app/services/quiz.py new file mode 100644 index 0000000..ca64e54 --- /dev/null +++ b/app/services/quiz.py @@ -0,0 +1,82 @@ +from sqlalchemy.orm import Session +from typing import List, Optional + +from app.models.quiz import Quiz +from app.models.question import Question +from app.schemas.quiz import QuizCreate, QuizUpdate + + +def create_quiz(db: Session, quiz_data: QuizCreate) -> Quiz: + """Create a new quiz.""" + # Extract question_ids + question_ids = quiz_data.question_ids + quiz_dict = quiz_data.model_dump(exclude={"question_ids"}) + + # Create quiz + db_quiz = Quiz(**quiz_dict) + db.add(db_quiz) + db.commit() + + # Add questions to quiz + questions = db.query(Question).filter(Question.id.in_(question_ids)).all() + db_quiz.questions = questions + db.commit() + db.refresh(db_quiz) + + return db_quiz + + +def get_quiz(db: Session, quiz_id: int) -> Optional[Quiz]: + """Get a quiz by ID.""" + return db.query(Quiz).filter(Quiz.id == quiz_id).first() + + +def get_quiz_with_questions(db: Session, quiz_id: int) -> Optional[Quiz]: + """Get a quiz by ID with questions.""" + return db.query(Quiz).filter(Quiz.id == quiz_id).first() + + +def get_quizzes( + db: Session, + skip: int = 0, + limit: int = 100, + category_id: Optional[int] = None +) -> List[Quiz]: + """Get all quizzes with optional filtering and pagination.""" + query = db.query(Quiz) + + if category_id: + query = query.filter(Quiz.category_id == category_id) + + return query.offset(skip).limit(limit).all() + + +def update_quiz(db: Session, quiz_id: int, quiz_data: QuizUpdate) -> Optional[Quiz]: + """Update an existing quiz.""" + db_quiz = get_quiz(db, quiz_id) + if not db_quiz: + return None + + # Update quiz fields + update_data = quiz_data.model_dump(exclude={"question_ids"}, exclude_unset=True) + for key, value in update_data.items(): + setattr(db_quiz, key, value) + + # Update questions if provided + if quiz_data.question_ids is not None: + questions = db.query(Question).filter(Question.id.in_(quiz_data.question_ids)).all() + db_quiz.questions = questions + + db.commit() + db.refresh(db_quiz) + return db_quiz + + +def delete_quiz(db: Session, quiz_id: int) -> bool: + """Delete a quiz.""" + db_quiz = get_quiz(db, quiz_id) + if db_quiz: + db.delete(db_quiz) + db.commit() + return True + return False \ No newline at end of file diff --git a/app/services/result.py b/app/services/result.py new file mode 100644 index 0000000..7df85bb --- /dev/null +++ b/app/services/result.py @@ -0,0 +1,29 @@ +from sqlalchemy.orm import Session +from typing import List, Optional + +from app.models.result import Result +from app.schemas.result import ResultCreate + + +def create_result(db: Session, result_data: ResultCreate) -> Result: + """Create a new quiz result.""" + db_result = Result(**result_data.model_dump()) + db.add(db_result) + db.commit() + db.refresh(db_result) + return db_result + + +def get_result(db: Session, result_id: int) -> Optional[Result]: + """Get a result by ID.""" + return db.query(Result).filter(Result.id == result_id).first() + + +def get_results(db: Session, skip: int = 0, limit: int = 100) -> List[Result]: + """Get all results with pagination.""" + return db.query(Result).offset(skip).limit(limit).all() + + +def get_user_results(db: Session, user_id: str) -> List[Result]: + """Get all results for a specific user.""" + return db.query(Result).filter(Result.user_id == user_id).all() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..6ea225d --- /dev/null +++ b/main.py @@ -0,0 +1,34 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.api.v1.router import api_router +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + description="Bible Quiz App API - A RESTful API for a Bible quiz application", + version="0.1.0", + openapi_url="/openapi.json", + docs_url="/docs", + redoc_url="/redoc", +) + +# 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) + +# Health check endpoint +@app.get("/health", tags=["Health"]) +async def health_check(): + return {"status": "healthy"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..1dccff5 --- /dev/null +++ b/migrations/README @@ -0,0 +1,13 @@ +Bible Quiz App API - Alembic Migrations + +This directory contains database migration scripts using Alembic. + +To apply migrations: +``` +alembic upgrade head +``` + +To create a new migration: +``` +alembic revision --autogenerate -m "description" +``` \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..0c1cb23 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,88 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# Import models to enable Alembic autogeneration +from app.db.session import Base +# These imports are needed for Alembic to detect the models +from app.models.category import Category # noqa: F401 +from app.models.question import Question # noqa: F401 +from app.models.quiz import Quiz # noqa: F401 +from app.models.result import Result # noqa: F401 + +# 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. +if config.config_file_name is not None: + 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() -> None: + """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"}, + render_as_batch=True # Important for SQLite migrations + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """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 migrations + ) + + 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..37d0cac --- /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() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/54a3fedbad84_initial_migration.py b/migrations/versions/54a3fedbad84_initial_migration.py new file mode 100644 index 0000000..a5b8931 --- /dev/null +++ b/migrations/versions/54a3fedbad84_initial_migration.py @@ -0,0 +1,98 @@ +"""Initial migration + +Revision ID: 54a3fedbad84 +Revises: +Create Date: 2023-11-01 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '54a3fedbad84' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create categories table + op.create_table('categories', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_categories_id'), 'categories', ['id'], unique=False) + op.create_index(op.f('ix_categories_name'), '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('answer', sa.String(length=255), nullable=False), + sa.Column('option1', sa.String(length=255), nullable=True), + sa.Column('option2', sa.String(length=255), nullable=True), + sa.Column('option3', sa.String(length=255), nullable=True), + sa.Column('difficulty', sa.String(length=20), nullable=True), + sa.Column('reference', sa.String(length=100), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['categories.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(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=True), + sa.Column('difficulty', sa.String(length=20), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_quizzes_id'), 'quizzes', ['id'], unique=False) + + # Create question_quiz association table + op.create_table('question_quiz', + sa.Column('question_id', sa.Integer(), nullable=False), + sa.Column('quiz_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ), + sa.ForeignKeyConstraint(['quiz_id'], ['quizzes.id'], ), + sa.PrimaryKeyConstraint('question_id', 'quiz_id') + ) + + # Create results table + op.create_table('results', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.String(length=100), nullable=False), + sa.Column('quiz_id', sa.Integer(), nullable=True), + sa.Column('score', sa.Float(), nullable=False), + sa.Column('total_questions', sa.Integer(), nullable=False), + sa.Column('correct_answers', sa.Integer(), nullable=False), + sa.Column('time_taken', sa.Float(), nullable=True), + sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.ForeignKeyConstraint(['quiz_id'], ['quizzes.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_results_id'), 'results', ['id'], unique=False) + op.create_index(op.f('ix_results_user_id'), 'results', ['user_id'], unique=False) + + +def downgrade() -> None: + # Drop tables in reverse order + op.drop_index(op.f('ix_results_user_id'), table_name='results') + op.drop_index(op.f('ix_results_id'), table_name='results') + op.drop_table('results') + op.drop_table('question_quiz') + 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_categories_name'), table_name='categories') + op.drop_index(op.f('ix_categories_id'), table_name='categories') + op.drop_table('categories') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f3ea67e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.104.0 +uvicorn>=0.23.2 +sqlalchemy>=2.0.0 +alembic>=1.12.0 +pydantic>=2.4.2 +python-dotenv>=1.0.0 +ruff>=0.0.292 +python-multipart>=0.0.6 +pathlib>=1.0.1 +email-validator>=2.0.0 \ No newline at end of file