qa: New service to handle QA testing by students

This commit is contained in:
nemunaire 2020-09-08 12:50:41 +02:00
parent a0155c6deb
commit a237936feb
37 changed files with 1476 additions and 0 deletions

1
htdocs-qa Symbolic link
View File

@ -0,0 +1 @@
qa/static

View File

@ -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
}

183
libfic/qa.go Normal file
View File

@ -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
}
}

1
qa/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
qa

36
qa/api/exercice.go Normal file
View File

@ -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
}

111
qa/api/handler.go Normal file
View File

@ -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)
}
}
}

140
qa/api/qa.go Normal file
View File

@ -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()
}

11
qa/api/router.go Normal file
View File

@ -0,0 +1,11 @@
package api
import (
"github.com/julienschmidt/httprouter"
)
var router = httprouter.New()
func Router() *httprouter.Router {
return router
}

64
qa/api/theme.go Normal file
View File

@ -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
}

13
qa/api/version.go Normal file
View File

@ -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
}

120
qa/main.go Normal file
View File

@ -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")
}

66
qa/static.go Normal file
View File

@ -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))
})
}

1
qa/static/css/bootstrap.min.css vendored Symbolic link
View File

@ -0,0 +1 @@
../../../admin/static/css/bootstrap.min.css

1
qa/static/css/fic.css Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/css/fic.css

1
qa/static/css/glyphicon.css Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/css/glyphicon.css

1
qa/static/favicon.ico Symbolic link
View File

@ -0,0 +1 @@
../../frontend/static/favicon.ico

1
qa/static/fonts Symbolic link
View File

@ -0,0 +1 @@
../../frontend/static/fonts/

1
qa/static/img/comcyber.png Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/img/comcyber.png

1
qa/static/img/epita.png Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/img/epita.png

1
qa/static/img/fic.png Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/img/fic.png

1
qa/static/img/srs.png Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/img/srs.png

70
qa/static/index.html Normal file
View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html ng-app="FICApp">
<head>
<meta charset="utf-8">
<title>Challenge Forensic - QA</title>
<link href="{{.urlbase}}css/bootstrap.min.css" type="text/css" rel="stylesheet">
<link href="{{.urlbase}}css/glyphicon.css" type="text/css" rel="stylesheet" media="screen">
<style>
.cksum {
overflow-x: hidden;
text-overflow: ellipsis;
max-width: 100%;
display: inline-block;
vertical-align: middle;
word-wrap: normal;
white-space: nowrap;
}
.bg-mfound {
background-color: #7bcfd0 !important;
}
.bg-ffound {
background-color: #7bdfc0 !important;
}
.bg-wchoices {
background-color: #c07bdf !important;
}
</style>
<base href="{{.urlbase}}">
</head>
<body class="bg-light text-dark">
<nav class="navbar sticky-top navbar-expand-md navbar-dark bg-dark text-light">
<a class="navbar-brand" href=".">
<img alt="FIC" src="img/fic.png" style="height: 30px">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#qaMenu" aria-controls="qaMenu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="qaMenu">
<ul class="navbar-nav mr-auto">
<li class="nav-item"><a class="nav-link" href="themes">Scénarios</a></li>
<li class="nav-item"><a class="nav-link" href="exercices">Défis</a></li>
</ul>
</div>
<span class="navbar-text" ng-controller="VersionController" ng-cloak>
v{{ v.version }} &ndash; Logged as {{ v.auth.name }} (team #{{ v.auth.id_team }})
</span>
</nav>
<div class="progress" style="background-color: #4eaee6; height: 3px; border-radius: 0;">
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{timeProgression * 100}}%"></div>
</div>
<div class="container mt-1" ng-view></div>
<div style="position: fixed; top: 60px; right: 0; z-index: 10; min-width: 30vw;">
<toast ng-repeat="toast in toasts" yes-no="toast.yesFunc || toast.noFunc" onyes="toast.yesFunc" onno="toast.noFunc" date="toast.date" msg="toast.msg" timeout="toast.timeout" title="toast.title" variant="toast.variant"></toast>
</div>
<script src="{{.urlbase}}js/jquery.min.js"></script>
<script src="{{.urlbase}}js/bootstrap.min.js"></script>
<script src="{{.urlbase}}js/angular.min.js"></script>
<script src="{{.urlbase}}js/angular-resource.min.js"></script>
<script src="{{.urlbase}}js/angular-route.min.js"></script>
<script src="{{.urlbase}}js/angular-sanitize.min.js"></script>
<script src="{{.urlbase}}js/qa.js"></script>
<script src="{{.urlbase}}js/common.js"></script>
</body>
</html>

