Merge pull request #5 from Akhdanre/feat/swagger-documentation

swagger and fix issue
This commit is contained in:
Akhdan Robbani 2025-05-25 12:56:22 +07:00 committed by GitHub
commit a099da34f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1395 additions and 25 deletions

131
.gitignore vendored
View File

@ -1,14 +1,137 @@
# Ignore only __pycache__ inside the app directory
app/**/__pycache__/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Only ignore __pycache__ inside app
app/**/__pycache__/
# Ignore compiled Python files inside app
app/**/*.pyc
app/**/*.pyo
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Logs
logs/
*.log
# Django stuff:
*.sqlite3
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre
.pyre/
# pytype
.pytype/
# Cython debug symbols
cython_debug/
# VS Code
.vscode/
# JetBrains IDEs
.idea/
*.iml
# MacOS
.DS_Store
# Thumbs.db (Windows)
Thumbs.db
ehthumbs.db
# Others
*.bak
*.swp
*.swo
*~
# Local dev files
local_settings.py

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"yaml.schemas": {
"openapi:v3": "file:///mnt/disc1/code/thesis_quiz_project/quiz_maker/docs/rest_api_docs.yaml"
}
}

View File

@ -2,6 +2,7 @@ from .default import default_blueprint
from .auth import auth_blueprint
from .user import user_blueprint
from .swagger import swagger_blueprint
from .quiz import quiz_bp
from .history import history_blueprint
from .subject import subject_blueprint
@ -15,6 +16,7 @@ __all__ = [
"history_blueprint",
"subject_blueprint",
"session_bp",
"swagger_blueprint",
]

View File

@ -17,8 +17,10 @@ def user_history(
@history_blueprint.route("/detail/<answer_id>", methods=["GET"])
@inject
def user_detail_history(
answer_id, controller: HistoryController = Provide[Container.history_controller]
answer_id: str,
controller: HistoryController = Provide[Container.history_controller],
):
print(answer_id)
return controller.get_detail_quiz_history(answer_id)

23
app/blueprints/swagger.py Normal file
View File

@ -0,0 +1,23 @@
from flask import Blueprint, jsonify, send_file
from flask_swagger_ui import get_swaggerui_blueprint
import os
swagger_blueprint = Blueprint("swagger", __name__)
SWAGGER_URL = "/swagger"
API_URL = "http://127.0.0.1:5000/swagger/docs"
swagger_ui_blueprint = get_swaggerui_blueprint(
SWAGGER_URL,
API_URL,
config={"app_name": "Quiz Maker API"},
)
swagger_blueprint.register_blueprint(swagger_ui_blueprint)
@swagger_blueprint.route("/swagger/docs")
def serve_openapi():
"""Serve the OpenAPI spec from a file."""
docs_path = os.path.abspath("docs/rest_api_docs.yaml")
return send_file(docs_path, mimetype="application/yaml")

View File

@ -38,3 +38,11 @@ def get_user(
user_id, user_controller: UserController = Provide[Container.user_controller]
):
return user_controller.get_user_by_id(user_id)
@user_blueprint.route("/user/status/<string:user_id>", methods=["GET"])
@inject
def get_user_stat(
user_id, user_controller: UserController = Provide[Container.user_controller]
):
return user_controller.user_stat(user_id)

View File

@ -9,6 +9,7 @@ class Config:
# Flask Environment Settings
FLASK_ENV = os.getenv("FLASK_ENV", "development")
DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "t")
API_VERSION = os.getenv("API_VERSION", "v1")
SECRET_KEY = os.getenv("SECRET_KEY", "your_secret_key")
# MongoDB Settings

View File

