From a237936febec7e3a250c30787c9da158d8fa942d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 8 Sep 2020 12:50:41 +0200 Subject: [PATCH] qa: New service to handle QA testing by students --- htdocs-qa | 1 + libfic/db.go | 31 ++ libfic/qa.go | 183 ++++++++++++ qa/.gitignore | 1 + qa/api/exercice.go | 36 +++ qa/api/handler.go | 111 +++++++ qa/api/qa.go | 140 +++++++++ qa/api/router.go | 11 + qa/api/theme.go | 64 +++++ qa/api/version.go | 13 + qa/main.go | 120 ++++++++ qa/static.go | 66 +++++ qa/static/css/bootstrap.min.css | 1 + qa/static/css/fic.css | 1 + qa/static/css/glyphicon.css | 1 + qa/static/favicon.ico | 1 + qa/static/fonts | 1 + qa/static/img/comcyber.png | 1 + qa/static/img/epita.png | 1 + qa/static/img/fic.png | 1 + qa/static/img/srs.png | 1 + qa/static/index.html | 70 +++++ qa/static/js/angular-resource.min.js | 15 + qa/static/js/angular-route.min.js | 1 + qa/static/js/angular-sanitize.min.js | 1 + qa/static/js/angular.min.js | 1 + qa/static/js/bootstrap.min.js | 1 + qa/static/js/common.js | 1 + qa/static/js/d3.v3.min.js | 1 + qa/static/js/i18n | 1 + qa/static/js/jquery.min.js | 1 + qa/static/js/qa.js | 414 +++++++++++++++++++++++++++ qa/static/views/exercice-list.html | 27 ++ qa/static/views/exercice.html | 105 +++++++ qa/static/views/home.html | 7 + qa/static/views/theme-list.html | 19 ++ qa/static/views/theme.html | 25 ++ 37 files changed, 1476 insertions(+) create mode 120000 htdocs-qa create mode 100644 libfic/qa.go create mode 100644 qa/.gitignore create mode 100644 qa/api/exercice.go create mode 100644 qa/api/handler.go create mode 100644 qa/api/qa.go create mode 100644 qa/api/router.go create mode 100644 qa/api/theme.go create mode 100644 qa/api/version.go create mode 100644 qa/main.go create mode 100644 qa/static.go create mode 120000 qa/static/css/bootstrap.min.css create mode 120000 qa/static/css/fic.css create mode 120000 qa/static/css/glyphicon.css create mode 120000 qa/static/favicon.ico create mode 120000 qa/static/fonts create mode 120000 qa/static/img/comcyber.png create mode 120000 qa/static/img/epita.png create mode 120000 qa/static/img/fic.png create mode 120000 qa/static/img/srs.png create mode 100644 qa/static/index.html create mode 100644 qa/static/js/angular-resource.min.js create mode 120000 qa/static/js/angular-route.min.js create mode 120000 qa/static/js/angular-sanitize.min.js create mode 120000 qa/static/js/angular.min.js create mode 120000 qa/static/js/bootstrap.min.js create mode 120000 qa/static/js/common.js create mode 120000 qa/static/js/d3.v3.min.js create mode 120000 qa/static/js/i18n create mode 120000 qa/static/js/jquery.min.js create mode 100644 qa/static/js/qa.js create mode 100644 qa/static/views/exercice-list.html create mode 100644 qa/static/views/exercice.html create mode 100644 qa/static/views/home.html create mode 100644 qa/static/views/theme-list.html create mode 100644 qa/static/views/theme.html diff --git a/htdocs-qa b/htdocs-qa new file mode 120000 index 00000000..2c4757cb --- /dev/null +++ b/htdocs-qa @@ -0,0 +1 @@ +qa/static \ No newline at end of file diff --git a/libfic/db.go b/libfic/db.go index 8a66087d..46bd2c67 100644 --- a/libfic/db.go +++ b/libfic/db.go @@ -415,6 +415,37 @@ CREATE TABLE IF NOT EXISTS claim_descriptions( publish BOOLEAN NOT NULL DEFAULT 0, FOREIGN KEY(id_claim) REFERENCES claims(id_claim) ) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; +`); err != nil { + return err + } + if _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS exercices_qa( + id_qa INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + id_exercice INTEGER NOT NULL, + id_team INTEGER NOT NULL, + authuser VARCHAR(255) NOT NULL, + subject VARCHAR(255) NOT NULL, + creation TIMESTAMP NOT NULL, + state VARCHAR(255) NOT NULL, + solved TIMESTAMP NULL, + closed TIMESTAMP NULL, + FOREIGN KEY(id_exercice) REFERENCES exercices(id_exercice), + FOREIGN KEY(id_team) REFERENCES teams(id_team) +) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; +`); err != nil { + return err + } + if _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS qa_comments( + id_comment INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + id_qa INTEGER NOT NULL, + id_team INTEGER NOT NULL, + authuser VARCHAR(255) NOT NULL, + date TIMESTAMP NOT NULL, + content TEXT NOT NULL, + FOREIGN KEY(id_qa) REFERENCES exercices_qa(id_qa), + FOREIGN KEY(id_team) REFERENCES teams(id_team) +) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; `); err != nil { return err } diff --git a/libfic/qa.go b/libfic/qa.go new file mode 100644 index 00000000..126e61e4 --- /dev/null +++ b/libfic/qa.go @@ -0,0 +1,183 @@ +package fic + +import ( + "database/sql" + "time" +) + +// QAQuery represents a QA query. +type QAQuery struct { + Id int64 `json:"id"` + IdExercice int64 `json:"id_exercice"` + IdTeam int64 `json:"id_team"` + User string `json:"user"` + Creation time.Time `json:"creation"` + State string `json:"state"` + Subject string `json:"subject"` + Solved *time.Time `json:"solved,omitempty"` + Closed *time.Time `json:"closed,omitempty"` +} + +// GetQAQuery retrieves the query with the given identifier. +func GetQAQuery(id int64) (q QAQuery, err error) { + err = DBQueryRow("SELECT id_qa, id_exercice, id_team, authuser, creation, state, subject, solved, closed FROM exercices_qa WHERE id_qa = ?", id).Scan(&q.Id, &q.IdExercice, &q.IdTeam, &q.User, &q.Creation, &q.State, &q.Subject, &q.Solved, &q.Closed) + return +} + +// GetQAQueries returns a list of all QAQuery registered in the database. +func GetQAQueries() (res []QAQuery, err error) { + var rows *sql.Rows + if rows, err = DBQuery("SELECT id_qa, id_exercice, id_team, authuser, creation, state, subject, solved, closed FROM exercices_qa"); err != nil { + return + } + defer rows.Close() + + for rows.Next() { + var q QAQuery + if err = rows.Scan(&q.Id, &q.IdExercice, &q.IdTeam, &q.User, &q.Creation, &q.State, &q.Subject, &q.Solved, &q.Closed); err != nil { + return + } + res = append(res, q) + } + err = rows.Err() + + return +} + +// GetQAQueries returns a list of all QAQuery registered for the Exercice. +func (e Exercice) GetQAQueries() (res []QAQuery, err error) { + var rows *sql.Rows + if rows, err = DBQuery("SELECT id_qa, id_exercice, id_team, authuser, creation, state, subject, solved, closed FROM exercices_qa WHERE id_exercice = ?", e.Id); err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var q QAQuery + if err = rows.Scan(&q.Id, &q.IdExercice, &q.IdTeam, &q.User, &q.Creation, &q.State, &q.Subject, &q.Solved, &q.Closed); err != nil { + return + } + res = append(res, q) + } + err = rows.Err() + + return +} + +// GetQAQuery retrieves the query with the given identifier. +func (e Exercice) GetQAQuery(id int64) (q QAQuery, err error) { + err = DBQueryRow("SELECT id_qa, id_exercice, id_team, authuser, creation, state, subject, solved, closed FROM exercices_qa WHERE id_qa = ? AND id_exercice = ?", id, e.Id).Scan(&q.Id, &q.IdExercice, &q.IdTeam, &q.User, &q.Creation, &q.State, &q.Subject, &q.Solved, &q.Closed) + return +} + +// NewQAQuery creates and fills a new struct QAQuery and registers it into the database. +func (e Exercice) NewQAQuery(subject string, teamId int64, user string, state string) (QAQuery, error) { + if res, err := DBExec("INSERT INTO exercices_qa (id_exercice, id_team, authuser, creation, state, subject) VALUES (?, ?, ?, ?, ?, ?)", e.Id, teamId, user, time.Now(), state, subject); err != nil { + return QAQuery{}, err + } else if qid, err := res.LastInsertId(); err != nil { + return QAQuery{}, err + } else { + return QAQuery{qid, e.Id, teamId, user, time.Now(), state, subject, nil, nil}, nil + } +} + +// Update applies modifications back to the database. +func (q QAQuery) Update() (int64, error) { + if res, err := DBExec("UPDATE exercices_qa SET subject = ?, id_team = ?, authuser = ?, id_exercice = ?, creation = ?, state = ?, solved = ?, closed = ? WHERE id_qa = ?", q.Subject, q.IdTeam, q.User, q.IdExercice, q.Creation, q.State, q.Solved, q.Closed, q.Id); err != nil { + return 0, err + } else if nb, err := res.RowsAffected(); err != nil { + return 0, err + } else { + return nb, err + } +} + +// Delete the query from the database. +func (q QAQuery) Delete() (int64, error) { + if res, err := DBExec("DELETE FROM exercices_qa WHERE id_qa = ?", q.Id); err != nil { + return 0, err + } else if nb, err := res.RowsAffected(); err != nil { + return 0, err + } else { + return nb, err + } +} + +// ClearQAQueries removes all queries from database. +func ClearQAQueries() (int64, error) { + if res, err := DBExec("DELETE FROM exercices_qa"); err != nil { + return 0, err + } else if nb, err := res.RowsAffected(); err != nil { + return 0, err + } else { + return nb, err + } +} + +// QAComment represents some text describing a QAQuery. +type QAComment struct { + Id int64 `json:"id"` + IdTeam int64 `json:"id_team"` + User string `json:"user"` + Date time.Time `json:"date"` + Content string `json:"content"` +} + +// GetComments returns a list of all descriptions stored in the database for the QAQuery. +func (q QAQuery) GetComments() (res []QAComment, err error) { + var rows *sql.Rows + if rows, err = DBQuery("SELECT id_comment, id_team, authuser, date, content FROM qa_comments WHERE id_qa = ?", q.Id); err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var c QAComment + if err = rows.Scan(&c.Id, &c.IdTeam, &c.User, &c.Date, &c.Content); err != nil { + return + } + res = append(res, c) + } + err = rows.Err() + + return +} + +// GetComment returns the comment stored in the database for the QAQuery. +func (q QAQuery) GetComment(id int64) (c QAComment, err error) { + err = DBQueryRow("SELECT id_comment, id_team, authuser, date, content FROM qa_comments WHERE id_comment = ? AND id_qa = ?", id, q.Id).Scan(&c.Id, &c.IdTeam, &c.User, &c.Date, &c.Content) + return +} + +// AddComment append in the database a new description; then returns the corresponding structure. +func (q QAQuery) AddComment(content string, teamId int64, user string) (QAComment, error) { + if res, err := DBExec("INSERT INTO qa_comments (id_qa, id_team, authuser, date, content) VALUES (?, ?, ?, ?, ?)", q.Id, teamId, user, time.Now(), content); err != nil { + return QAComment{}, err + } else if cid, err := res.LastInsertId(); err != nil { + return QAComment{}, err + } else { + return QAComment{cid, teamId, user, time.Now(), content}, nil + } +} + +// Update applies modifications back to the database +func (c QAComment) Update() (int64, error) { + if res, err := DBExec("UPDATE qa_comments SET id_team = ?, authuser = ?, date = ?, content = ? WHERE id_comment = ?", c.IdTeam, c.User, c.Date, c.Content, c.Id); err != nil { + return 0, err + } else if nb, err := res.RowsAffected(); err != nil { + return 0, err + } else { + return nb, err + } +} + +// Delete the comment in the database. +func (c QAComment) Delete() (int64, error) { + if res, err := DBExec("DELETE FROM qa_comments WHERE id_comment = ?", c.Id); err != nil { + return 0, err + } else if nb, err := res.RowsAffected(); err != nil { + return 0, err + } else { + return nb, err + } +} diff --git a/qa/.gitignore b/qa/.gitignore new file mode 100644 index 00000000..a483eac5 --- /dev/null +++ b/qa/.gitignore @@ -0,0 +1 @@ +qa \ No newline at end of file diff --git a/qa/api/exercice.go b/qa/api/exercice.go new file mode 100644 index 00000000..523f4d6d --- /dev/null +++ b/qa/api/exercice.go @@ -0,0 +1,36 @@ +package api + +import ( + "strconv" + + "srs.epita.fr/fic-server/libfic" + + "github.com/julienschmidt/httprouter" +) + +func init() { + router.GET("/api/exercices/", apiHandler(listExercices)) + + router.GET("/api/exercices/:eid", apiHandler(exerciceHandler(showExercice))) +} + +func exerciceHandler(f func(QAUser, fic.Exercice, []byte) (interface{}, error)) func(QAUser, httprouter.Params, []byte) (interface{}, error) { + return func(u QAUser, ps httprouter.Params, body []byte) (interface{}, error) { + if eid, err := strconv.ParseInt(string(ps.ByName("eid")), 10, 64); err != nil { + return nil, err + } else if exercice, err := fic.GetExercice(eid); err != nil { + return nil, err + } else { + return f(u, exercice, body) + } + } +} + +func listExercices(_ QAUser, _ httprouter.Params, body []byte) (interface{}, error) { + // List all exercices + return fic.GetExercices() +} + +func showExercice(_ QAUser, exercice fic.Exercice, body []byte) (interface{}, error) { + return exercice, nil +} diff --git a/qa/api/handler.go b/qa/api/handler.go new file mode 100644 index 00000000..1d76e57f --- /dev/null +++ b/qa/api/handler.go @@ -0,0 +1,111 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path" + "strconv" + "time" + + "github.com/julienschmidt/httprouter" +) + +var Simulator string +var TeamsDir string + +type QAUser struct { + User string `json:"name"` + TeamId int64 `json:"id_team"` +} + +type DispatchFunction func(QAUser, httprouter.Params, []byte) (interface{}, error) + +func apiHandler(f DispatchFunction) func(http.ResponseWriter, *http.Request, httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + ficteam := Simulator + if t := r.Header.Get("X-FIC-Team"); t != "" { + ficteam = t + } + + var teamid int64 + var err error + + if ficteam == "" { + log.Printf("%s 401 \"%s %s\" [%s]\n", r.RemoteAddr, r.Method, r.URL.Path, r.UserAgent()) + w.Header().Set("Content-Type", "application/json") + http.Error(w, fmt.Sprintf("{errmsg:\"Need to authenticate.\"}"), http.StatusUnauthorized) + return + } else if teamid, err = strconv.ParseInt(ficteam, 10, 64); err != nil { + if lnk, err := os.Readlink(path.Join(TeamsDir, ficteam)); err != nil { + log.Printf("[ERR] Unable to readlink %q: %s\n", path.Join(TeamsDir, ficteam), err) + return + } else if teamid, err = strconv.ParseInt(lnk, 10, 64); err != nil { + log.Printf("[ERR] Error during ParseInt team %q: %s\n", lnk, err) + return + } + } + + log.Printf("%s \"%s %s\" [%s]\n", r.RemoteAddr, r.Method, r.URL.Path, r.UserAgent()) + + // Read the body + if r.ContentLength < 0 || r.ContentLength > 6553600 { + http.Error(w, fmt.Sprintf("{errmsg:\"Request too large or request size unknown\"}"), http.StatusRequestEntityTooLarge) + return + } + var body []byte + if r.ContentLength > 0 { + tmp := make([]byte, 1024) + for { + n, err := r.Body.Read(tmp) + for j := 0; j < n; j++ { + body = append(body, tmp[j]) + } + if err != nil || n <= 0 { + break + } + } + } + + var ret interface{} + + ret, err = f(QAUser{ficteam, teamid}, ps, body) + + // Format response + resStatus := http.StatusOK + if err != nil { + ret = map[string]string{"errmsg": err.Error()} + resStatus = http.StatusBadRequest + log.Println(r.RemoteAddr, resStatus, err.Error()) + } + + if ret == nil { + ret = map[string]string{"errmsg": "Page not found"} + resStatus = http.StatusNotFound + } + + w.Header().Set("X-FIC-Time", fmt.Sprintf("%f", float64(time.Now().UnixNano()/1000)/1000000)) + + if str, found := ret.(string); found { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resStatus) + io.WriteString(w, str) + } else if bts, found := ret.([]byte); found { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment") + w.Header().Set("Content-Transfer-Encoding", "binary") + w.WriteHeader(resStatus) + w.Write(bts) + } else if j, err := json.Marshal(ret); err != nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, fmt.Sprintf("{\"errmsg\":%q}", err), http.StatusInternalServerError) + } else { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resStatus) + w.Write(j) + } + } +} diff --git a/qa/api/qa.go b/qa/api/qa.go new file mode 100644 index 00000000..d8814ac4 --- /dev/null +++ b/qa/api/qa.go @@ -0,0 +1,140 @@ +package api + +import ( + "encoding/json" + "errors" + "strconv" + + "srs.epita.fr/fic-server/libfic" + + "github.com/julienschmidt/httprouter" +) + +func init() { + router.GET("/api/qa/:eid", apiHandler(exerciceHandler(getExerciceQA))) + router.POST("/api/qa/:eid", apiHandler(exerciceHandler(createExerciceQA))) + + router.PUT("/api/qa/:eid/:qid", apiHandler(qaHandler(updateExerciceQA))) + router.DELETE("/api/qa/:eid/:qid", apiHandler(qaHandler(deleteExerciceQA))) + + router.GET("/api/qa/:eid/:qid/comments", apiHandler(qaHandler(getQAComments))) + router.POST("/api/qa/:eid/:qid/comments", apiHandler(qaHandler(createQAComment))) + + router.DELETE("/api/qa/:eid/:qid/comments/:cid", apiHandler(qaCommentHandler(deleteQAComment))) +} + +func qaHandler(f func(QAUser, fic.QAQuery, fic.Exercice, []byte) (interface{}, error)) func(QAUser, httprouter.Params, []byte) (interface{}, error) { + return func(u QAUser, ps httprouter.Params, body []byte) (interface{}, error) { + return exerciceHandler(func(u QAUser, exercice fic.Exercice, _ []byte) (interface{}, error) { + if qid, err := strconv.ParseInt(string(ps.ByName("qid")), 10, 64); err != nil { + return nil, err + } else if query, err := exercice.GetQAQuery(qid); err != nil { + return nil, err + } else { + return f(u, query, exercice, body) + } + })(u, ps, body) + } +} + +func qaCommentHandler(f func(QAUser, fic.QAComment, fic.QAQuery, fic.Exercice, []byte) (interface{}, error)) func(QAUser, httprouter.Params, []byte) (interface{}, error) { + return func(u QAUser, ps httprouter.Params, body []byte) (interface{}, error) { + return qaHandler(func(u QAUser, query fic.QAQuery, exercice fic.Exercice, _ []byte) (interface{}, error) { + if cid, err := strconv.ParseInt(string(ps.ByName("cid")), 10, 64); err != nil { + return nil, err + } else if comment, err := query.GetComment(cid); err != nil { + return nil, err + } else { + return f(u, comment, query, exercice, body) + } + })(u, ps, body) + } +} + +func getExerciceQA(_ QAUser, exercice fic.Exercice, body []byte) (interface{}, error) { + return exercice.GetQAQueries() +} + +func createExerciceQA(u QAUser, exercice fic.Exercice, body []byte) (interface{}, error) { + // Create a new query + var uq fic.QAQuery + if err := json.Unmarshal(body, &uq); err != nil { + return nil, err + } + + if len(uq.State) == 0 { + return nil, errors.New("State not filled") + } + + if len(uq.Subject) == 0 { + return nil, errors.New("Subject not filled") + } + + if qa, err := exercice.NewQAQuery(uq.Subject, u.TeamId, u.User, uq.State); err != nil { + return nil, err + } else { + var uc fic.QAComment + if err := json.Unmarshal(body, &uc); err != nil { + return nil, err + } + + if uc.Content != "" { + _, err = qa.AddComment(uc.Content, u.TeamId, u.User) + } + + return qa, err + } +} + +func updateExerciceQA(u QAUser, query fic.QAQuery, exercice fic.Exercice, body []byte) (interface{}, error) { + var uq fic.QAQuery + if err := json.Unmarshal(body, &uq); err != nil { + return nil, err + } + + uq.Id = query.Id + + if uq.User != query.User && (uq.IdExercice != query.IdExercice || uq.IdTeam != query.IdTeam || uq.User != query.User || uq.Creation != query.Creation || uq.State != query.State || uq.Subject != query.Subject || uq.Closed != query.Closed) { + return nil, errors.New("You can only update your own entry.") + } + + if _, err := uq.Update(); err != nil { + return nil, err + } else { + return uq, err + } +} + +func deleteExerciceQA(u QAUser, query fic.QAQuery, exercice fic.Exercice, body []byte) (interface{}, error) { + if u.User != query.User { + return nil, errors.New("You can only delete your own entry.") + } + + return query.Delete() +} + +func getQAComments(_ QAUser, query fic.QAQuery, exercice fic.Exercice, body []byte) (interface{}, error) { + return query.GetComments() +} + +func createQAComment(u QAUser, query fic.QAQuery, exercice fic.Exercice, body []byte) (interface{}, error) { + // Create a new query + var uc fic.QAComment + if err := json.Unmarshal(body, &uc); err != nil { + return nil, err + } + + if len(uc.Content) == 0 { + return nil, errors.New("Empty comment") + } + + return query.AddComment(uc.Content, u.TeamId, u.User) +} + +func deleteQAComment(u QAUser, comment fic.QAComment, query fic.QAQuery, exercice fic.Exercice, body []byte) (interface{}, error) { + if u.User != comment.User { + return nil, errors.New("You can only delete your own comment.") + } + + return comment.Delete() +} diff --git a/qa/api/router.go b/qa/api/router.go new file mode 100644 index 00000000..a6bd873b --- /dev/null +++ b/qa/api/router.go @@ -0,0 +1,11 @@ +package api + +import ( + "github.com/julienschmidt/httprouter" +) + +var router = httprouter.New() + +func Router() *httprouter.Router { + return router +} diff --git a/qa/api/theme.go b/qa/api/theme.go new file mode 100644 index 00000000..6931b073 --- /dev/null +++ b/qa/api/theme.go @@ -0,0 +1,64 @@ +package api + +import ( + "strconv" + + "srs.epita.fr/fic-server/libfic" + + "github.com/julienschmidt/httprouter" +) + +func init() { + router.GET("/api/themes", apiHandler(listThemes)) + router.GET("/api/themes.json", apiHandler(exportThemes)) + + router.GET("/api/themes/:thid", apiHandler(themeHandler(showTheme))) + + router.GET("/api/themes/:thid/exercices", apiHandler(themeHandler(listThemedExercices))) + + router.GET("/api/themes/:thid/exercices/:eid", apiHandler(exerciceHandler(showExercice))) +} + +func themeHandler(f func(QAUser, fic.Theme, []byte) (interface{}, error)) func(QAUser, httprouter.Params, []byte) (interface{}, error) { + return func(u QAUser, ps httprouter.Params, body []byte) (interface{}, error) { + if thid, err := strconv.ParseInt(string(ps.ByName("thid")), 10, 64); err != nil { + return nil, err + } else if theme, err := fic.GetTheme(thid); err != nil { + return nil, err + } else { + return f(u, theme, body) + } + } +} + +func getExercice(args []string) (fic.Exercice, error) { + if tid, err := strconv.ParseInt(string(args[0]), 10, 64); err != nil { + return fic.Exercice{}, err + } else if theme, err := fic.GetTheme(tid); err != nil { + return fic.Exercice{}, err + } else if eid, err := strconv.Atoi(string(args[1])); err != nil { + return fic.Exercice{}, err + } else { + return theme.GetExercice(eid) + } +} + +func listThemes(_ QAUser, _ httprouter.Params, _ []byte) (interface{}, error) { + return fic.GetThemes() +} + +func exportThemes(_ QAUser, _ httprouter.Params, _ []byte) (interface{}, error) { + return fic.ExportThemes() +} + +func showTheme(_ QAUser, theme fic.Theme, _ []byte) (interface{}, error) { + return theme, nil +} + +func listThemedExercices(_ QAUser, theme fic.Theme, _ []byte) (interface{}, error) { + return theme.GetExercices() +} + +func showThemedExercice(_ QAUser, theme fic.Theme, exercice fic.Exercice, body []byte) (interface{}, error) { + return exercice, nil +} diff --git a/qa/api/version.go b/qa/api/version.go new file mode 100644 index 00000000..c351b81e --- /dev/null +++ b/qa/api/version.go @@ -0,0 +1,13 @@ +package api + +import ( + "github.com/julienschmidt/httprouter" +) + +func init() { + router.GET("/api/version", apiHandler(showVersion)) +} + +func showVersion(u QAUser, _ httprouter.Params, body []byte) (interface{}, error) { + return map[string]interface{}{"version": 0.1, "auth": u}, nil +} diff --git a/qa/main.go b/qa/main.go new file mode 100644 index 00000000..5cc80a76 --- /dev/null +++ b/qa/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "path" + "path/filepath" + "strings" + "syscall" + + "srs.epita.fr/fic-server/libfic" + "srs.epita.fr/fic-server/qa/api" +) + +var StaticDir string + +type ResponseWriterPrefix struct { + real http.ResponseWriter + prefix string +} + +func (r ResponseWriterPrefix) Header() http.Header { + return r.real.Header() +} + +func (r ResponseWriterPrefix) WriteHeader(s int) { + if v, exists := r.real.Header()["Location"]; exists { + r.real.Header().Set("Location", r.prefix+v[0]) + } + r.real.WriteHeader(s) +} + +func (r ResponseWriterPrefix) Write(z []byte) (int, error) { + return r.real.Write(z) +} + +func StripPrefix(prefix string, h http.Handler) http.Handler { + if prefix == "" { + return h + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if prefix != "/" && r.URL.Path == "/" { + http.Redirect(w, r, prefix+"/", http.StatusFound) + } else if p := strings.TrimPrefix(r.URL.Path, prefix); len(p) < len(r.URL.Path) { + r2 := new(http.Request) + *r2 = *r + r2.URL = new(url.URL) + *r2.URL = *r.URL + r2.URL.Path = p + h.ServeHTTP(ResponseWriterPrefix{w, prefix}, r2) + } else { + h.ServeHTTP(w, r) + } + }) +} + +func main() { + // Read parameters from command line + var bind = flag.String("bind", "127.0.0.1:8083", "Bind port/socket") + var dsn = flag.String("dsn", fic.DSNGenerator(), "DSN to connect to the MySQL server") + flag.StringVar(&BaseURL, "baseurl", BaseURL, "URL prepended to each URL") + flag.StringVar(&StaticDir, "static", "./htdocs-qa/", "Directory containing static files") + flag.StringVar(&api.TeamsDir, "teams", "./TEAMS", "Base directory where save teams JSON files") + flag.StringVar(&api.Simulator, "simulator", "", "Auth string to simulate (for development only)") + flag.Parse() + + log.SetPrefix("[qa] ") + + // Sanitize options + var err error + log.Println("Checking paths...") + if StaticDir, err = filepath.Abs(StaticDir); err != nil { + log.Fatal(err) + } + if BaseURL != "/" { + BaseURL = path.Clean(BaseURL) + } else { + BaseURL = "" + } + if api.Simulator != "" { + if _, err := os.Stat(path.Join(api.TeamsDir, api.Simulator)); os.IsNotExist(err) { + log.Fatal(err) + } + } + + // Database connection + log.Println("Opening database...") + if err = fic.DBInit(*dsn); err != nil { + log.Fatal("Cannot open the database: ", err) + } + defer fic.DBClose() + + // Prepare graceful shutdown + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) + + srv := &http.Server{ + Addr: *bind, + Handler: StripPrefix(BaseURL, api.Router()), + } + + // Serve content + go func() { + log.Fatal(srv.ListenAndServe()) + }() + log.Println(fmt.Sprintf("Ready, listening on %s", *bind)) + + // Wait shutdown signal + <-interrupt + + log.Print("The service is shutting down...") + srv.Shutdown(context.Background()) + log.Println("done") +} diff --git a/qa/static.go b/qa/static.go new file mode 100644 index 00000000..0d5e479b --- /dev/null +++ b/qa/static.go @@ -0,0 +1,66 @@ +package main + +import ( + "bytes" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "path" + + "srs.epita.fr/fic-server/qa/api" + + "github.com/julienschmidt/httprouter" +) + +var BaseURL = "/" + +var indexTmpl []byte + +func getIndexHtml(w io.Writer) { + if len(indexTmpl) == 0 { + if file, err := os.Open(path.Join(StaticDir, "index.html")); err != nil { + log.Println("Unable to open index.html: ", err) + } else { + defer file.Close() + + if indexTmpl, err = ioutil.ReadAll(file); err != nil { + log.Println("Cannot read whole index.html: ", err) + } else { + indexTmpl = bytes.Replace(indexTmpl, []byte("{{.urlbase}}"), []byte(path.Clean(path.Join(BaseURL+"/", "nuke"))[:len(path.Clean(path.Join(BaseURL+"/", "nuke")))-4]), -1) + } + } + } + + w.Write(indexTmpl) +} + +func init() { + api.Router().GET("/", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + getIndexHtml(w) + }) + + api.Router().GET("/exercices/*_", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + getIndexHtml(w) + }) + api.Router().GET("/themes/*_", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + getIndexHtml(w) + }) + + api.Router().GET("/css/*_", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + http.ServeFile(w, r, path.Join(StaticDir, r.URL.Path)) + }) + api.Router().GET("/fonts/*_", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + http.ServeFile(w, r, path.Join(StaticDir, r.URL.Path)) + }) + api.Router().GET("/img/*_", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + http.ServeFile(w, r, path.Join(StaticDir, r.URL.Path)) + }) + api.Router().GET("/js/*_", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + http.ServeFile(w, r, path.Join(StaticDir, r.URL.Path)) + }) + api.Router().GET("/views/*_", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + http.ServeFile(w, r, path.Join(StaticDir, r.URL.Path)) + }) +} diff --git a/qa/static/css/bootstrap.min.css b/qa/static/css/bootstrap.min.css new file mode 120000 index 00000000..8dd0a066 --- /dev/null +++ b/qa/static/css/bootstrap.min.css @@ -0,0 +1 @@ +../../../admin/static/css/bootstrap.min.css \ No newline at end of file diff --git a/qa/static/css/fic.css b/qa/static/css/fic.css new file mode 120000 index 00000000..5f8e4077 --- /dev/null +++ b/qa/static/css/fic.css @@ -0,0 +1 @@ +../../../frontend/static/css/fic.css \ No newline at end of file diff --git a/qa/static/css/glyphicon.css b/qa/static/css/glyphicon.css new file mode 120000 index 00000000..6d65cb92 --- /dev/null +++ b/qa/static/css/glyphicon.css @@ -0,0 +1 @@ +../../../frontend/static/css/glyphicon.css \ No newline at end of file diff --git a/qa/static/favicon.ico b/qa/static/favicon.ico new file mode 120000 index 00000000..060d10ef --- /dev/null +++ b/qa/static/favicon.ico @@ -0,0 +1 @@ +../../frontend/static/favicon.ico \ No newline at end of file diff --git a/qa/static/fonts b/qa/static/fonts new file mode 120000 index 00000000..5431051e --- /dev/null +++ b/qa/static/fonts @@ -0,0 +1 @@ +../../frontend/static/fonts/ \ No newline at end of file diff --git a/qa/static/img/comcyber.png b/qa/static/img/comcyber.png new file mode 120000 index 00000000..0df83a18 --- /dev/null +++ b/qa/static/img/comcyber.png @@ -0,0 +1 @@ +../../../frontend/static/img/comcyber.png \ No newline at end of file diff --git a/qa/static/img/epita.png b/qa/static/img/epita.png new file mode 120000 index 00000000..110ea62c --- /dev/null +++ b/qa/static/img/epita.png @@ -0,0 +1 @@ +../../../frontend/static/img/epita.png \ No newline at end of file diff --git a/qa/static/img/fic.png b/qa/static/img/fic.png new file mode 120000 index 00000000..00ba2748 --- /dev/null +++ b/qa/static/img/fic.png @@ -0,0 +1 @@ +../../../frontend/static/img/fic.png \ No newline at end of file diff --git a/qa/static/img/srs.png b/qa/static/img/srs.png new file mode 120000 index 00000000..a599744d --- /dev/null +++ b/qa/static/img/srs.png @@ -0,0 +1 @@ +../../../frontend/static/img/srs.png \ No newline at end of file diff --git a/qa/static/index.html b/qa/static/index.html new file mode 100644 index 00000000..e86d9086 --- /dev/null +++ b/qa/static/index.html @@ -0,0 +1,70 @@ + + + + + Challenge Forensic - QA + + + + + + + + +
+
+
+ +
+ +
+ +
+ + + + + + + + + + + diff --git a/qa/static/js/angular-resource.min.js b/qa/static/js/angular-resource.min.js new file mode 100644 index 00000000..8b924c37 --- /dev/null +++ b/qa/static/js/angular-resource.min.js @@ -0,0 +1,15 @@ +/* + AngularJS v1.7.9 + (c) 2010-2018 Google, Inc. http://angularjs.org + License: MIT +*/ +(function(T,a){'use strict';function M(m,f){f=f||{};a.forEach(f,function(a,d){delete f[d]});for(var d in m)!m.hasOwnProperty(d)||"$"===d.charAt(0)&&"$"===d.charAt(1)||(f[d]=m[d]);return f}var B=a.$$minErr("$resource"),H=/^(\.[a-zA-Z_$@][0-9a-zA-Z_$@]*)+$/;a.module("ngResource",["ng"]).info({angularVersion:"1.7.9"}).provider("$resource",function(){var m=/^https?:\/\/\[[^\]]*][^/]*/,f=this;this.defaults={stripTrailingSlashes:!0,cancellable:!1,actions:{get:{method:"GET"},save:{method:"POST"},query:{method:"GET", +isArray:!0},remove:{method:"DELETE"},"delete":{method:"DELETE"}}};this.$get=["$http","$log","$q","$timeout",function(d,F,G,N){function C(a,d){this.template=a;this.defaults=n({},f.defaults,d);this.urlParams={}}var O=a.noop,r=a.forEach,n=a.extend,R=a.copy,P=a.isArray,D=a.isDefined,x=a.isFunction,I=a.isNumber,y=a.$$encodeUriQuery,S=a.$$encodeUriSegment;C.prototype={setUrlParams:function(a,d,f){var g=this,c=f||g.template,s,h,n="",b=g.urlParams=Object.create(null);r(c.split(/\W/),function(a){if("hasOwnProperty"=== +a)throw B("badname");!/^\d+$/.test(a)&&a&&(new RegExp("(^|[^\\\\]):"+a+"(\\W|$)")).test(c)&&(b[a]={isQueryParamValue:(new RegExp("\\?.*=:"+a+"(?:\\W|$)")).test(c)})});c=c.replace(/\\:/g,":");c=c.replace(m,function(b){n=b;return""});d=d||{};r(g.urlParams,function(b,a){s=d.hasOwnProperty(a)?d[a]:g.defaults[a];D(s)&&null!==s?(h=b.isQueryParamValue?y(s,!0):S(s),c=c.replace(new RegExp(":"+a+"(\\W|$)","g"),function(b,a){return h+a})):c=c.replace(new RegExp("(/?):"+a+"(\\W|$)","g"),function(b,a,e){return"/"=== +e.charAt(0)?e:a+e})});g.defaults.stripTrailingSlashes&&(c=c.replace(/\/+$/,"")||"/");c=c.replace(/\/\.(?=\w+($|\?))/,".");a.url=n+c.replace(/\/(\\|%5C)\./,"/.");r(d,function(b,c){g.urlParams[c]||(a.params=a.params||{},a.params[c]=b)})}};return function(m,y,z,g){function c(b,c){var d={};c=n({},y,c);r(c,function(c,f){x(c)&&(c=c(b));var e;if(c&&c.charAt&&"@"===c.charAt(0)){e=b;var k=c.substr(1);if(null==k||""===k||"hasOwnProperty"===k||!H.test("."+k))throw B("badmember",k);for(var k=k.split("."),h=0, +n=k.length;h +
+    + + just now + +
+
+
+ + +
+ ` + }); + +angular.module("FICApp") + .factory("Version", function($resource) { + return $resource("/api/version") + }) + .factory("Team", function($resource) { + return $resource("/api/teams/:teamId", { teamId: '@id' }, { + 'update': {method: 'PUT'}, + }) + }) + .factory("Teams", function($resource) { + return $resource("/api/teams.json") + }) + .factory("Theme", function($resource) { + return $resource("/api/themes/:themeId", { themeId: '@id' }, { + update: {method: 'PUT'} + }); + }) + .factory("Themes", function($resource) { + return $resource("/api/themes.json", null, { + 'get': {method: 'GET'}, + }) + }) + .factory("ThemedExercice", function($resource) { + return $resource("/api/themes/:themeId/exercices/:exerciceId", { themeId: '@id', exerciceId: '@idExercice' }, { + update: {method: 'PUT'} + }) + }) + .factory("Exercice", function($resource) { + return $resource("/api/exercices/:exerciceId", { exerciceId: '@id' }, { + update: {method: 'PUT'}, + patch: {method: 'PATCH'} + }) + }) + .factory("ExerciceQA", function($resource) { + return $resource("/api/qa/:exerciceId/:qaId", { exerciceId: '@idExercice', qaId: '@id' }, { + update: {method: 'PUT'}, + patch: {method: 'PATCH'} + }) + }); + +angular.module("FICApp") + .filter("toColor", function() { + return function(num) { + num >>>= 0; + var b = num & 0xFF, + g = (num & 0xFF00) >>> 8, + r = (num & 0xFF0000) >>> 16, + a = ( (num & 0xFF000000) >>> 24 ) / 255 ; + return "#" + r.toString(16) + g.toString(16) + b.toString(16); + } + }) + .filter("cksum", function() { + return function(input) { + if (input == undefined) + return input; + var raw = atob(input).toString(16); + var hex = ''; + for (var i = 0; i < raw.length; i++ ) { + var _hex = raw.charCodeAt(i).toString(16) + hex += (_hex.length == 2 ? _hex : '0' + _hex); + } + return hex + } + }) + + .directive('color', function() { + return { + require: 'ngModel', + link: function(scope, ele, attr, ctrl){ + ctrl.$formatters.unshift(function(num){ + num >>>= 0; + var b = num & 0xFF, + g = (num & 0xFF00) >>> 8, + r = (num & 0xFF0000) >>> 16, + a = ( (num & 0xFF000000) >>> 24 ) / 255 ; + return "#" + r.toString(16) + g.toString(16) + b.toString(16); + }); + ctrl.$parsers.unshift(function(viewValue){ + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(viewValue); + return result ? ( + parseInt(result[1], 16) * 256 * 256 + + parseInt(result[2], 16) * 256 + + parseInt(result[3], 16) + + ) : 0; + }); + } + }; + }) + + .directive('integer', function() { + return { + require: 'ngModel', + link: function(scope, ele, attr, ctrl){ + ctrl.$parsers.unshift(function(viewValue){ + return parseInt(viewValue, 10); + }); + } + }; + }) + + .directive('float', function() { + return { + require: 'ngModel', + link: function(scope, ele, attr, ctrl){ + ctrl.$parsers.unshift(function(viewValue){ + return parseFloat(viewValue, 10); + }); + } + }; + }) + + .run(function($rootScope, $http, $interval) { + $rootScope.toasts = []; + $rootScope.addToast = function(kind, title, msg, yesFunc, noFunc, tmout) { + $rootScope.toasts.unshift({ + variant: kind, + title: title, + msg: msg, + timeout: tmout, + yesFunc: yesFunc, + noFunc: noFunc, + }); + } + }) + + .controller("VersionController", function($scope, Version) { + $scope.v = Version.get(); + }) + + .controller("ThemesListController", function($scope, Theme, $location, $rootScope, $http) { + $scope.themes = Theme.query(); + $scope.fields = ["name", "authors", "headline"]; + + $scope.validateSearch = function(keyEvent) { + if (keyEvent.which === 13) { + var myTheme = null; + $scope.themes.forEach(function(theme) { + if (String(theme.name.toLowerCase()).indexOf($scope.query.toLowerCase()) >= 0) { + if (myTheme === null) + myTheme = theme; + else + myTheme = false; + } + }); + if (myTheme) + $location.url("themes/" + myTheme.id); + } + }; + + $scope.show = function(id) { + $location.url("/themes/" + id); + }; + }) + .controller("ThemeController", function($scope, Theme, $routeParams, $location, $rootScope, $http) { + $scope.theme = Theme.get({ themeId: $routeParams.themeId }); + $scope.fields = ["name", "urlid", "authors", "headline", "intro", "image"]; + }) + + .controller("AllExercicesListController", function($scope, Exercice, Theme, $routeParams, $location, $rootScope, $http, $filter) { + $http({ + url: "/api/themes.json", + method: "GET" + }).then(function(response) { + $scope.themes = response.data + }); + + $scope.exercices = Exercice.query(); + $scope.exercice = {}; // Array used to save fields to updates in selected exercices + $scope.fields = ["title", "headline"]; + + $scope.validateSearch = function(keyEvent) { + if (keyEvent.which === 13) { + var myExercice = null; + $scope.exercices.forEach(function(exercice) { + if (String(exercice.title.toLowerCase()).indexOf($scope.query.toLowerCase()) >= 0) { + if (myExercice === null) + myExercice = exercice; + else + myExercice = false; + } + }); + if (myExercice) + $location.url("exercices/" + myExercice.id); + } + }; + + $scope.show = function(id) { + $location.url("/exercices/" + id); + }; + }) + .controller("ExercicesListController", function($scope, ThemedExercice, $location, $rootScope, $http) { + $scope.exercices = ThemedExercice.query({ themeId: $scope.theme.id }); + $scope.fields = ["title", "headline"]; + + $scope.show = function(id) { + $location.url("/themes/" + $scope.theme.id + "/exercices/" + id); + }; + }) + + .controller("ExerciceController", function($scope, $rootScope, Exercice, ThemedExercice, $routeParams, $location, $http) { + if ($routeParams.themeId && $routeParams.exerciceId == "new") { + $scope.exercice = new ThemedExercice(); + } else { + $scope.exercice = Exercice.get({ exerciceId: $routeParams.exerciceId }); + } + $http({ + url: "/api/themes.json", + method: "GET" + }).then(function(response) { + $scope.themes = response.data + var last_exercice = null; + angular.forEach($scope.themes[$scope.exercice.id_theme].exercices, function(exercice, k) { + if (last_exercice != null) { + $scope.themes[$scope.exercice.id_theme].exercices[last_exercice].next = k; + exercice.previous = last_exercice; + } + last_exercice = k; + exercice.id = k; + }); + }); + $scope.exercices = Exercice.query(); + }) + + .controller("ExerciceQAController", function($scope, $rootScope, ExerciceQA, $routeParams, $location, $http) { + $scope.queries = ExerciceQA.query({ exerciceId: $routeParams.exerciceId }); + $scope.fields = ["state", "subject", "user", "creation"]; + $scope.namedFields = { + "state": "État", + "subject": "Sujet", + "content": "Description", + }; + $scope.states = { + "ok": "OK", + "orthograph": "Orthographe et grammaire", + "issue-statement": "Pas compris", + "issue-flag": "Problème de flag", + "issue-mcq": "Problème de QCM/QCU", + "issue-hint": "Problème d'indice", + "issue-file": "Problème de fichier", + "issue": "Problème autre", + "suggest": "Suggestion", + "too-hard": "Trop dur", + "too-easy": "Trop facile", + }; + + $scope.newQuery = new ExerciceQA(); + + $scope.query_comments = null + $scope.query_selected = null + $scope.showComments = function(qid) { + if ($scope.query_selected == qid) { + $scope.query_selected = null + $scope.queries_comments = null + } else { + $scope.query_selected = qid + $http({ + url: "/api/qa/" + $routeParams.exerciceId + "/" + $scope.queries[$scope.query_selected].id + "/comments" + }).then(function(response) { + $scope.queries_comments = response.data + }) + } + } + + $scope.newComment = {content: ""} + $scope.addComment = function() { + $http({ + url: "/api/qa/" + $routeParams.exerciceId + "/" + $scope.queries[$scope.query_selected].id + "/comments", + method: "POST", + data: $scope.newComment, + }).then(function(response) { + $scope.newComment = {content: ""} + $http({ + url: "/api/qa/" + $routeParams.exerciceId + "/" + $scope.queries[$scope.query_selected].id + "/comments" + }).then(function(response) { + $scope.queries_comments = response.data + }) + }, function(response) { + $scope.addToast('danger', 'An error occurs when trying to respond to QA entry:', response.data.errmsg); + }) + } + + $scope.updateQA = function(qid) { + $scope.newQuery = $scope.queries[$scope.query_selected] + } + + $scope.deleteQA = function(qid) { + var myq = $scope.queries[$scope.query_selected] + myq.$delete( + { exerciceId: $routeParams.exerciceId, qaId: qid }, + function() { + $scope.queries = ExerciceQA.query({ exerciceId: $routeParams.exerciceId }); + $scope.query_selected = null + }, function(response) { + $scope.addToast('danger', 'An error occurs when trying to delete QA query:', response.data.errmsg); + } + ) + } + + $scope.solveQA = function(qid) { + var myq = $scope.queries[$scope.query_selected] + myq.solved = (new Date()).toISOString() + myq.$update({ exerciceId: $routeParams.exerciceId, qaId: qid }) + } + + $scope.closeQA = function(qid) { + var myq = $scope.queries[$scope.query_selected] + myq.closed = (new Date()).toISOString() + myq.$update({ exerciceId: $routeParams.exerciceId, qaId: qid }) + } + + $scope.saveQuery = function() { + if (this.newQuery.id) { + this.newQuery.$update({ exerciceId: $routeParams.exerciceId, qaId: this.newQuery.id }); + } else { + this.newQuery.$save({ exerciceId: $routeParams.exerciceId }, function() { + //$scope.saveComment(); + $scope.addToast('success', 'QA query created!'); + $scope.queries = ExerciceQA.query({ exerciceId: $routeParams.exerciceId }); + $scope.newQuery = new ExerciceQA(); + }, function(response) { + $scope.addToast('danger', 'An error occurs when trying to create QA query:', response.data.errmsg); + }); + } + } + }); diff --git a/qa/static/views/exercice-list.html b/qa/static/views/exercice-list.html new file mode 100644 index 00000000..dc3855f5 --- /dev/null +++ b/qa/static/views/exercice-list.html @@ -0,0 +1,27 @@ +

+ Défis +

+ +
+

+ + + + + + + + + + + + + +
+ {{ field }} + + Scénario +
+ {{ themes[exercice.id_theme].name }} +
+
diff --git a/qa/static/views/exercice.html b/qa/static/views/exercice.html new file mode 100644 index 00000000..7152b459 --- /dev/null +++ b/qa/static/views/exercice.html @@ -0,0 +1,105 @@ +

+ {{exercice.title}} + {{themes[exercice.id_theme].name}} +
+ + +
+

+ +
+
+
+
+ +
+
+
+ Qu'avez-vous pensé de ce défi ? +
+
+
+ +
+ + + +
+
+ +
+
+ + + + + + + + + + + + + + + + + +
+ {{ field }} +
Aucun requête enregistrée
+ +
+
+

{{ queries[query_selected].subject }}

+
+
+
+
+
Qui ?
+
{{ queries[query_selected].user }} (team #{{ queries[query_selected].id_team}})
+ +
État
+
{{ queries[query_selected].state }}
+ +
Date de création
+
{{ queries[query_selected].creation }}
+ +
Date de résolution
+
{{ queries[query_selected].solved }}
+ +
Date de clôture
+
{{ queries[query_selected].closed }}
+
+
+ + + +
+
+ + + + +
+ Le {{ comment.date }}, {{ comment.user }} a écrit : {{ comment.content }} +
+
+ Répondre : + + + +
+
+
diff --git a/qa/static/views/home.html b/qa/static/views/home.html new file mode 100644 index 00000000..39ee5004 --- /dev/null +++ b/qa/static/views/home.html @@ -0,0 +1,7 @@ +
+

Interface QA du challenge

+
+
+
+
+
diff --git a/qa/static/views/theme-list.html b/qa/static/views/theme-list.html new file mode 100644 index 00000000..daf7ff81 --- /dev/null +++ b/qa/static/views/theme-list.html @@ -0,0 +1,19 @@ +

+ Scénarios +

+ +

+ + + + + + + + + + + +
+ {{ field }} +
diff --git a/qa/static/views/theme.html b/qa/static/views/theme.html new file mode 100644 index 00000000..88086d3b --- /dev/null +++ b/qa/static/views/theme.html @@ -0,0 +1,25 @@ +

{{theme.name}} {{theme.authors | stripHTML}}

+ +
+ +
+

+ Défis ({{ exercices.length }}) +

+ +

+ + + + + + + + + + + +
+ {{ field }} +
+