15
qa/static/js/angular-resource.min.js vendored Normal file
View File

@ -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<n&&a.isDefined(e);h++){var g=k[h];e=null!==e?e[g]:void 0}}else e=c;d[f]=e});return d}function s(b){return b.resource}function h(b){M(b||{},this)}var Q=new C(m,g);z=n({},f.defaults.actions,z);h.prototype.toJSON=function(){var b=n({},this);delete b.$promise;delete b.$resolved;delete b.$cancelRequest;return b};r(z,function(b,a){var f=!0===b.hasBody||!1!==b.hasBody&&/^(POST|PUT|PATCH)$/i.test(b.method),g=b.timeout,m=D(b.cancellable)?b.cancellable:Q.defaults.cancellable;g&&!I(g)&&(F.debug("ngResource:\n Only numeric values are allowed as `timeout`.\n Promises are not supported in $resource, because the same value would be used for multiple requests. If you are looking for a way to cancel requests, you should use the `cancellable` option."),
delete b.timeout,g=null);h[a]=function(e,k,J,y){function z(a){p.catch(O);null!==u&&u.resolve(a)}var K={},v,t,w;switch(arguments.length){case 4:w=y,t=J;case 3:case 2:if(x(k)){if(x(e)){t=e;w=k;break}t=k;w=J}else{K=e;v=k;t=J;break}case 1:x(e)?t=e:f?v=e:K=e;break;case 0:break;default:throw B("badargs",arguments.length);}var E=this instanceof h,l=E?v:b.isArray?[]:new h(v),q={},C=b.interceptor&&b.interceptor.request||void 0,D=b.interceptor&&b.interceptor.requestError||void 0,F=b.interceptor&&b.interceptor.response||
s,H=b.interceptor&&b.interceptor.responseError||G.reject,I=t?function(a){t(a,A.headers,A.status,A.statusText)}:void 0;w=w||void 0;var u,L,A;r(b,function(a,b){switch(b){default:q[b]=R(a);case "params":case "isArray":case "interceptor":case "cancellable":}});!E&&m&&(u=G.defer(),q.timeout=u.promise,g&&(L=N(u.resolve,g)));f&&(q.data=v);Q.setUrlParams(q,n({},c(v,b.params||{}),K),b.url);var p=G.resolve(q).then(C).catch(D).then(d),p=p.then(function(c){var e=c.data;if(e){if(P(e)!==!!b.isArray)throw B("badcfg",
a,b.isArray?"array":"object",P(e)?"array":"object",q.method,q.url);if(b.isArray)l.length=0,r(e,function(a){"object"===typeof a?l.push(new h(a)):l.push(a)});else{var d=l.$promise;M(e,l);l.$promise=d}}c.resource=l;A=c;return F(c)},function(a){a.resource=l;A=a;return H(a)}),p=p["finally"](function(){l.$resolved=!0;!E&&m&&(l.$cancelRequest=O,N.cancel(L),u=L=q.timeout=null)});p.then(I,w);return E?p:(l.$promise=p,l.$resolved=!1,m&&(l.$cancelRequest=z),l)};h.prototype["$"+a]=function(b,c,d){x(b)&&(d=c,c=
b,b={});b=h[a].call(this,b,this,c,d);return b.$promise||b}});return h}}]})})(window,window.angular);
//# sourceMappingURL=angular-resource.min.js.map

1
qa/static/js/angular-route.min.js vendored Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/js/angular-route.min.js

1
qa/static/js/angular-sanitize.min.js vendored Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/js/angular-sanitize.min.js

1
qa/static/js/angular.min.js vendored Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/js/angular.min.js

1
qa/static/js/bootstrap.min.js vendored Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/js/bootstrap.min.js

1
qa/static/js/common.js Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/js/common.js

1
qa/static/js/d3.v3.min.js vendored Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/js/d3.v3.min.js

1
qa/static/js/i18n Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/js/i18n/

1
qa/static/js/jquery.min.js vendored Symbolic link
View File

@ -0,0 +1 @@
../../../frontend/static/js/jquery.min.js

414
qa/static/js/qa.js Normal file
View File

