From 0773609fdadce6dcc2a6e7c0fc8a120f767bf14f Mon Sep 17 00:00:00 2001 From: akhdanre Date: Wed, 30 Apr 2025 15:21:33 +0700 Subject: [PATCH] fix: answer input --- app/blueprints/quiz.py | 8 +- app/controllers/quiz_controller.py | 33 ++++--- app/di_container.py | 7 +- app/exception/__init__.py | 2 + app/exception/base_exception.py | 3 + app/exception/validation_exception.py | 8 ++ app/helpers/response_helper.py | 30 ++++++- app/mapper/quiz_mapper.py | 2 + app/models/entities/__init__.py | 1 + app/models/entities/answer_item.py | 3 - app/models/entities/question_item_entity.py | 7 +- app/models/entities/user_answer_entity.py | 4 +- app/repositories/quiz_repositroy.py | 21 ++++- .../answer/answer_item_request_schema.py | 3 - .../requests/answer/answer_request_schema.py | 5 +- app/schemas/requests/quiz/quiz_item_schema.py | 1 + app/schemas/response/__init__.py | 2 + .../response/quiz/question_item_schema.py | 3 + .../response/quiz/quiz_data_rsp_schema.py | 8 ++ app/services/answer_service.py | 87 ++++++++++++++++++- app/services/quiz_service.py | 22 ++++- 21 files changed, 212 insertions(+), 48 deletions(-) create mode 100644 app/exception/validation_exception.py create mode 100644 app/schemas/response/quiz/quiz_data_rsp_schema.py diff --git a/app/blueprints/quiz.py b/app/blueprints/quiz.py index da5826f..86f3895 100644 --- a/app/blueprints/quiz.py +++ b/app/blueprints/quiz.py @@ -51,9 +51,9 @@ def get_quiz_recommendation( @quiz_bp.route("/user/", methods=["GET"]) @inject -def get_user_quiz(controller: QuizController = Provide[Container.quiz_controller]): +def get_user_quiz( + user_id: str, controller: QuizController = Provide[Container.quiz_controller] +): page = request.args.get("page", default=1, type=int) page_size = request.args.get("page_size", default=10, type=int) - return controller.get_user_quiz( - user_id=request.view_args["user_id"], page=page, page_size=page_size - ) + return controller.get_user_quiz(user_id=user_id, page=page, page_size=page_size) diff --git a/app/controllers/quiz_controller.py b/app/controllers/quiz_controller.py index d53abda..6cbc082 100644 --- a/app/controllers/quiz_controller.py +++ b/app/controllers/quiz_controller.py @@ -1,9 +1,11 @@ -from flask import jsonify +import json from pydantic import ValidationError from schemas.requests import QuizCreateSchema, UserAnswerSchema from schemas.response import QuizCreationResponse +from schemas import MetaSchema from services import QuizService, AnswerService from helpers import make_response, make_error_response +from exception import ValidationException class QuizController: @@ -29,7 +31,7 @@ class QuizController: data=QuizCreationResponse(quiz_id=quiz_id), status_code=201, ) - except ValidationError as e: + except (ValidationError, ValidationException) as e: return make_response(e.errors(), status_code=400) except Exception as e: return make_error_response(e) @@ -46,11 +48,11 @@ class QuizController: # except ValidationError as e: # return jsonify({"error": "Validation error", "detail": e.errors()}), 400 - def delete_quiz(self, quiz_id): - success = self.quiz_service.delete_quiz(quiz_id) - if not success: - return jsonify({"error": "Quiz not found"}), 400 - return jsonify({"message": "Quiz deleted"}), 200 + # def delete_quiz(self, quiz_id): + # success = self.quiz_service.delete_quiz(quiz_id) + # if not success: + # return jsonify({"error": "Quiz not found"}), 400 + # return jsonify({"message": "Quiz deleted"}), 200 def quiz_recomendation(self): try: @@ -63,16 +65,21 @@ class QuizController: def submit_answer(self, answer_data): try: - # Assuming answer_data is a dictionary with the necessary fields answer_obj = UserAnswerSchema(**answer_data) answer_id = self.answer_service.create_answer(answer_obj) return make_response( message="Answer submitted", - data={"answer_id": answer_id}, + data={"answer_id": True}, status_code=201, ) except ValidationError as e: - return make_response(e.errors(), status_code=400) + return make_response( + message="validation error", data=json.loads(e.json()), status_code=400 + ) + except ValidationException as e: + return make_response( + message=f"validation issue {e.message}", status_code=400 + ) except Exception as e: return make_error_response(e) @@ -89,7 +96,11 @@ class QuizController: user_id=user_id, page=page, page_size=page_size ) return make_response( - message="User quizzes retrieved successfully", data=result + message="User quizzes retrieved successfully", + data=result.quizzes, + page=page, + page_size=page_size, + total_all_data=result.total, ) except Exception as e: return make_error_response(e) diff --git a/app/di_container.py b/app/di_container.py index e621246..57045a0 100644 --- a/app/di_container.py +++ b/app/di_container.py @@ -18,7 +18,12 @@ class Container(containers.DeclarativeContainer): auth_service = providers.Factory(AuthService, user_repository) user_service = providers.Factory(UserService, user_repository) quiz_service = providers.Factory(QuizService, quiz_repository) - answer_service = providers.Factory(AnswerService, answer_repository) + answer_service = providers.Factory( + AnswerService, + answer_repository, + quiz_repository, + user_repository, + ) # controllers auth_controller = providers.Factory(AuthController, user_service, auth_service) diff --git a/app/exception/__init__.py b/app/exception/__init__.py index a9d1d9e..0a46faa 100644 --- a/app/exception/__init__.py +++ b/app/exception/__init__.py @@ -1,10 +1,12 @@ from .auth_exception import AuthException from .already_exist_exception import AlreadyExistException from .data_not_found_exception import DataNotFoundException +from .validation_exception import ValidationException __all__ = [ "AuthException", "AlreadyExistException", "DataNotFoundException", + "ValidationException", ] diff --git a/app/exception/base_exception.py b/app/exception/base_exception.py index 747b739..ffb19b0 100644 --- a/app/exception/base_exception.py +++ b/app/exception/base_exception.py @@ -8,3 +8,6 @@ class BaseExceptionTemplate(Exception): def __str__(self): return f"{self.__class__.__name__}: {self.message}" + + def json(self): + return {"error": self.__class__.__name__, "message": self.message} diff --git a/app/exception/validation_exception.py b/app/exception/validation_exception.py new file mode 100644 index 0000000..02d1706 --- /dev/null +++ b/app/exception/validation_exception.py @@ -0,0 +1,8 @@ +from .base_exception import BaseExceptionTemplate + + +class ValidationException(BaseExceptionTemplate): + """Exception for validation""" + + def __init__(self, message: str = "validation error, check yout input"): + super().__init__(message, status_code=400) diff --git a/app/helpers/response_helper.py b/app/helpers/response_helper.py index 135d672..1eb68f4 100644 --- a/app/helpers/response_helper.py +++ b/app/helpers/response_helper.py @@ -1,15 +1,37 @@ from flask import jsonify, current_app from typing import Optional, Union -from schemas import ResponseSchema +from schemas import ResponseSchema, MetaSchema +import math + + +def calculate_total_page(total_data: int, page_size: int) -> int: + if not page_size or page_size <= 0: + return 1 + return math.ceil(total_data / page_size) def make_response( message: str, - data: Optional[dict] = None, - meta: Optional[dict] = None, + data: Optional[Union[dict, list]] = None, + page: Optional[int] = None, + page_size: Optional[int] = None, + total_all_data: Optional[int] = None, status_code: int = 200, ): - response = ResponseSchema(message=message, data=data, meta=meta) + meta = None + if page is not None and page_size is not None and total_all_data is not None: + meta = MetaSchema( + current_page=page, + total_all_data=total_all_data, + total_data=len(data) if data else 0, + total_page=calculate_total_page(total_all_data, page_size), + ) + + response = ResponseSchema( + message=message, + data=data, + meta=meta, + ) return jsonify(response.model_dump()), status_code diff --git a/app/mapper/quiz_mapper.py b/app/mapper/quiz_mapper.py index 19977e8..c66e8d5 100644 --- a/app/mapper/quiz_mapper.py +++ b/app/mapper/quiz_mapper.py @@ -4,10 +4,12 @@ from schemas import QuizGetSchema, QuestionItemSchema def map_question_entity_to_schema(entity: QuestionItemEntity) -> QuestionItemSchema: return QuestionItemSchema( + index=entity.index, question=entity.question, target_answer=entity.target_answer, duration=entity.duration, type=entity.type, + options=entity.options, ) diff --git a/app/models/entities/__init__.py b/app/models/entities/__init__.py index afed6ab..2bf5cb6 100644 --- a/app/models/entities/__init__.py +++ b/app/models/entities/__init__.py @@ -3,6 +3,7 @@ from .base import PyObjectId from .quiz_entity import QuizEntity from .question_item_entity import QuestionItemEntity from .user_answer_entity import UserAnswerEntity +from .answer_item import AnswerItemEntity __all__ = [ "UserEntity", diff --git a/app/models/entities/answer_item.py b/app/models/entities/answer_item.py index e5bf0e3..3ce6038 100644 --- a/app/models/entities/answer_item.py +++ b/app/models/entities/answer_item.py @@ -3,9 +3,6 @@ from pydantic import BaseModel class AnswerItemEntity(BaseModel): question_index: int - question: str answer: str - correct_answer: str is_correct: bool - duration: int time_spent: float diff --git a/app/models/entities/question_item_entity.py b/app/models/entities/question_item_entity.py index 2172ef5..e7aa3ad 100644 --- a/app/models/entities/question_item_entity.py +++ b/app/models/entities/question_item_entity.py @@ -1,12 +1,11 @@ from typing import Optional, List from pydantic import BaseModel -from datetime import datetime -from .base import PyObjectId class QuestionItemEntity(BaseModel): - _id: Optional[PyObjectId] = None + index: int question: str target_answer: str duration: int - type: str # "isian" | "true_false" + type: str + options: Optional[List[str]] = None diff --git a/app/models/entities/user_answer_entity.py b/app/models/entities/user_answer_entity.py index 90aa669..94114ee 100644 --- a/app/models/entities/user_answer_entity.py +++ b/app/models/entities/user_answer_entity.py @@ -9,8 +9,8 @@ from .base import PyObjectId class UserAnswerEntity(BaseModel): _id: Optional[PyObjectId] = None - session_id: Optional[PyObjectId] - quiz_id: PyObjectId + session_id: Optional[str] + quiz_id: str user_id: str answered_at: datetime answers: List[AnswerItemEntity] diff --git a/app/repositories/quiz_repositroy.py b/app/repositories/quiz_repositroy.py index 9f8060d..9dc8a5f 100644 --- a/app/repositories/quiz_repositroy.py +++ b/app/repositories/quiz_repositroy.py @@ -2,6 +2,7 @@ from bson import ObjectId from typing import List, Optional from models import QuizEntity from pymongo.database import Database +from datetime import datetime class QuizRepository: @@ -24,11 +25,20 @@ class QuizRepository: ) -> List[QuizEntity]: skip = (page - 1) * page_size cursor = ( - self.collection.find({"user_id": ObjectId(user_id)}) - .skip(skip) - .limit(page_size) + self.collection.find({"author_id": user_id}).skip(skip).limit(page_size) ) - return [QuizEntity(**doc) for doc in cursor] + + quiz_list = [] + for doc in cursor: + if "date" in doc and isinstance(doc["date"], str): + try: + doc["date"] = datetime.strptime(doc["date"], "%d-%m-%Y") + except ValueError: + doc["date"] = None + + quiz_list.append(QuizEntity(**doc)) + + return quiz_list def get_all(self, skip: int = 0, limit: int = 10) -> List[QuizEntity]: cursor = self.collection.find().skip(skip).limit(limit) @@ -43,3 +53,6 @@ class QuizRepository: def delete(self, quiz_id: str) -> bool: result = self.collection.delete_one({"_id": ObjectId(quiz_id)}) return result.deleted_count > 0 + + def count_by_user_id(self, user_id: str) -> int: + return self.collection.count_documents({"author_id": user_id}) diff --git a/app/schemas/requests/answer/answer_item_request_schema.py b/app/schemas/requests/answer/answer_item_request_schema.py index cf553f4..4ac6387 100644 --- a/app/schemas/requests/answer/answer_item_request_schema.py +++ b/app/schemas/requests/answer/answer_item_request_schema.py @@ -3,9 +3,6 @@ from pydantic import BaseModel class AnswerItemSchema(BaseModel): question_index: int - question: str answer: str - correct_answer: str is_correct: bool - duration: int time_spent: float diff --git a/app/schemas/requests/answer/answer_request_schema.py b/app/schemas/requests/answer/answer_request_schema.py index 6a6d1cd..1c83c99 100644 --- a/app/schemas/requests/answer/answer_request_schema.py +++ b/app/schemas/requests/answer/answer_request_schema.py @@ -5,11 +5,8 @@ from .answer_item_request_schema import AnswerItemSchema class UserAnswerSchema(BaseModel): - session_id: Optional[str] = None quiz_id: str user_id: str + session_id: Optional[str] = None answered_at: datetime - total_score: int - total_correct: int - total_questions: int answers: List[AnswerItemSchema] diff --git a/app/schemas/requests/quiz/quiz_item_schema.py b/app/schemas/requests/quiz/quiz_item_schema.py index 641a923..d2f3a05 100644 --- a/app/schemas/requests/quiz/quiz_item_schema.py +++ b/app/schemas/requests/quiz/quiz_item_schema.py @@ -3,6 +3,7 @@ from pydantic import BaseModel class QuestionItemSchema(BaseModel): + index: int question: str target_answer: str duration: int diff --git a/app/schemas/response/__init__.py b/app/schemas/response/__init__.py index 9e906d3..34df505 100644 --- a/app/schemas/response/__init__.py +++ b/app/schemas/response/__init__.py @@ -1,9 +1,11 @@ from .quiz.quiz_creation_response import QuizCreationResponse from .quiz.quiz_get_response import QuizGetSchema from .quiz.question_item_schema import QuestionItemSchema +from .quiz.quiz_data_rsp_schema import UserQuizListResponse __all__ = [ "QuizCreationResponse", "QuizGetSchema", "QuestionItemSchema", + "UserQuizListResponse" ] diff --git a/app/schemas/response/quiz/question_item_schema.py b/app/schemas/response/quiz/question_item_schema.py index 6f98a7b..d2f3a05 100644 --- a/app/schemas/response/quiz/question_item_schema.py +++ b/app/schemas/response/quiz/question_item_schema.py @@ -1,8 +1,11 @@ +from typing import List, Optional from pydantic import BaseModel class QuestionItemSchema(BaseModel): + index: int question: str target_answer: str duration: int type: str + options: Optional[List[str]] = None diff --git a/app/schemas/response/quiz/quiz_data_rsp_schema.py b/app/schemas/response/quiz/quiz_data_rsp_schema.py new file mode 100644 index 0000000..ac8b8fd --- /dev/null +++ b/app/schemas/response/quiz/quiz_data_rsp_schema.py @@ -0,0 +1,8 @@ +from typing import List +from pydantic import BaseModel +from schemas.response import QuizGetSchema + + +class UserQuizListResponse(BaseModel): + total: int + quizzes: List[QuizGetSchema] diff --git a/app/services/answer_service.py b/app/services/answer_service.py index 6291cc7..daef84a 100644 --- a/app/services/answer_service.py +++ b/app/services/answer_service.py @@ -1,9 +1,20 @@ -from repositories import UserAnswerRepository +from repositories import UserAnswerRepository, QuizRepository, UserRepository +from schemas.requests import UserAnswerSchema +from models import UserAnswerEntity +from models.entities import AnswerItemEntity +from exception import ValidationException class AnswerService: - def __init__(self, answer_repository: UserAnswerRepository): + def __init__( + self, + answer_repository: UserAnswerRepository, + quiz_repository: QuizRepository, + user_repositroy: UserRepository, + ): self.answer_repository = answer_repository + self.quiz_repository = quiz_repository + self.user_repositroy = user_repositroy def get_answer_by_id(self, answer_id): return self.answer_repository.get_answer_by_id(answer_id) @@ -12,8 +23,76 @@ class AnswerService: if quiz_id is not None: return self.answer_repository - def create_answer(self, answer_data): - return self.answer_repository.create(answer_data) + def create_answer(self, answer_data: UserAnswerSchema): + quiz_data = self.quiz_repository.get_by_id(answer_data.quiz_id) + user_data = self.user_repositroy.get_user_by_id(answer_data.user_id) + if not user_data: + raise ValidationException(message="user is not registered") + + question_map = {q.index: q for q in quiz_data.question_listings} + + answer_item_Entity = [] + total_correct = 0 + for user_answer in answer_data.answers: + question = question_map.get(user_answer.question_index) + if question is None: + raise ValueError( + f"Question index {user_answer.question_index} tidak ditemukan di kuis." + ) + + correct = False + if question.type in ["fill_the_blank", "true_false"]: + correct = ( + user_answer.answer.strip().lower() + == question.target_answer.strip().lower() + ) + elif question.type == "option": + try: + answer_index = int(user_answer.answer) + if 0 <= answer_index < len(question.options): + correct = str(answer_index) == question.target_answer + else: + raise ValueError( + f"Index jawaban tidak valid untuk soal {question.index}" + ) + except ValueError: + raise ValueError( + f"Jawaban bukan index valid untuk soal {question.index}" + ) + else: + raise ValueError(f"Tipe soal tidak dikenali: {question.type}") + + user_answer.is_correct = correct + if correct: + total_correct += 1 + + answer_item_Entity.append( + AnswerItemEntity( + question_index=user_answer.question_index, + answer=user_answer.answer, + is_correct=user_answer.is_correct, + time_spent=user_answer.time_spent, + ) + ) + total_questions = len(quiz_data.question_listings) + total_score = ( + total_correct * 100 // total_questions + ) # contoh perhitungan: nilai 100 dibagi rata + + # Buat entitas yang akan disimpan + answer_entity = UserAnswerEntity( + session_id=answer_data.session_id, + quiz_id=answer_data.quiz_id, + user_id=answer_data.user_id, + answered_at=answer_data.answered_at, + answers=answer_item_Entity, + total_correct=total_correct, + total_questions=total_questions, + total_score=total_score, + ) + + self.answer_repository.create(answer_entity) + return True def update_answer(self, answer_id, answer_data): return self.answer_repository.update(answer_id, answer_data) diff --git a/app/services/quiz_service.py b/app/services/quiz_service.py index 08c7c2a..20a33e4 100644 --- a/app/services/quiz_service.py +++ b/app/services/quiz_service.py @@ -1,8 +1,11 @@ from typing import List from repositories import QuizRepository from schemas import QuizGetSchema +from schemas.requests import QuizCreateSchema +from schemas.response import UserQuizListResponse from exception import DataNotFoundException from mapper import map_quiz_entity_to_schema +from exception import ValidationException class QuizService: @@ -13,16 +16,27 @@ class QuizService: data = self.quiz_repository.get_by_id(quiz_id) if data is None: raise DataNotFoundException("Quiz not found") - return map_quiz_entity_to_schema(data) def get_user_quiz( self, user_id: str, page: int = 1, page_size: int = 10 - ) -> List[QuizGetSchema]: + ) -> UserQuizListResponse: quizzes = self.quiz_repository.get_by_user_id(user_id, page, page_size) - return [QuizGetSchema.model_validate(quiz) for quiz in quizzes] + total_user_quiz = self.quiz_repository.count_by_user_id(user_id) + return UserQuizListResponse( + total=total_user_quiz, + quizzes=[map_quiz_entity_to_schema(quiz) for quiz in quizzes], + ) + + def create_quiz(self, quiz_data: QuizCreateSchema): + for question in quiz_data.question_listings: + if question.type == "option" and ( + not question.options or len(question.options) != 4 + ): + raise ValidationException( + "Option type questions must have exactly 4 options." + ) - def create_quiz(self, quiz_data): return self.quiz_repository.create(quiz_data) def update_quiz(self, quiz_id, quiz_data):