diff --git a/docs/docs.go b/docs/docs.go index fbd9998a5aaccf9cea15498eec6984d0488d596b..734b8808010f8a64ff1f1055e13f3be2a39b6bdb 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1441,7 +1441,98 @@ const docTemplate = `{ } } }, + "/course/{id}/quiz": { + "get": { + "description": "Get all cours", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course" + ], + "summary": "Get Course quiz", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Course id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.BaseResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/quiz.Quiz" + } + } + } + } + ] + } + } + } + } + }, "/material/{id}": { + "get": { + "description": "Get material detail", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "content" + ], + "summary": "Get material detail", + "parameters": [ + { + "type": "string", + "example": "IF3270", + "description": "Material id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.BaseResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/material.Material" + } + } + } + ] + } + } + } + }, "post": { "description": "Add content of material", "consumes": [ @@ -1600,6 +1691,164 @@ const docTemplate = `{ } } }, + "/quiz/{id}": { + "get": { + "description": "Get Quiz Detail", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quiz" + ], + "summary": "Get Quiz Detail", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Quiz id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.BaseResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/quiz.Quiz" + } + } + } + ] + } + } + } + } + }, + "/quiz/{id}/solution": { + "get": { + "description": "Take a quiz", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quiz" + ], + "summary": "Get Quiz Solution", + "parameters": [ + { + "type": "string", + "description": "Authenticate User (any role)", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Quiz id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.BaseResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/quiz.QuizDetail" + } + } + } + ] + } + } + } + } + }, + "/quiz/{id}/take": { + "post": { + "description": "Finish quiz session and get the score", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quiz" + ], + "summary": "Finish Quiz", + "parameters": [ + { + "type": "string", + "description": "Authenticate User (any role)", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "Quiz Finish payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/quiz.FinishQuizPayload" + } + }, + { + "type": "string", + "format": "uuid", + "description": "Quiz id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.BaseResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/quiz.QuizDetail" + } + } + } + ] + } + } + } + } + }, "/reset/confirm": { "put": { "description": "Do confirmation to reset password", @@ -2111,6 +2360,134 @@ const docTemplate = `{ } } }, + "quiz.AnswerOption": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_solution": { + "type": "boolean" + }, + "media_id": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "quiz.FinishQuizPayload": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/quiz.Response" + } + } + } + }, + "quiz.Quiz": { + "type": "object", + "properties": { + "course_id": { + "type": "string" + }, + "creator_email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "nama": { + "type": "string" + } + } + }, + "quiz.QuizDetail": { + "type": "object", + "properties": { + "course_id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "help": { + "type": "string" + }, + "id": { + "type": "string" + }, + "media": { + "type": "array", + "items": { + "$ref": "#/definitions/quiz.QuizMedia" + } + }, + "name": { + "type": "string" + }, + "problems": { + "type": "array", + "items": { + "$ref": "#/definitions/quiz.QuizProblem" + } + } + } + }, + "quiz.QuizMedia": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "quiz.QuizProblem": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/definitions/quiz.AnswerOption" + } + }, + "id": { + "type": "string" + }, + "media_id": { + "type": "array", + "items": { + "type": "string" + } + }, + "question": { + "type": "string" + } + } + }, + "quiz.Response": { + "type": "object", + "properties": { + "answer_id": { + "type": "string" + }, + "problem_id": { + "type": "string" + } + } + }, "refresh.RefreshResponsePayload": { "description": "Refresh endpoint response when process success", "type": "object", diff --git a/docs/swagger.json b/docs/swagger.json index 42693b034ca16e65ac3ffa432905cdbcaaeff8c3..883d3d799d994fab91eb3dbf9e1aee4d8483c36f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1432,7 +1432,98 @@ } } }, + "/course/{id}/quiz": { + "get": { + "description": "Get all cours", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course" + ], + "summary": "Get Course quiz", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Course id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.BaseResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/quiz.Quiz" + } + } + } + } + ] + } + } + } + } + }, "/material/{id}": { + "get": { + "description": "Get material detail", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "content" + ], + "summary": "Get material detail", + "parameters": [ + { + "type": "string", + "example": "IF3270", + "description": "Material id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.BaseResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/material.Material" + } + } + } + ] + } + } + } + }, "post": { "description": "Add content of material", "consumes": [ @@ -1591,6 +1682,164 @@ } } }, + "/quiz/{id}": { + "get": { + "description": "Get Quiz Detail", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quiz" + ], + "summary": "Get Quiz Detail", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Quiz id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.BaseResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/quiz.Quiz" + } + } + } + ] + } + } + } + } + }, + "/quiz/{id}/solution": { + "get": { + "description": "Take a quiz", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quiz" + ], + "summary": "Get Quiz Solution", + "parameters": [ + { + "type": "string", + "description": "Authenticate User (any role)", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Quiz id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.BaseResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/quiz.QuizDetail" + } + } + } + ] + } + } + } + } + }, + "/quiz/{id}/take": { + "post": { + "description": "Finish quiz session and get the score", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quiz" + ], + "summary": "Finish Quiz", + "parameters": [ + { + "type": "string", + "description": "Authenticate User (any role)", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "Quiz Finish payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/quiz.FinishQuizPayload" + } + }, + { + "type": "string", + "format": "uuid", + "description": "Quiz id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.BaseResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/quiz.QuizDetail" + } + } + } + ] + } + } + } + } + }, "/reset/confirm": { "put": { "description": "Do confirmation to reset password", @@ -2102,6 +2351,134 @@ } } }, + "quiz.AnswerOption": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_solution": { + "type": "boolean" + }, + "media_id": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "quiz.FinishQuizPayload": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/quiz.Response" + } + } + } + }, + "quiz.Quiz": { + "type": "object", + "properties": { + "course_id": { + "type": "string" + }, + "creator_email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "nama": { + "type": "string" + } + } + }, + "quiz.QuizDetail": { + "type": "object", + "properties": { + "course_id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "help": { + "type": "string" + }, + "id": { + "type": "string" + }, + "media": { + "type": "array", + "items": { + "$ref": "#/definitions/quiz.QuizMedia" + } + }, + "name": { + "type": "string" + }, + "problems": { + "type": "array", + "items": { + "$ref": "#/definitions/quiz.QuizProblem" + } + } + } + }, + "quiz.QuizMedia": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "quiz.QuizProblem": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/definitions/quiz.AnswerOption" + } + }, + "id": { + "type": "string" + }, + "media_id": { + "type": "array", + "items": { + "type": "string" + } + }, + "question": { + "type": "string" + } + } + }, + "quiz.Response": { + "type": "object", + "properties": { + "answer_id": { + "type": "string" + }, + "problem_id": { + "type": "string" + } + } + }, "refresh.RefreshResponsePayload": { "description": "Refresh endpoint response when process success", "type": "object", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 8356f559d011d170cbb138f0e37af8c463827c83..58ea9348fb473426c5890cff161bd98360f62bf0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -298,6 +298,89 @@ definitions: upload_link: type: string type: object + quiz.AnswerOption: + properties: + answer: + type: string + id: + type: string + is_solution: + type: boolean + media_id: + items: + type: string + type: array + type: object + quiz.FinishQuizPayload: + properties: + data: + items: + $ref: '#/definitions/quiz.Response' + type: array + type: object + quiz.Quiz: + properties: + course_id: + type: string + creator_email: + type: string + id: + type: string + nama: + type: string + type: object + quiz.QuizDetail: + properties: + course_id: + type: string + description: + type: string + help: + type: string + id: + type: string + media: + items: + $ref: '#/definitions/quiz.QuizMedia' + type: array + name: + type: string + problems: + items: + $ref: '#/definitions/quiz.QuizProblem' + type: array + type: object + quiz.QuizMedia: + properties: + id: + type: string + type: + type: string + url: + type: string + type: object + quiz.QuizProblem: + properties: + answers: + items: + $ref: '#/definitions/quiz.AnswerOption' + type: array + id: + type: string + media_id: + items: + type: string + type: array + question: + type: string + type: object + quiz.Response: + properties: + answer_id: + type: string + problem_id: + type: string + type: object refresh.RefreshResponsePayload: description: Refresh endpoint response when process success properties: @@ -917,6 +1000,35 @@ paths: summary: Get materials tags: - content + /course/{id}/quiz: + get: + consumes: + - application/json + description: Get all cours + parameters: + - description: Course id + format: uuid + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/web.BaseResponse' + - properties: + data: + items: + $ref: '#/definitions/quiz.Quiz' + type: array + type: object + summary: Get Course quiz + tags: + - course /course/faculty: get: description: Retrieves a list of all faculties @@ -1332,6 +1444,32 @@ paths: summary: Delete material tags: - content + get: + consumes: + - application/json + description: Get material detail + parameters: + - description: Material id + example: IF3270 + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/web.BaseResponse' + - properties: + data: + $ref: '#/definitions/material.Material' + type: object + summary: Get material detail + tags: + - content post: consumes: - application/json @@ -1410,6 +1548,103 @@ paths: summary: Delete Content tags: - content + /quiz/{id}: + get: + consumes: + - application/json + description: Get Quiz Detail + parameters: + - description: Quiz id + format: uuid + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/web.BaseResponse' + - properties: + data: + $ref: '#/definitions/quiz.Quiz' + type: object + summary: Get Quiz Detail + tags: + - quiz + /quiz/{id}/solution: + get: + consumes: + - application/json + description: Take a quiz + parameters: + - description: Authenticate User (any role) + in: header + name: Authorization + required: true + type: string + - description: Quiz id + format: uuid + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/web.BaseResponse' + - properties: + data: + $ref: '#/definitions/quiz.QuizDetail' + type: object + summary: Get Quiz Solution + tags: + - quiz + /quiz/{id}/take: + post: + consumes: + - application/json + description: Finish quiz session and get the score + parameters: + - description: Authenticate User (any role) + in: header + name: Authorization + required: true + type: string + - description: Quiz Finish payload + in: body + name: data + required: true + schema: + $ref: '#/definitions/quiz.FinishQuizPayload' + - description: Quiz id + format: uuid + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/web.BaseResponse' + - properties: + data: + $ref: '#/definitions/quiz.QuizDetail' + type: object + summary: Finish Quiz + tags: + - quiz /reset/confirm: put: description: Do confirmation to reset password diff --git a/handler/di.go b/handler/di.go index 643a1fcfdc45a1bb37cd1e2e197932b5e320cb55..399ef81ecbb23c9d70b0431c6129610c92d5dadc 100644 --- a/handler/di.go +++ b/handler/di.go @@ -7,6 +7,7 @@ import ( "gitlab.informatika.org/ocw/ocw-backend/handler/common" "gitlab.informatika.org/ocw/ocw-backend/handler/course" "gitlab.informatika.org/ocw/ocw-backend/handler/material" + "gitlab.informatika.org/ocw/ocw-backend/handler/quiz" "gitlab.informatika.org/ocw/ocw-backend/handler/reset" "gitlab.informatika.org/ocw/ocw-backend/handler/swagger" ) @@ -39,4 +40,8 @@ var HandlerSet = wire.NewSet( // Material wire.Struct(new(material.MaterialHandlerImpl), "*"), wire.Bind(new(material.MaterialHandler), new(*material.MaterialHandlerImpl)), + + // Quiz + wire.Struct(new(quiz.QuizHandlerImpl), "*"), + wire.Bind(new(quiz.QuizHandler), new(*quiz.QuizHandlerImpl)), ) diff --git a/handler/material/detail_material.go b/handler/material/detail_material.go new file mode 100644 index 0000000000000000000000000000000000000000..87c428d09d68ddea8b6741dac355083f0082125b --- /dev/null +++ b/handler/material/detail_material.go @@ -0,0 +1,57 @@ +package material + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "gitlab.informatika.org/ocw/ocw-backend/model/web" +) + +// Index godoc +// +// @Tags content +// @Summary Get material detail +// @Description Get material detail +// @Produce json +// @Accept json +// @Param id path string true "Material id" example(IF3270) +// @Success 200 {object} web.BaseResponse{data=material.Material} +// @Router /material/{id} [get] +func (m MaterialHandlerImpl) DetailMaterial(w http.ResponseWriter, r *http.Request) { + idString := chi.URLParam(r, "material-id") + + if idString == "" { + payload := m.WrapperUtil.ErrorResponseWrap("material id is required", nil) + m.HttpUtil.WriteJson(w, http.StatusUnsupportedMediaType, payload) + return + } + + id, err := uuid.Parse(idString) + + if err != nil { + // invalid uuid + payload := m.WrapperUtil.ErrorResponseWrap(err.Error(), nil) + m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + return + } + + res, err := m.MaterialService.GetById(id) + + if err != nil { + respErr, ok := err.(web.ResponseError) + if ok { + payload := m.WrapperUtil.ErrorResponseWrap(respErr.Error(), respErr) + + m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + } else { + payload := m.WrapperUtil.ErrorResponseWrap("internal server error", nil) + m.HttpUtil.WriteJson(w, http.StatusInternalServerError, payload) + } + return + } + + responsePayload := m.WrapperUtil.SuccessResponseWrap(res) + m.HttpUtil.WriteSuccessJson(w, responsePayload) + +} diff --git a/handler/material/get_material.go b/handler/material/get_material.go index bc1009f92b06b087eeee36b8806257a5f5af0558..0ce5b5ab59c0aff2c604bb3965093ff3e9d242aa 100644 --- a/handler/material/get_material.go +++ b/handler/material/get_material.go @@ -33,11 +33,7 @@ func (m MaterialHandlerImpl) GetMaterial(w http.ResponseWriter, r *http.Request) if ok { payload := m.WrapperUtil.ErrorResponseWrap(respErr.Error(), respErr) - if respErr.Code != "NOT_OWNER" { - m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) - } else { - m.HttpUtil.WriteJson(w, http.StatusForbidden, payload) - } + m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) } else { payload := m.WrapperUtil.ErrorResponseWrap("internal server error", nil) m.HttpUtil.WriteJson(w, http.StatusInternalServerError, payload) diff --git a/handler/material/impl.go b/handler/material/impl.go index 647ce941833b3c696b66271c41dd2565bb23a874..f8aab59ca45cb8ce12796599e6b197e1a0d8c418 100644 --- a/handler/material/impl.go +++ b/handler/material/impl.go @@ -12,7 +12,7 @@ type MaterialHandlerImpl struct { material.MaterialService material.MaterialContentService httputil.HttpUtil + logger.Logger wrapper.WrapperUtil course.CourseRepository - logger.Logger } diff --git a/handler/material/types.go b/handler/material/types.go index 1c7cb4534dbb5780a6e3fbe5bd38f31d7bbb6369..8d968e0f8332c295dcc480310efa3461457d8654 100644 --- a/handler/material/types.go +++ b/handler/material/types.go @@ -7,6 +7,7 @@ type MaterialHandler interface { DeleteContent(w http.ResponseWriter, r *http.Request) CreateMaterial(w http.ResponseWriter, r *http.Request) + DetailMaterial(w http.ResponseWriter, r *http.Request) DeleteMaterial(w http.ResponseWriter, r *http.Request) GetMaterial(w http.ResponseWriter, r *http.Request) } diff --git a/handler/quiz/get.go b/handler/quiz/get.go new file mode 100644 index 0000000000000000000000000000000000000000..799ca822a991240a0c6febccb29640720f1b5e67 --- /dev/null +++ b/handler/quiz/get.go @@ -0,0 +1,57 @@ +package quiz + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "gitlab.informatika.org/ocw/ocw-backend/model/web" +) + +// Index godoc +// +// @Tags quiz +// @Summary Get Quiz Detail +// @Description Get Quiz Detail +// @Produce json +// @Accept json +// @Param id path string true "Quiz id" Format(uuid) +// @Success 200 {object} web.BaseResponse{data=quiz.Quiz} +// @Router /quiz/{id} [get] +func (m QuizHandlerImpl) GetQuizDetail(w http.ResponseWriter, r *http.Request) { + quizId := chi.URLParam(r, "id") + + if quizId == "" { + payload := m.WrapperUtil.ErrorResponseWrap("quiz id is required", nil) + m.HttpUtil.WriteJson(w, http.StatusUnsupportedMediaType, payload) + return + } + + id, err := uuid.Parse(quizId) + + if err != nil { + // invalid uuid + payload := m.WrapperUtil.ErrorResponseWrap(err.Error(), nil) + m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + return + } + + result, err := m.QuizService.GetQuizDetail(id) + + if err != nil { + respErr, ok := err.(web.ResponseError) + if ok { + payload := m.WrapperUtil.ErrorResponseWrap(respErr.Error(), respErr) + + m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + } else { + payload := m.WrapperUtil.ErrorResponseWrap("internal server error", nil) + m.HttpUtil.WriteJson(w, http.StatusInternalServerError, payload) + } + return + } + + responsePayload := m.WrapperUtil.SuccessResponseWrap(result) + m.HttpUtil.WriteSuccessJson(w, responsePayload) + +} diff --git a/handler/quiz/impl.go b/handler/quiz/impl.go new file mode 100644 index 0000000000000000000000000000000000000000..f95f7e5f0ca56fe2c605c5c3692eb5c4dab1008a --- /dev/null +++ b/handler/quiz/impl.go @@ -0,0 +1,15 @@ +package quiz + +import ( + "gitlab.informatika.org/ocw/ocw-backend/service/logger" + "gitlab.informatika.org/ocw/ocw-backend/service/quiz" + "gitlab.informatika.org/ocw/ocw-backend/utils/httputil" + "gitlab.informatika.org/ocw/ocw-backend/utils/wrapper" +) + +type QuizHandlerImpl struct { + quiz.QuizService + wrapper.WrapperUtil + httputil.HttpUtil + logger.Logger +} diff --git a/handler/quiz/list.go b/handler/quiz/list.go new file mode 100644 index 0000000000000000000000000000000000000000..544894fcc9985ee8ebea4f75fd3032da1ff0fd2d --- /dev/null +++ b/handler/quiz/list.go @@ -0,0 +1,50 @@ +package quiz + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "gitlab.informatika.org/ocw/ocw-backend/model/web" +) + +// Index godoc +// +// @Tags course +// @Summary Get Course quiz +// @Description Get all cours +// @Produce json +// @Accept json +// @Param id path string true "Course id" Format(uuid) +// @Success 200 {object} web.BaseResponse{data=[]quiz.Quiz} +// @Router /course/{id}/quiz [get] +func (m QuizHandlerImpl) GetAllQuizes(w http.ResponseWriter, r *http.Request) { + courseId := chi.URLParam(r, "id") + + if courseId == "" { + payload := m.WrapperUtil.ErrorResponseWrap("course id is required", nil) + m.HttpUtil.WriteJson(w, http.StatusUnsupportedMediaType, payload) + return + } + + result, err := m.QuizService.ListAllQuiz(courseId) + + if err != nil { + respErr, ok := err.(web.ResponseError) + if ok { + payload := m.WrapperUtil.ErrorResponseWrap(respErr.Error(), respErr) + + if respErr.Code != "NOT_OWNER" { + m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + } else { + m.HttpUtil.WriteJson(w, http.StatusForbidden, payload) + } + } else { + payload := m.WrapperUtil.ErrorResponseWrap("internal server error", nil) + m.HttpUtil.WriteJson(w, http.StatusInternalServerError, payload) + } + return + } + + responsePayload := m.WrapperUtil.SuccessResponseWrap(result) + m.HttpUtil.WriteSuccessJson(w, responsePayload) +} diff --git a/handler/quiz/take.go b/handler/quiz/take.go new file mode 100644 index 0000000000000000000000000000000000000000..530f4f77823001b1a5027b76d610b8c9ccafcaad --- /dev/null +++ b/handler/quiz/take.go @@ -0,0 +1,219 @@ +package quiz + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + "github.com/google/uuid" + "gitlab.informatika.org/ocw/ocw-backend/middleware/guard" + "gitlab.informatika.org/ocw/ocw-backend/model/web" + authToken "gitlab.informatika.org/ocw/ocw-backend/model/web/auth/token" + "gitlab.informatika.org/ocw/ocw-backend/model/web/quiz" +) + +// Index godoc +// +// @Tags quiz +// @Summary Take Quiz +// @Description Take a quiz +// @Produce json +// @Accept json +// @Param Authorization header string true "Authenticate User (any role)" +// @Param id path string true "Quiz id" Format(uuid) +// @Success 200 {object} web.BaseResponse{data=quiz.QuizDetail} +// @Router /quiz/{id}/take [post] +func (m QuizHandlerImpl) TakeQuiz(w http.ResponseWriter, r *http.Request) { + rawQuizId := chi.URLParam(r, "id") + + if rawQuizId == "" { + payload := m.WrapperUtil.ErrorResponseWrap("quiz id is required", nil) + m.HttpUtil.WriteJson(w, http.StatusUnsupportedMediaType, payload) + return + } + + quizId, err := uuid.Parse(rawQuizId) + + if err != nil { + payload := m.WrapperUtil.ErrorResponseWrap("quiz id is not valid", nil) + m.HttpUtil.WriteJson(w, http.StatusUnsupportedMediaType, payload) + return + } + + user, ok := r.Context().Value(guard.UserContext).(authToken.UserClaim) + + if !ok { + m.Logger.Error("Context is not found") + payload := m.WrapperUtil.ErrorResponseWrap("internal server error", nil) + m.HttpUtil.WriteJson(w, http.StatusInternalServerError, payload) + return + } + + detail, err := m.DoTakeQuiz(r.Context(), quizId, user.Email) + + if err != nil { + respErr, ok := err.(web.ResponseError) + if ok { + payload := m.WrapperUtil.ErrorResponseWrap(respErr.Error(), respErr) + m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + } else { + payload := m.WrapperUtil.ErrorResponseWrap("internal server error", nil) + m.HttpUtil.WriteJson(w, http.StatusInternalServerError, payload) + } + return + } + + responsePayload := m.WrapperUtil.SuccessResponseWrap(detail) + m.HttpUtil.WriteSuccessJson(w, responsePayload) +} + +// Index godoc +// +// @Tags quiz +// @Summary Get Quiz Solution +// @Description Take a quiz +// @Produce json +// @Accept json +// @Param Authorization header string true "Authenticate User (any role)" +// @Param id path string true "Quiz id" Format(uuid) +// @Success 200 {object} web.BaseResponse{data=quiz.QuizDetail} +// @Router /quiz/{id}/solution [get] +func (m QuizHandlerImpl) GetQuizSolution(w http.ResponseWriter, r *http.Request) { + rawQuizId := chi.URLParam(r, "id") + + if rawQuizId == "" { + payload := m.WrapperUtil.ErrorResponseWrap("quiz id is required", nil) + m.HttpUtil.WriteJson(w, http.StatusUnsupportedMediaType, payload) + return + } + + quizId, err := uuid.Parse(rawQuizId) + + if err != nil { + payload := m.WrapperUtil.ErrorResponseWrap("quiz id is not valid", nil) + m.HttpUtil.WriteJson(w, http.StatusUnsupportedMediaType, payload) + return + } + + user, ok := r.Context().Value(guard.UserContext).(authToken.UserClaim) + + if !ok { + m.Logger.Error("Context is not found") + payload := m.WrapperUtil.ErrorResponseWrap("internal server error", nil) + m.HttpUtil.WriteJson(w, http.StatusInternalServerError, payload) + return + } + + detail, err := m.GetSolutionQuiz(r.Context(), quizId, user.Email) + + if err != nil { + respErr, ok := err.(web.ResponseError) + if ok { + if respErr.Code == "ERR_NOT_ALLOWED" { + payload := m.WrapperUtil.ErrorResponseWrap(respErr.Error(), respErr) + m.HttpUtil.WriteJson(w, http.StatusForbidden, payload) + } else { + payload := m.WrapperUtil.ErrorResponseWrap(respErr.Error(), respErr) + m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + } + } else { + payload := m.WrapperUtil.ErrorResponseWrap("internal server error", nil) + m.HttpUtil.WriteJson(w, http.StatusInternalServerError, payload) + } + return + } + + responsePayload := m.WrapperUtil.SuccessResponseWrap(detail) + m.HttpUtil.WriteSuccessJson(w, responsePayload) +} + +// Index godoc +// +// @Tags quiz +// @Summary Finish Quiz +// @Description Finish quiz session and get the score +// @Produce json +// @Accept json +// @Param Authorization header string true "Authenticate User (any role)" +// @Param data body quiz.FinishQuizPayload true "Quiz Finish payload" +// @Param id path string true "Quiz id" Format(uuid) +// @Success 200 {object} web.BaseResponse{data=quiz.QuizDetail} +// @Router /quiz/{id}/take [post] +func (m QuizHandlerImpl) FinishQuiz(w http.ResponseWriter, r *http.Request) { + payload := quiz.FinishQuizPayload{} + + /* Get user */ + user, ok := r.Context().Value(guard.UserContext).(authToken.UserClaim) + + if !ok { + m.Logger.Error("Context is not found") + payload := m.WrapperUtil.ErrorResponseWrap("internal server error", nil) + m.HttpUtil.WriteJson(w, http.StatusInternalServerError, payload) + return + } + /* Get user */ + + /* Validate input */ + validate := validator.New() + + if r.Header.Get("Content-Type") != "application/json" { + payload := m.WrapperUtil.ErrorResponseWrap("this service only receive json input", nil) + m.HttpUtil.WriteJson(w, http.StatusUnsupportedMediaType, payload) + return + } + + if err := m.HttpUtil.ParseJson(r, &payload); err != nil { + payload := m.WrapperUtil.ErrorResponseWrap("invalid json input", err.Error()) + m.HttpUtil.WriteJson(w, http.StatusUnprocessableEntity, payload) + return + } + + if err := validate.Struct(payload); err != nil { + if _, ok := err.(*validator.InvalidValidationError); ok { + payload := m.WrapperUtil.ErrorResponseWrap(err.Error(), nil) + m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + return + } + + errPayload := web.NewResponseErrorFromValidator(err.(validator.ValidationErrors)) + payload := m.WrapperUtil.ErrorResponseWrap(errPayload.Error(), errPayload) + m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + return + } + /* End of validate */ + + /* Get quiz id */ + rawQuizId := chi.URLParam(r, "id") + + if rawQuizId == "" { + payload := m.WrapperUtil.ErrorResponseWrap("quiz id is required", nil) + m.HttpUtil.WriteJson(w, http.StatusUnsupportedMediaType, payload) + return + } + + quizId, err := uuid.Parse(rawQuizId) + + if err != nil { + payload := m.WrapperUtil.ErrorResponseWrap("quiz id is not valid", nil) + m.HttpUtil.WriteJson(w, http.StatusUnsupportedMediaType, payload) + return + } + /* end of get quiz id */ + + res, err := m.DoFinishQuiz(r.Context(), quizId, user.Email, payload.Data) + + if err != nil { + respErr, ok := err.(web.ResponseError) + if ok { + payload := m.WrapperUtil.ErrorResponseWrap(respErr.Error(), respErr) + m.HttpUtil.WriteJson(w, http.StatusBadRequest, payload) + } else { + payload := m.WrapperUtil.ErrorResponseWrap("internal server error", nil) + m.HttpUtil.WriteJson(w, http.StatusInternalServerError, payload) + } + return + } + + responsePayload := m.WrapperUtil.SuccessResponseWrap(res) + m.HttpUtil.WriteSuccessJson(w, responsePayload) +} diff --git a/handler/quiz/type.go b/handler/quiz/type.go new file mode 100644 index 0000000000000000000000000000000000000000..8c3bf6f5567cf7f04be9e42b67558279a0f6159d --- /dev/null +++ b/handler/quiz/type.go @@ -0,0 +1,12 @@ +package quiz + +import "net/http" + +type QuizHandler interface { + GetAllQuizes(w http.ResponseWriter, r *http.Request) + GetQuizDetail(w http.ResponseWriter, r *http.Request) + + TakeQuiz(w http.ResponseWriter, r *http.Request) + GetQuizSolution(w http.ResponseWriter, r *http.Request) + FinishQuiz(w http.ResponseWriter, r *http.Request) +} diff --git a/middleware/guard/builder.go b/middleware/guard/builder.go index 75850ebf45a8ef08662c76311156cd2a067a4ff3..c0f06a9e6c2324734c1db6f64e5b5eb8e34f46d8 100644 --- a/middleware/guard/builder.go +++ b/middleware/guard/builder.go @@ -10,7 +10,9 @@ import ( ) type GuardBuilder struct { - GuardMiddleware + token.TokenUtil + logger.Logger + wrapper.WrapperUtil } func NewBuilder( @@ -19,25 +21,19 @@ func NewBuilder( wrapper wrapper.WrapperUtil, ) *GuardBuilder { return &GuardBuilder{ - GuardMiddleware{ - Token: token, - Role: []user.UserRole{}, - Logger: logger, - WrapperUtil: wrapper, - }, + token, + logger, + wrapper, } } -func (g *GuardBuilder) AddRole(role ...user.UserRole) *GuardBuilder { - g.GuardMiddleware.Role = role - return g -} - -func (g *GuardBuilder) Build() func(http.Handler) http.Handler { - return g.GuardMiddleware.Handle -} +func (g *GuardBuilder) Build(role ...user.UserRole) func(http.Handler) http.Handler { + handler := &GuardMiddleware{ + Token: g.TokenUtil, + Role: role, + Logger: g.Logger, + WrapperUtil: g.WrapperUtil, + } -func (g *GuardBuilder) BuildSimple(role user.UserRole) func(http.Handler) http.Handler { - g.AddRole(role) - return g.Build() + return handler.Handle } diff --git a/model/domain/quiz/options.go b/model/domain/quiz/options.go deleted file mode 100644 index d2bdddbf7da440ce8b48385cd7a5d5b0f118bcef..0000000000000000000000000000000000000000 --- a/model/domain/quiz/options.go +++ /dev/null @@ -1,14 +0,0 @@ -package quiz - -import "github.com/google/uuid" - -type AnswerOption struct { - Id uuid.UUID `gorm:"primaryKey" json:"id"` - QuizProblemId uuid.UUID `gorm:"primaryKey" json:"problem_id"` - Statement string `json:"statement"` - IsAnswer bool `json:"isAnswer"` -} - -func (AnswerOption) TableName() string { - return "quiz_choice_answer" -} diff --git a/model/domain/quiz/problem.go b/model/domain/quiz/problem.go new file mode 100644 index 0000000000000000000000000000000000000000..7794b7f4355177f0ce5a948d11c6ff3f62aa89de --- /dev/null +++ b/model/domain/quiz/problem.go @@ -0,0 +1,38 @@ +package quiz + +import "github.com/google/uuid" + +type QuizMedia struct { + Id uuid.UUID `json:"id"` + Url string `json:"url"` + Type string `json:"type"` +} + +type AnswerOption struct { + Id uuid.UUID `json:"id"` + MediaId []uuid.UUID `json:"media_id"` + Answer string `json:"answer"` + IsSolution *bool `json:"is_solution"` +} + +type QuizProblem struct { + Id uuid.UUID `json:"id"` + MediaId []uuid.UUID `json:"media_id"` + Question string `json:"question"` + Answer []AnswerOption `json:"answers"` +} + +type QuizDetail struct { + Id uuid.UUID `json:"id"` + Name string `json:"name"` + CourseId string `json:"course_id"` + Description string `json:"description"` + Help string `json:"help"` + Media []QuizMedia `json:"media"` + Problems []QuizProblem `json:"problems"` +} + +type Response struct { + ProblemId uuid.UUID `json:"problem_id"` + AnswerId uuid.UUID `json:"answer_id"` +} diff --git a/model/domain/quiz/problem_type.go b/model/domain/quiz/problem_type.go deleted file mode 100644 index c03621526c635dc524f1caee1518665931b80d10..0000000000000000000000000000000000000000 --- a/model/domain/quiz/problem_type.go +++ /dev/null @@ -1,67 +0,0 @@ -package quiz - -import ( - "database/sql/driver" - "encoding/json" - "errors" - "fmt" -) - -type ProblemType int - -const ( - Choice ProblemType = iota -) - -var roleMapping = map[ProblemType]string{ - Choice: "choice", -} - -func (ur *ProblemType) Scan(value interface{}) error { - val := value.(string) - - for key, label := range roleMapping { - if label == val { - *ur = key - return nil - } - } - - return fmt.Errorf("invalid user role") -} - -func (u ProblemType) Value() (driver.Value, error) { - value, ok := roleMapping[u] - - if !ok { - return nil, fmt.Errorf("invalid user role") - } - - return value, nil -} - -func (u *ProblemType) UnmarshalJSON(b []byte) error { - var s string - if err := json.Unmarshal(b, &s); err != nil { - return err - } - - for key, label := range roleMapping { - if label == s { - *u = key - return nil - } - } - - return fmt.Errorf("unkown role, given %s", s) -} - -func (u ProblemType) MarshalJSON() ([]byte, error) { - s, ok := roleMapping[u] - - if !ok { - return nil, errors.New("unkown user role") - } - - return json.Marshal(s) -} diff --git a/model/domain/quiz/quiz.go b/model/domain/quiz/quiz.go index f4dcce5a8155d1c0a670dd962bd549233d95b2d8..896536cc66c3ffbca9840836c446019b139e8074 100644 --- a/model/domain/quiz/quiz.go +++ b/model/domain/quiz/quiz.go @@ -2,18 +2,14 @@ package quiz import ( "github.com/google/uuid" - "gitlab.informatika.org/ocw/ocw-backend/model/domain/course" - "gitlab.informatika.org/ocw/ocw-backend/model/domain/user" ) type Quiz struct { - Id uuid.UUID `gorm:"primaryKey" json:"id"` - Name string `json:"name"` - CourseId string `json:"course_id"` - CreatorEmail string `json:"creator_email"` - Creator user.User `gorm:"foreignKey:CreatorEmail;references:Email" json:"creator"` - Course course.Course `gorm:"foreignKey:CourseId;references:Id" json:"course"` - Problems []QuizProblem `gorm:"foreignKey:QuizId;references:Id" json:"problems"` + Id uuid.UUID `gorm:"primaryKey" json:"id"` + Name string `json:"nama"` + CourseId string `json:"course_id"` + CreatorEmail string `json:"creator_email"` + QuizPath string `json:"-"` } func (Quiz) TableName() string { diff --git a/model/domain/quiz/quiz_problem.go b/model/domain/quiz/quiz_problem.go deleted file mode 100644 index 1bcdcf880ad9eaeeadd164cf60d253fd9eedb8db..0000000000000000000000000000000000000000 --- a/model/domain/quiz/quiz_problem.go +++ /dev/null @@ -1,15 +0,0 @@ -package quiz - -import "github.com/google/uuid" - -type QuizProblem struct { - Id uuid.UUID `gorm:"primaryKey" json:"id"` - Statement string `json:"statement"` - Type ProblemType `json:"type"` - QuizId uuid.UUID `json:"quiz_id"` - Options []AnswerOption `gorm:"foreignKey:QuizProblemId;references:Id" json:"options"` -} - -func (QuizProblem) TableName() string { - return "quiz_problem" -} diff --git a/model/domain/quiz/take.go b/model/domain/quiz/take.go index 2c707ab601da28e630420015393607aef0f9d228..399989d2db63432372643f615caecafee0cbc896 100644 --- a/model/domain/quiz/take.go +++ b/model/domain/quiz/take.go @@ -1,22 +1,18 @@ package quiz import ( - "os/user" "time" "github.com/google/uuid" ) type QuizTake struct { - Id uuid.UUID `gorm:"primaryKey" json:"id"` - QuizId uuid.UUID `json:"quiz_id"` - Email string `json:"email"` - StartTime time.Time `json:"start"` - IsFinished bool `json:"finished"` - Score int `json:"score"` - Quiz `gorm:"foreignKey:QuizId;references:Id" json:"quiz"` - user.User `gorm:"foreignKey:Email;references:Email" json:"user"` - ChoiceAnswers []TakeChoiceAnswer `gorm:"foreignKey:QuizTakeId;references:Id" json:"-"` + Id uuid.UUID `gorm:"primaryKey" json:"id"` + QuizId uuid.UUID `json:"quiz_id"` + Email string `json:"email"` + StartTime time.Time `json:"start"` + IsFinished bool `json:"finished"` + Score int `json:"score"` } func (QuizTake) TableName() string { diff --git a/model/domain/quiz/take_choice_answer.go b/model/domain/quiz/take_choice_answer.go deleted file mode 100644 index 25e76746d8951251d3dbdee694986205ce364d53..0000000000000000000000000000000000000000 --- a/model/domain/quiz/take_choice_answer.go +++ /dev/null @@ -1,14 +0,0 @@ -package quiz - -import "github.com/google/uuid" - -type TakeChoiceAnswer struct { - QuizTakeId uuid.UUID `gorm:"primaryKey"` - AnswerChoice uuid.UUID - QuizProblemId uuid.UUID `gorm:"primaryKey"` - AnswerOption `gorm:"foreignKey:AnswerChoice,QuizProblemId;references:Id,QuizProblemId"` -} - -func (TakeChoiceAnswer) TableName() string { - return "quiz_take_choice_answer" -} diff --git a/model/web/quiz/finish.go b/model/web/quiz/finish.go new file mode 100644 index 0000000000000000000000000000000000000000..80c7335963d5977527460cf5ada30201865723e4 --- /dev/null +++ b/model/web/quiz/finish.go @@ -0,0 +1,7 @@ +package quiz + +import "gitlab.informatika.org/ocw/ocw-backend/model/domain/quiz" + +type FinishQuizPayload struct { + Data []quiz.Response `json:"data"` +} diff --git a/provider/storage/manager.go b/provider/storage/manager.go index cc87edd73acda90734470345e5fd3ea1230a1280..cfeae57144fe4d23729a2382984360253e5d4659 100644 --- a/provider/storage/manager.go +++ b/provider/storage/manager.go @@ -9,3 +9,16 @@ import ( func (s S3Storage) Delete(ctx context.Context, path string) error { return s.minio.RemoveObject(ctx, s.env.BucketName, path, minio.RemoveObjectOptions{}) } + +func (s S3Storage) Get(ctx context.Context, path string) ([]byte, error) { + result := []byte{} + obj, err := s.minio.GetObject(ctx, s.env.BucketName, path, minio.GetObjectOptions{}) + + if err != nil { + return result, err + } + + _, err = obj.Read(result) + + return result, err +} diff --git a/provider/storage/type.go b/provider/storage/type.go index dc7cdd2f97597d8760d26aa2b75791989a12ff9f..19f2a46fb4d9044839660ba4234f47f5f661f0d2 100644 --- a/provider/storage/type.go +++ b/provider/storage/type.go @@ -9,4 +9,5 @@ type Storage interface { CreatePutSignedLink(ctx context.Context, path string) (string, error) CreateGetSignedLink(ctx context.Context, path string, reqParam url.Values) (string, error) Delete(ctx context.Context, path string) error + Get(ctx context.Context, path string) ([]byte, error) } diff --git a/repository/di.go b/repository/di.go index 9136c031788ba4f56d8d3bfe4c46f2864b641571..b564bd5406f7d94427c5c0f80ccc518bc23fd5b2 100644 --- a/repository/di.go +++ b/repository/di.go @@ -2,12 +2,13 @@ package repository import ( "github.com/google/wire" - "gitlab.informatika.org/ocw/ocw-backend/repository/user" - "gitlab.informatika.org/ocw/ocw-backend/repository/course" "gitlab.informatika.org/ocw/ocw-backend/repository/cache" "gitlab.informatika.org/ocw/ocw-backend/repository/content" + "gitlab.informatika.org/ocw/ocw-backend/repository/course" "gitlab.informatika.org/ocw/ocw-backend/repository/material" + "gitlab.informatika.org/ocw/ocw-backend/repository/quiz" "gitlab.informatika.org/ocw/ocw-backend/repository/transaction" + "gitlab.informatika.org/ocw/ocw-backend/repository/user" ) var RepositoryBasicSet = wire.NewSet( @@ -37,6 +38,9 @@ var RepositoryBasicSet = wire.NewSet( transaction.NewBuilder, wire.Bind(new(transaction.Transaction), new(*transaction.TransactionRepositoryImpl)), wire.Bind(new(transaction.TransactionBuilder), new(*transaction.TransactionBuilderImpl)), + + quiz.New, + wire.Bind(new(quiz.QuizRepository), new(*quiz.QuizRepositoryImpl)), ) var RepositorySet = wire.NewSet( diff --git a/repository/material/material.go b/repository/material/material.go index 9cecd65f3fec546c254cfd731fde21660b4f684e..87cbd435c379d505d4be048f8d1d5e878ffba019 100644 --- a/repository/material/material.go +++ b/repository/material/material.go @@ -20,6 +20,12 @@ func NewMaterial( return &MaterialRepositoryImpl{builder, db.Connect()} } +func (m MaterialRepositoryImpl) Get(materialId uuid.UUID) (*material.Material, error) { + res := &material.Material{} + err := m.db.Preload("Contents").Where("id = ?", materialId).Find(res).Error + return res, err +} + func (m MaterialRepositoryImpl) IsUserContributor(id uuid.UUID, email string) (bool, error) { err := m.db.Where("creator_email = ? AND id = ?", email, id).Find(&material.Material{}).Error if err != nil { diff --git a/repository/material/type.go b/repository/material/type.go index 4a0d0920746e05e4ccdfead1096db39dda8402db..4014763d006c08c2eabeb294a8f043ae023d0b17 100644 --- a/repository/material/type.go +++ b/repository/material/type.go @@ -9,6 +9,7 @@ import ( type MaterialRepository interface { New(courseId string, creatorEmail string, name string) (uuid.UUID, error) Delete(id uuid.UUID) error + Get(materialId uuid.UUID) (*material.Material, error) GetAll(courseId string) ([]material.Material, error) IsUserContributor(id uuid.UUID, email string) (bool, error) diff --git a/repository/quiz/impl.go b/repository/quiz/impl.go new file mode 100644 index 0000000000000000000000000000000000000000..e51495422f1a10911dd47bf00b1764eb541446ab --- /dev/null +++ b/repository/quiz/impl.go @@ -0,0 +1,97 @@ +package quiz + +import ( + "errors" + "time" + + "github.com/google/uuid" + "gitlab.informatika.org/ocw/ocw-backend/model/domain/quiz" + "gitlab.informatika.org/ocw/ocw-backend/model/web" + "gitlab.informatika.org/ocw/ocw-backend/provider/db" + "gorm.io/gorm" +) + +type QuizRepositoryImpl struct { + db *gorm.DB +} + +func New( + db db.Database, +) *QuizRepositoryImpl { + return &QuizRepositoryImpl{db.Connect()} +} + +func (q *QuizRepositoryImpl) GetQuizes(courseId string) ([]quiz.Quiz, error) { + result := &[]quiz.Quiz{} + err := q.db.Where("course_id = ?", courseId).Find(result).Error + + return *result, err +} + +func (q *QuizRepositoryImpl) GetQuizDetail(quizId uuid.UUID) (*quiz.Quiz, error) { + result := &quiz.Quiz{} + err := q.db.Where("id = ?", quizId).First(result).Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, web.NewResponseError("Record not found", "ERR_NOT_FOUND") + } + + return result, nil +} + +func (q *QuizRepositoryImpl) UpdateScore(takeId uuid.UUID, score int) error { + return q.db. + Model(&quiz.QuizTake{}). + Update("score", score). + Update("is_finished", true). + Where("id = ?", takeId).Error +} + +func (q *QuizRepositoryImpl) NewTake(quizId uuid.UUID, userEmail string) (uuid.UUID, error) { + id := uuid.New() + err := q.db.Create( + &quiz.QuizTake{ + Id: id, + Email: userEmail, + StartTime: time.Now(), + QuizId: quizId, + IsFinished: false, + Score: 0, + }, + ).Error + + return id, err +} + +func (q *QuizRepositoryImpl) IsActiveTake(quizId uuid.UUID, userEmail string) (bool, error) { + result := struct{ cnt int }{} + err := q.db. + Select("COUNT(*) as cnt"). + Where("quiz_id = ? AND email = ? AND is_finished = false", quizId, userEmail). + Find(result). + Error + + if err != nil { + return false, nil + } + + return result.cnt > 0, nil +} + +func (q *QuizRepositoryImpl) GetAllTake(quizId uuid.UUID, userEmail string) ([]quiz.QuizTake, error) { + result := []quiz.QuizTake{} + err := q.db. + Where("quiz_id = ? AND email = ?", quizId, userEmail). + Find(result).Error + + return result, err +} + +func (q *QuizRepositoryImpl) GetLastTake(quizId uuid.UUID, userEmail string) (*quiz.QuizTake, error) { + result := &quiz.QuizTake{} + err := q.db. + Where("quiz_id = ? AND email = ?", quizId, userEmail). + Last(result).Error + + return result, err +} diff --git a/repository/quiz/type.go b/repository/quiz/type.go new file mode 100644 index 0000000000000000000000000000000000000000..1b4e0cbeca391ec136dfc8c696d1c058d49e55e4 --- /dev/null +++ b/repository/quiz/type.go @@ -0,0 +1,16 @@ +package quiz + +import ( + "github.com/google/uuid" + "gitlab.informatika.org/ocw/ocw-backend/model/domain/quiz" +) + +type QuizRepository interface { + GetQuizes(courseId string) ([]quiz.Quiz, error) + GetQuizDetail(quizId uuid.UUID) (*quiz.Quiz, error) + UpdateScore(takeId uuid.UUID, score int) error + NewTake(quizId uuid.UUID, userEmail string) (uuid.UUID, error) + IsActiveTake(quizId uuid.UUID, userEmail string) (bool, error) + GetAllTake(quizId uuid.UUID, userEmail string) ([]quiz.QuizTake, error) + GetLastTake(quizId uuid.UUID, userEmail string) (*quiz.QuizTake, error) +} diff --git a/routes/admin/route.go b/routes/admin/route.go index d1ff589b86faa181e6e671bf7111ab3efed0b239..d1fe8e2213ad86d7b7bdd1ecf4fcac90ab8f60a5 100644 --- a/routes/admin/route.go +++ b/routes/admin/route.go @@ -14,7 +14,7 @@ type AdminRoutes struct { func (adr AdminRoutes) Register(r chi.Router) { r.Route("/admin", func(r chi.Router) { - r.Use(adr.GuardBuilder.BuildSimple(user.Admin)) + r.Use(adr.GuardBuilder.Build(user.Admin)) r.Get("/user", adr.AdminHandler.GetAllUser) r.Get("/user/{email}", adr.AdminHandler.GetUserByEmail) diff --git a/routes/course/route.go b/routes/course/route.go index 23cb9f533b686bbf87655fcf1186ff319f1a0f6a..43a8305f4187ad99c9e7baadea375a59f373ad37 100644 --- a/routes/course/route.go +++ b/routes/course/route.go @@ -36,7 +36,7 @@ func (c CourseRoutes) Register(r chi.Router) { }) r.Route("/course/{id}/material", func(r chi.Router) { - r.Use(c.BuildSimple(user.Contributor)) + r.Use(c.Build(user.Contributor)) r.Post("/", c.MaterialHandler.CreateMaterial) }) } diff --git a/routes/di.go b/routes/di.go index e05148661337dd26d5400800ca389644f93aa3be..ddae40f95c5cde473aadc4f6d26a357c98b5f089 100644 --- a/routes/di.go +++ b/routes/di.go @@ -7,6 +7,7 @@ import ( "gitlab.informatika.org/ocw/ocw-backend/routes/common" "gitlab.informatika.org/ocw/ocw-backend/routes/course" "gitlab.informatika.org/ocw/ocw-backend/routes/material" + "gitlab.informatika.org/ocw/ocw-backend/routes/quiz" "gitlab.informatika.org/ocw/ocw-backend/routes/reset" "gitlab.informatika.org/ocw/ocw-backend/routes/swagger" ) @@ -19,6 +20,7 @@ var routesCollectionSet = wire.NewSet( wire.Struct(new(reset.ResetRoutes), "*"), wire.Struct(new(course.CourseRoutes), "*"), wire.Struct(new(material.MaterialRoutes), "*"), + wire.Struct(new(quiz.QuizRoutes), "*"), ) var RoutesSet = wire.NewSet( diff --git a/routes/material/route.go b/routes/material/route.go index 31fc398e8a45b45537ac0c72b661c5f404fb239b..41126ecf7b9b60c1f56f9bd77208e9486db51ef0 100644 --- a/routes/material/route.go +++ b/routes/material/route.go @@ -14,13 +14,17 @@ type MaterialRoutes struct { func (c MaterialRoutes) Register(r chi.Router) { r.Route("/material/{material-id}", func(r chi.Router) { - r.Use(c.GuardBuilder.BuildSimple(user.Contributor)) + r.Get("/", c.DetailMaterial) - // Add - r.Post("/content", c.AddContent) + r.Route("/", func(r chi.Router) { + r.Use(c.GuardBuilder.Build(user.Contributor)) - // Delete - r.Delete("/", c.DeleteMaterial) - r.Delete("/content/{content-id}", c.DeleteContent) + // Add + r.Post("/content", c.AddContent) + + // Delete + r.Delete("/", c.DeleteMaterial) + r.Delete("/content/{content-id}", c.DeleteContent) + }) }) } diff --git a/routes/quiz/route.go b/routes/quiz/route.go new file mode 100644 index 0000000000000000000000000000000000000000..b89269412c9add8ccc39a190a965f187a13c15b9 --- /dev/null +++ b/routes/quiz/route.go @@ -0,0 +1,39 @@ +package quiz + +import ( + "github.com/go-chi/chi/v5" + "gitlab.informatika.org/ocw/ocw-backend/handler/quiz" + "gitlab.informatika.org/ocw/ocw-backend/middleware/guard" + "gitlab.informatika.org/ocw/ocw-backend/model/domain/user" +) + +type QuizRoutes struct { + quiz.QuizHandler + *guard.GuardBuilder +} + +func (q QuizRoutes) Register(r chi.Router) { + r.Get("/course/{id}/quiz", q.QuizHandler.GetAllQuizes) + r.Get("/quiz/{id}", q.QuizHandler.GetQuizDetail) + + guard := q.GuardBuilder.Build( + user.Student, + user.Contributor, + user.Admin, + ) + + r.Route("/quiz/{id}/take", func(r chi.Router) { + r.Use(guard) + r.Post("/", q.QuizHandler.TakeQuiz) + }) + + r.Route("/quiz/{id}/finish", func(r chi.Router) { + r.Use(guard) + r.Post("/", q.QuizHandler.FinishQuiz) + }) + + r.Route("/quiz/{id}/solution", func(r chi.Router) { + r.Use(guard) + r.Get("/", q.QuizHandler.GetQuizSolution) + }) +} diff --git a/routes/routes.go b/routes/routes.go index 63e1d79fb448d35384bc6f062d360cdb2a7a258f..096f4d5005e2bfe01f82bfe53552edc99b1d0258 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -6,6 +6,7 @@ import ( "gitlab.informatika.org/ocw/ocw-backend/routes/common" "gitlab.informatika.org/ocw/ocw-backend/routes/course" "gitlab.informatika.org/ocw/ocw-backend/routes/material" + "gitlab.informatika.org/ocw/ocw-backend/routes/quiz" "gitlab.informatika.org/ocw/ocw-backend/routes/reset" "gitlab.informatika.org/ocw/ocw-backend/routes/swagger" @@ -19,6 +20,7 @@ type AppRouter struct { common.CommonRoutes auth.AuthRoutes reset.ResetRoutes + quiz.QuizRoutes course.CourseRoutes material.MaterialRoutes diff --git a/service/course/add.go b/service/course/add.go index dc42d5698abbb725b15e52d533ffd7ef9c719226..a94a59b6e56c8d465adf46559d3877dff50b34e0 100644 --- a/service/course/add.go +++ b/service/course/add.go @@ -132,7 +132,7 @@ func (c CourseServiceImpl) AddFaculty(payload faculty.AddFacultyRequestPayload) // Unauthorized Role if claim.Role != user.Admin { - return web.NewResponseErrorFromError(err, web.UnauthorizedAccess) + return web.NewResponseError("user is not allowed to access this resources", web.UnauthorizedAccess) } id, err := uuid.NewUUID() diff --git a/service/di.go b/service/di.go index 6cdb051667f746ccdc3ff0244fd29bdf19f69561..3a133f5fc0ccdaba5c7260c52fa6a068306cf8a2 100644 --- a/service/di.go +++ b/service/di.go @@ -5,13 +5,14 @@ import ( "gitlab.informatika.org/ocw/ocw-backend/service/admin" "gitlab.informatika.org/ocw/ocw-backend/service/auth" "gitlab.informatika.org/ocw/ocw-backend/service/common" + "gitlab.informatika.org/ocw/ocw-backend/service/course" "gitlab.informatika.org/ocw/ocw-backend/service/logger" "gitlab.informatika.org/ocw/ocw-backend/service/logger/hooks" "gitlab.informatika.org/ocw/ocw-backend/service/material" + "gitlab.informatika.org/ocw/ocw-backend/service/quiz" "gitlab.informatika.org/ocw/ocw-backend/service/reporter" "gitlab.informatika.org/ocw/ocw-backend/service/reset" "gitlab.informatika.org/ocw/ocw-backend/service/verification" - "gitlab.informatika.org/ocw/ocw-backend/service/course" ) var ServiceTestSet = wire.NewSet( @@ -64,6 +65,12 @@ var ServiceTestSet = wire.NewSet( wire.Bind(new(material.MaterialContentService), new(*material.MaterialContentServiceImpl)), wire.Bind(new(material.MaterialService), new(*material.MaterialServiceImpl)), ), + + // Quiz service + wire.NewSet( + wire.Struct(new(quiz.QuizServiceImpl), "*"), + wire.Bind(new(quiz.QuizService), new(*quiz.QuizServiceImpl)), + ), ) var ServiceSet = wire.NewSet( diff --git a/service/material/impl.go b/service/material/impl.go index 5d59d826c1d80689e479b198b9b714ec94ef1bd2..c50b67586123fcf70acf3c2c0a4ee7f42b53034c 100644 --- a/service/material/impl.go +++ b/service/material/impl.go @@ -22,6 +22,16 @@ func (m MaterialServiceImpl) Get(courseId string) ([]materialDomain.Material, er return materials, err } +func (m MaterialServiceImpl) GetById(materialId uuid.UUID) (*materialDomain.Material, error) { + material, err := m.MaterialRepository.Get(materialId) + + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, web.NewResponseErrorFromError(err, "ID_NOT_FOUND") + } + + return material, err +} + func (m MaterialServiceImpl) Create(courseId string, email string, name string) (uuid.UUID, error) { isSuccess := false tx := m.TransactionBuilder.Build() diff --git a/service/material/type.go b/service/material/type.go index 1e129d48229c705cd961b4cb8d33c35459b414b9..ca617ce6fdce82217b439b2f4b1f0a0b58d3545d 100644 --- a/service/material/type.go +++ b/service/material/type.go @@ -9,6 +9,7 @@ type MaterialService interface { Create(courseId string, email string, name string) (uuid.UUID, error) Delete(materialId uuid.UUID, email string) error Get(courseId string) ([]material.Material, error) + GetById(materialId uuid.UUID) (*material.Material, error) } type MaterialContentService interface { diff --git a/service/quiz/impl.go b/service/quiz/impl.go new file mode 100644 index 0000000000000000000000000000000000000000..3898cd448978c6604f71f68e7561077c9c396a00 --- /dev/null +++ b/service/quiz/impl.go @@ -0,0 +1,172 @@ +package quiz + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/google/uuid" + "gitlab.informatika.org/ocw/ocw-backend/model/domain/quiz" + "gitlab.informatika.org/ocw/ocw-backend/model/web" + "gitlab.informatika.org/ocw/ocw-backend/provider/storage" + quizRepo "gitlab.informatika.org/ocw/ocw-backend/repository/quiz" +) + +type QuizServiceImpl struct { + quizRepo.QuizRepository + storage.Storage +} + +func (q QuizServiceImpl) ListAllQuiz(courseId string) ([]quiz.Quiz, error) { + return q.QuizRepository.GetQuizes(courseId) +} + +func (q QuizServiceImpl) GetQuizDetail(quizId uuid.UUID) (*quiz.Quiz, error) { + return q.QuizRepository.GetQuizDetail(quizId) +} + +func (q QuizServiceImpl) getQuizDetail(ctx context.Context, quizId uuid.UUID) (*quiz.QuizDetail, error) { + detail, err := q.QuizRepository.GetQuizDetail(quizId) + + if err != nil { + return nil, err + } + + payload, err := q.Storage.Get(ctx, detail.QuizPath) + + if err != nil { + return nil, err + } + + result := &quiz.QuizDetail{} + + decoder := json.NewDecoder(bytes.NewReader(payload)) + err = decoder.Decode(result) + + return result, err +} + +func (q QuizServiceImpl) DoTakeQuiz(ctx context.Context, quizId uuid.UUID, email string) (*quiz.QuizDetail, error) { + result, err := q.getQuizDetail(ctx, quizId) + + if err != nil { + return nil, err + } + + taken, err := q.IsActiveTake(quizId, email) + + if err != nil { + return nil, err + } + + if !taken { + _, err = q.NewTake(quizId, email) + + if err != nil { + return nil, err + } + } + + for i := range result.Problems { + for j := range result.Problems[i].Answer { + result.Problems[i].Answer[j].IsSolution = nil + } + } + + return result, nil +} + +func (q QuizServiceImpl) GetSolutionQuiz(ctx context.Context, quizId uuid.UUID, email string) (*quiz.QuizDetail, error) { + result, err := q.getQuizDetail(ctx, quizId) + + if err != nil { + return nil, err + } + + _, err = q.GetLastTake(quizId, email) + + if err != nil { + return nil, err + } + + taken, err := q.IsActiveTake(quizId, email) + + if err != nil { + return nil, err + } + + if taken { + return nil, web.NewResponseError("user is not allow to access this data", "ERR_NOT_ALLOWED") + } + + return result, nil +} + +func (q QuizServiceImpl) checkAnswer(detail *quiz.QuizDetail, studentAnswer []quiz.Response) float64 { + answerDict := map[uuid.UUID][]uuid.UUID{} + totalProblem := len(detail.Problems) + + for _, problem := range detail.Problems { + correctAnswerId := []uuid.UUID{} + for _, answer := range problem.Answer { + if *answer.IsSolution { + correctAnswerId = append(correctAnswerId, answer.Id) + } + } + + answerDict[problem.Id] = correctAnswerId + } + + correctAnswer := 0 + for _, responseItem := range studentAnswer { + numCorrect := 0 + + for _, correctId := range answerDict[responseItem.ProblemId] { + if responseItem.AnswerId == correctId { + numCorrect++ + } + } + + if numCorrect == len(answerDict[responseItem.ProblemId]) { + correctAnswer++ + } + } + + return float64(correctAnswer) / float64(totalProblem) * 100 +} + +func (q QuizServiceImpl) DoFinishQuiz(ctx context.Context, quizId uuid.UUID, email string, studentAnswer []quiz.Response) (*quiz.QuizTake, error) { + taken, err := q.IsActiveTake(quizId, email) + + if err != nil { + return nil, err + } + + if !taken { + return nil, web.NewResponseError("user not yet do take the quiz", "NOT_TAKEN_QUIZ_YET") + } + + result, err := q.getQuizDetail(ctx, quizId) + + if err != nil { + return nil, err + } + + score := q.checkAnswer(result, studentAnswer) + + data, err := q.QuizRepository.GetLastTake(quizId, email) + data.IsFinished = true + data.Score = int(score) + + if err != nil { + return nil, err + } + + err = q.QuizRepository.UpdateScore(data.Id, int(score)) + + if err != nil { + return nil, err + } + + return data, nil +} diff --git a/service/quiz/type.go b/service/quiz/type.go new file mode 100644 index 0000000000000000000000000000000000000000..7c97e4965500fb25f75c19a4117c536559fcf9a8 --- /dev/null +++ b/service/quiz/type.go @@ -0,0 +1,17 @@ +package quiz + +import ( + "context" + + "github.com/google/uuid" + "gitlab.informatika.org/ocw/ocw-backend/model/domain/quiz" +) + +type QuizService interface { + ListAllQuiz(courseId string) ([]quiz.Quiz, error) + GetQuizDetail(quizId uuid.UUID) (*quiz.Quiz, error) + + DoTakeQuiz(ctx context.Context, quizId uuid.UUID, email string) (*quiz.QuizDetail, error) + DoFinishQuiz(ctx context.Context, quizId uuid.UUID, email string, studentAnswer []quiz.Response) (*quiz.QuizTake, error) + GetSolutionQuiz(ctx context.Context, quizId uuid.UUID, email string) (*quiz.QuizDetail, error) +}