@ -0,0 +1,414 @@
angular.module("FICApp", ["ngRoute", "ngResource", "ngSanitize"])
.config(function($routeProvider, $locationProvider) {
$routeProvider
.when("/themes", {
controller: "ThemesListController",
templateUrl: "views/theme-list.html"
})
.when("/themes/:themeId", {
controller: "ThemeController",
templateUrl: "views/theme.html"
})
.when("/themes/:themeId/exercices/:exerciceId", {
controller: "ExerciceController",
templateUrl: "views/exercice.html"
})
.when("/exercices", {
controller: "AllExercicesListController",
templateUrl: "views/exercice-list.html"
})
.when("/exercices/:exerciceId", {
controller: "ExerciceController",
templateUrl: "views/exercice.html"
})
.when("/", {
templateUrl: "views/home.html"
});
$locationProvider.html5Mode(true);
});
angular.module("FICApp")
.directive('autofocus', ['$timeout', function($timeout) {
return {
restrict: 'A',
link : function($scope, $element) {
$timeout(function() {
$element[0].focus();
});
}
}
}])
.component('toast', {
bindings: {
date: '=',
msg: '=',
timeout: '=',
title: '=',
variant: '=',
yesNo: '=',
onyes: '=',
onno: '=',
},
controller: function($element) {
if (this.timeout === 0)
$element.children(0).toast({autohide: false});
else if (!this.timeout && this.timeout !== 0)
$element.children(0).toast({delay: 7000});
else
$element.children(0).toast({delay: this.timeout});
$element.children(0).toast('show');
this.yesFunc = function() {
$element.children(0).toast('dispose');
if (this.onyes)
this.onyes();
}
this.noFunc = function() {
$element.children(0).toast('dispose');
if (this.onno)
this.onno();
}
},
template: `<div class="toast mb-2" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<span ng-if="$ctrl.variant" class="badge badge-pill badge-{{ $ctrl.variant }}" style="padding: .25em .66em">&nbsp;</span>&nbsp;
<strong class="mr-auto" ng-bind="$ctrl.title"></strong>
<small class="text-muted" ng-bind="$ctrl.date">just now</small>
<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="toast-body" ng-bind-html="$ctrl.msg" ng-if="$ctrl.msg"></div>
<div class="d-flex justify-content-around mb-1" ng-if="$ctrl.yesNo">
<button type="button" class="ml-2 btn btn-sm btn-success" ng-click="$ctrl.yesFunc()">Yes</button>
<button type="button" class="btn btn-sm btn-danger" ng-click="$ctrl.noFunc()">No</button>
</div>
</div>`
});
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);
});
}
}
});

View File