@ -9,9 +9,14 @@ class LoggerConfig:
LOG_DIR = "logs" # Define the log directory
@staticmethod
def init_logger(app):
"""Initializes separate log files for INFO, ERROR, and WARNING levels."""
def init_logger(app, production_mode=False):
"""
Initializes separate log files for INFO, ERROR, and WARNING levels.
Args:
app: Flask application instance
production_mode (bool): If True, disables console output and only logs to files
"""
# Ensure the logs directory exists
if not os.path.exists(LoggerConfig.LOG_DIR):
os.makedirs(LoggerConfig.LOG_DIR)
@ -20,23 +25,37 @@ class LoggerConfig:
for handler in app.logger.handlers[:]:
app.logger.removeHandler(handler)
# Disable propagation to root logger to prevent console output in production
if production_mode:
app.logger.propagate = False
# Create separate loggers
info_logger = LoggerConfig._setup_logger(
info_handler = LoggerConfig._setup_logger(
"info_logger", "info.log", logging.INFO, logging.WARNING
)
error_logger = LoggerConfig._setup_logger(
error_handler = LoggerConfig._setup_logger(
"error_logger", "error.log", logging.ERROR, logging.CRITICAL
)
warning_logger = LoggerConfig._setup_logger(
warning_handler = LoggerConfig._setup_logger(
"warning_logger", "warning.log", logging.WARNING, logging.WARNING
)
# Attach handlers to Flask app logger
app.logger.addHandler(info_logger)
app.logger.addHandler(error_logger)
app.logger.addHandler(warning_logger)
app.logger.addHandler(info_handler)
app.logger.addHandler(error_handler)
app.logger.addHandler(warning_handler)
app.logger.setLevel(logging.DEBUG) # Set lowest level to capture all logs
# Add console handler only in development mode
if not production_mode:
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_formatter = logging.Formatter(
"%(asctime)s - %(levelname)s - %(message)s"
)
console_handler.setFormatter(console_formatter)
app.logger.addHandler(console_handler)
app.logger.info("Logger has been initialized for Flask application.")
@staticmethod
@ -44,11 +63,14 @@ class LoggerConfig:
"""Helper method to configure loggers for specific levels."""
logger = logging.getLogger(name)
logger.setLevel(level)
log_file_path = os.path.join(LoggerConfig.LOG_DIR, filename)
log_handler = RotatingFileHandler(log_file_path, maxBytes=100000, backupCount=3)
log_handler.setLevel(level)
log_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
log_handler.setFormatter(log_formatter)
log_handler.addFilter(lambda record: level <= record.levelno <= max_level)
logger.addHandler(log_handler)
return log_handler

View File

@ -1,5 +1,6 @@
from app.services.subject_service import SubjectService
from app.helpers import make_response, make_error_response
from app.schemas.requests import SubjectCreateRequest
class SubjectController:
@ -8,7 +9,8 @@ class SubjectController:
def create(self, req_body):
try:
new_id = self.service.create_subject(req_body)
data = SubjectCreateRequest(**req_body)
new_id = self.service.create_subject(data)
return make_response(message="Subject created", data={"id": new_id})
except Exception as e:
return make_error_response(e)

View File

@ -114,3 +114,16 @@ class UserController:
message="An internal server error occurred. Please try again later.",
status_code=500,
)
def user_stat(self, user_id):
try:
response = self.user_service.get_user_status(user_id)
return make_response(message="Success retrive user stat", data=response)
except Exception as e:
current_app.logger.error(
f"Error while changing password: {str(e)}", exc_info=True
)
return make_response(
message="An internal server error occurred. Please try again later.",
status_code=500,
)

View File

@ -66,6 +66,8 @@ class Container(containers.DeclarativeContainer):
user_service = providers.Factory(
UserService,
user_repository,
answer_repository,
quiz_repository,
)
quiz_service = providers.Factory(

View File

@ -16,6 +16,7 @@ from app.blueprints import (
history_blueprint,
subject_blueprint,
session_bp,
swagger_blueprint,
)
from app.database import init_db
from redis import Redis
@ -24,7 +25,7 @@ from redis import Redis
def createApp() -> tuple[Flask, SocketIO]:
app = Flask(__name__)
app.config.from_object(Config)
LoggerConfig.init_logger(app)
LoggerConfig.init_logger(app, not Config.DEBUG)
logging.basicConfig(
level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
@ -65,6 +66,7 @@ def createApp() -> tuple[Flask, SocketIO]:
)
app.register_blueprint(default_blueprint)
app.register_blueprint(swagger_blueprint)
app.register_blueprint(auth_blueprint, url_prefix="/api")
app.register_blueprint(user_blueprint, url_prefix="/api")
app.register_blueprint(quiz_bp, url_prefix="/api/quiz")

View File

@ -15,3 +15,7 @@ class ResponseSchema(BaseModel, Generic[T]):
message: str
data: Optional[T] = None
meta: Optional[MetaSchema] = None
class Config:
from_attributes = True
exclude_none = True

View File

@ -1,10 +1,8 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional
class SubjectCreateRequest(BaseModel):
name: str = Field(..., example="Ilmu Pengetahuan ALam")
alias: str = Field(..., examples="IPA", alias="short_name")
description: Optional[str] = Field(
None, example="Pelajaran tentang angka dan logika"
)
name: str
alias: str
description: Optional[str]

View File

@ -1,8 +1,8 @@
from datetime import datetime
from app.repositories import UserRepository
from app.repositories import UserRepository, UserAnswerRepository, QuizRepository
from app.schemas import RegisterSchema
from app.schemas.requests import ProfileUpdateSchema
from app.schemas.response import UserResponseSchema
from app.models.entities import UserAnswerEntity
from app.mapper import UserMapper
from app.exception import AlreadyExistException, DataNotFoundException
from werkzeug.security import generate_password_hash, check_password_hash
@ -10,8 +10,15 @@ from app.helpers import DatetimeUtil
class UserService:
def __init__(self, user_repository: UserRepository):
def __init__(
self,
user_repository: UserRepository,
answer_repository: UserAnswerRepository,
quiz_repository: QuizRepository,
):
self.user_repository = user_repository
self.answer_repository = answer_repository
self.quiz_repository = quiz_repository
def get_all_users(self):
return self.user_repository.get_all_users()
@ -94,3 +101,24 @@ class UserService:
user_dict["updated_at"] = DatetimeUtil.to_string(user_dict["updated_at"])
return UserResponseSchema(**user_dict)
def get_user_status(self, user_id):
user_answers: list[UserAnswerEntity] = self.answer_repository.get_by_user(
user_id
)
total_quiz = self.quiz_repository.count_by_user_id(user_id)
if not user_answers:
return None
total_score = sum(answer.total_score for answer in user_answers)
total_questions = len(user_answers)
percentage = total_score / total_questions
return {
"avg_score": round(percentage, 2),
"total_solve": total_questions,
"total_quiz": total_quiz,
}

1135
docs/rest_api_docs.yaml Normal file

File diff suppressed because it is too large Load Diff