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 # Byte-compiled / optimized / DLL files
app/**/__pycache__/ __pycache__/
*.py[cod]
*$py.class
# Only ignore __pycache__ inside app
app/**/__pycache__/
# Ignore compiled Python files inside app # Ignore compiled Python files inside app
app/**/*.pyc app/**/*.pyc
app/**/*.pyo 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 .env
.venv .venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Logs
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 .auth import auth_blueprint
from .user import user_blueprint from .user import user_blueprint
from .swagger import swagger_blueprint
from .quiz import quiz_bp from .quiz import quiz_bp
from .history import history_blueprint from .history import history_blueprint
from .subject import subject_blueprint from .subject import subject_blueprint
@ -15,6 +16,7 @@ __all__ = [
"history_blueprint", "history_blueprint",
"subject_blueprint", "subject_blueprint",
"session_bp", "session_bp",
"swagger_blueprint",
] ]

View File

@ -17,8 +17,10 @@ def user_history(
@history_blueprint.route("/detail/<answer_id>", methods=["GET"]) @history_blueprint.route("/detail/<answer_id>", methods=["GET"])
@inject @inject
def user_detail_history( 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) 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] user_id, user_controller: UserController = Provide[Container.user_controller]
): ):
return user_controller.get_user_by_id(user_id) 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 Environment Settings
FLASK_ENV = os.getenv("FLASK_ENV", "development") FLASK_ENV = os.getenv("FLASK_ENV", "development")
DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "t") 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") SECRET_KEY = os.getenv("SECRET_KEY", "your_secret_key")
# MongoDB Settings # MongoDB Settings

View File

@ -9,9 +9,14 @@ class LoggerConfig:
LOG_DIR = "logs" # Define the log directory LOG_DIR = "logs" # Define the log directory
@staticmethod @staticmethod
def init_logger(app): def init_logger(app, production_mode=False):
"""Initializes separate log files for INFO, ERROR, and WARNING levels.""" """
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 # Ensure the logs directory exists
if not os.path.exists(LoggerConfig.LOG_DIR): if not os.path.exists(LoggerConfig.LOG_DIR):
os.makedirs(LoggerConfig.LOG_DIR) os.makedirs(LoggerConfig.LOG_DIR)
@ -20,23 +25,37 @@ class LoggerConfig:
for handler in app.logger.handlers[:]: for handler in app.logger.handlers[:]:
app.logger.removeHandler(handler) 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 # Create separate loggers
info_logger = LoggerConfig._setup_logger( info_handler = LoggerConfig._setup_logger(
"info_logger", "info.log", logging.INFO, logging.WARNING "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 "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 "warning_logger", "warning.log", logging.WARNING, logging.WARNING
) )
# Attach handlers to Flask app logger # Attach handlers to Flask app logger
app.logger.addHandler(info_logger) app.logger.addHandler(info_handler)
app.logger.addHandler(error_logger) app.logger.addHandler(error_handler)
app.logger.addHandler(warning_logger) app.logger.addHandler(warning_handler)
app.logger.setLevel(logging.DEBUG) # Set lowest level to capture all logs 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.") app.logger.info("Logger has been initialized for Flask application.")
@staticmethod @staticmethod
@ -44,11 +63,14 @@ class LoggerConfig:
"""Helper method to configure loggers for specific levels.""" """Helper method to configure loggers for specific levels."""
logger = logging.getLogger(name) logger = logging.getLogger(name)
logger.setLevel(level) logger.setLevel(level)
log_file_path = os.path.join(LoggerConfig.LOG_DIR, filename) log_file_path = os.path.join(LoggerConfig.LOG_DIR, filename)
log_handler = RotatingFileHandler(log_file_path, maxBytes=100000, backupCount=3) log_handler = RotatingFileHandler(log_file_path, maxBytes=100000, backupCount=3)
log_handler.setLevel(level) log_handler.setLevel(level)
log_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") log_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
log_handler.setFormatter(log_formatter) log_handler.setFormatter(log_formatter)
log_handler.addFilter(lambda record: level <= record.levelno <= max_level) log_handler.addFilter(lambda record: level <= record.levelno <= max_level)
logger.addHandler(log_handler) logger.addHandler(log_handler)
return log_handler return log_handler

View File

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

View File

@ -114,3 +114,16 @@ class UserController:
message="An internal server error occurred. Please try again later.", message="An internal server error occurred. Please try again later.",
status_code=500, 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( user_service = providers.Factory(
UserService, UserService,
user_repository, user_repository,
answer_repository,
quiz_repository,
) )
quiz_service = providers.Factory( quiz_service = providers.Factory(

View File

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

View File

@ -15,3 +15,7 @@ class ResponseSchema(BaseModel, Generic[T]):
message: str message: str
data: Optional[T] = None data: Optional[T] = None
meta: Optional[MetaSchema] = 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 from typing import Optional
class SubjectCreateRequest(BaseModel): class SubjectCreateRequest(BaseModel):
name: str = Field(..., example="Ilmu Pengetahuan ALam") name: str
alias: str = Field(..., examples="IPA", alias="short_name") alias: str
description: Optional[str] = Field( description: Optional[str]
None, example="Pelajaran tentang angka dan logika"
)

View File

@ -1,8 +1,8 @@
from datetime import datetime from app.repositories import UserRepository, UserAnswerRepository, QuizRepository
from app.repositories import UserRepository
from app.schemas import RegisterSchema from app.schemas import RegisterSchema
from app.schemas.requests import ProfileUpdateSchema from app.schemas.requests import ProfileUpdateSchema
from app.schemas.response import UserResponseSchema from app.schemas.response import UserResponseSchema
from app.models.entities import UserAnswerEntity
from app.mapper import UserMapper from app.mapper import UserMapper
from app.exception import AlreadyExistException, DataNotFoundException from app.exception import AlreadyExistException, DataNotFoundException
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
@ -10,8 +10,15 @@ from app.helpers import DatetimeUtil
class UserService: 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.user_repository = user_repository
self.answer_repository = answer_repository
self.quiz_repository = quiz_repository
def get_all_users(self): def get_all_users(self):
return self.user_repository.get_all_users() return self.user_repository.get_all_users()
@ -94,3 +101,24 @@ class UserService:
user_dict["updated_at"] = DatetimeUtil.to_string(user_dict["updated_at"]) user_dict["updated_at"] = DatetimeUtil.to_string(user_dict["updated_at"])
return UserResponseSchema(**user_dict) 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