@ -0,0 +1,27 @@
<h2>
Défis
</h2>
<div>
<p><input type="search" class="form-control" placeholder="Filtrer" ng-model="query" ng-keypress="validateSearch($event)" autofocus></p>
<table class="table table-hover table-bordered table-striped table-sm">
<thead class="thead-dark">
<tr>
<th ng-repeat="field in fields">
{{ field }}
</th>
<th>
Scénario
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="exercice in exercices | filter: query">
<td ng-repeat="field in fields" ng-click="show(exercice.id)" ng-bind-html="exercice[field]"></td>
<td>
<a ng-href="themes/{{ exercice.id_theme }}">{{ themes[exercice.id_theme].name }}</a>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,105 @@
<h2>
{{exercice.title}}
<small ng-if="themes && themes[exercice.id_theme]"><a href="themes/{{ exercice.id_theme }}" title="{{themes[exercice.id_theme].authors | stripHTML}}">{{themes[exercice.id_theme].name}}</a></small>
<div class="btn-group" role="group" ng-if="themes[exercice.id_theme].exercices[exercice.id]">
<a href="exercices/{{ themes[exercice.id_theme].exercices[exercice.id].previous }}" title="Exercice précédent" ng-class="{'disabled': !themes[exercice.id_theme].exercices[exercice.id].previous}" class="btn btn-sm btn-light"><span class="glyphicon glyphicon-chevron-left" aria-hidden="true"></span></a>
<a href="exercices/{{ themes[exercice.id_theme].exercices[exercice.id].next }}" title="Exercice suivant" ng-class="{'disabled': !themes[exercice.id_theme].exercices[exercice.id].next}" class="btn btn-sm btn-light"><span class="glyphicon glyphicon-chevron-right" aria-hidden="true"></span></a>
</div>
</h2>
<div class="row mb-3">
<div class="col-md-6" ng-bind-html="exercice.statement"></div>
<div class="col-md-6" ng-bind-html="exercice.overview"></div>
</div>
<div ng-controller="ExerciceQAController" class="mb-5">
<form ng-submit="saveQuery()" class="card mb-3">
<div class="card-header">
Qu'avez-vous pensé de ce défi ?
</div>
<div class="card-body">
<div class="form-group row" ng-repeat="(field, namedField) in namedFields">
<label for="{{ field }}" class="col-2 col-form-label col-form-label-sm">{{ namedField }}</label>
<div class="col-10">
<input type="text" class="form-control form-control-sm" id="{{ field }}" ng-model="newQuery[field]" ng-if="field != 'state' && field != 'content'">
<select class="custom-select custom-select-sm" id="{{ field }}" ng-model="newQuery[field]" ng-options="k as v for (k, v) in states" ng-if="field == 'state'"></select>
<textarea class="form-control form-control-sm" placeholder="Ajouter un commentaire" rows="2" id="{{ field }}" ng-model="newQuery[field]" ng-if="field == 'content' && !newQuery.id"></textarea>
</div>
</div>
<button type="submit" class="btn btn-primary float-right">
Soumettre
</button>
</div>
</form>
<table class="table table-bordered table-striped" ng-class="{'table-hover': queries.length, 'table-sm': queries.length}">
<thead class="thead-dark">
<tr>
<th ng-repeat="field in fields">
{{ field }}
</th>
</tr>
</thead>
<tbody ng-if="queries.length">
<tr ng-repeat="(qid, q) in queries" ng-click="showComments(qid)" ng-class="{'bg-warning': qid == query_selected}">
<td ng-repeat="field in fields" ng-bind-html="q[field]"></td>
</tr>
</tbody>
<tbody ng-if="!queries.length">
<tr>
<td colspan="{{ fields.length }}" class="font-weight-bold text-info text-center">Aucun requête enregistrée</td>
</tr>
</tbody>
</table>
<div ng-if="query_selected !== null" class="card">
<div class="card-header">
<h4>{{ queries[query_selected].subject }}</h4>
</div>
<div class="card-body">
<div class="row">
<dl class="col-9 row">
<dt class="col-3">Qui ?</dt>
<dd class="col-9">{{ queries[query_selected].user }} (team #{{ queries[query_selected].id_team}})</dd>
<dt class="col-3">État</dt>
<dd class="col-9">{{ queries[query_selected].state }}</dd>
<dt class="col-3">Date de création</dt>
<dd class="col-9">{{ queries[query_selected].creation }}</dd>
<dt class="col-3">Date de résolution</dt>
<dd class="col-9">{{ queries[query_selected].solved }}</dd>
<dt class="col-3">Date de clôture</dt>
<dd class="col-9">{{ queries[query_selected].closed }}</dd>
</dl>
<div class="col-3">
<button ng-click="updateQA(queries[query_selected].id)" class="btn btn-secondary">
Mettre à jour
</button>
<button ng-click="solveQA(queries[query_selected].id)" class="btn btn-info">
Marqué comme résolu
</button>
<button ng-click="deleteQA(queries[query_selected].id)" class="btn btn-danger">
Supprimer
</button>
</div>
</div>
<table class="table table-striped">
<tr ng-repeat="comment in queries_comments">
<td>
Le {{ comment.date }}, <strong>{{ comment.user }}</strong> a écrit : {{ comment.content }}
</td>
</tr>
</table>
<form ng-submit="addComment()">
<labe for="newComment">Répondre :</label>
<textarea class="form-control" placeholder="Ajouter un commentaire" rows="2" id="newComment" ng-model="newComment.content"></textarea>
<button type="submit" class="btn btn-primary mt-1 float-right">
Ajouter le commentaire
</button>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,7 @@
<div class="jumbotron text-light bg-dark">
<h1 class="display-5">Interface QA du challenge</h1>
<div class="row">
<div class="col">
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
<h2>
Scénarios
</h2>
<p><input type="search" class="form-control" placeholder="Filtrer" ng-model="query" ng-keypress="validateSearch($event)" autofocus></p>
<table class="table table-hover table-bordered table-striped">
<thead class="thead-dark">
<tr>
<th ng-repeat="field in fields">
{{ field }}
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="theme in themes | filter: query" ng-click="show(theme.id)">
<td ng-repeat="field in fields" ng-bind-html="theme[field]"></td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,25 @@
<h2>{{theme.name}} <small class="text-muted">{{theme.authors | stripHTML}}</small></h2>
<div class="container" ng-bind-html="theme.intro"></div>
<div ng-if="theme.id" ng-controller="ExercicesListController">
<h3>
Défis ({{ exercices.length }})
</h3>
<p><input type="search" class="form-control form-control-sm" placeholder="Search" ng-model="query" autofocus></p>
<table class="table table-hover table-bordered table-striped table-sm">
<thead class="thead-dark">
<tr>
<th ng-repeat="field in fields">
{{ field }}
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="exercice in exercices | filter: query" ng-click="show(exercice.id)">
<td ng-repeat="field in fields" ng-bind-html="exercice[field]"></td>
</tr>
</tbody>
</table>
</div>