Compare commits

..

1 commit

Author SHA1 Message Date
1f00d50490 OIDC: Retrieve face pictures from claim
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-11 11:20:13 +01:00
132 changed files with 5752 additions and 8508 deletions

View file

@ -1,6 +1,56 @@
---
kind: pipeline
type: docker
name: build-arm
platform:
os: linux
arch: arm
steps:
- name: build front
image: node:19-alpine
commands:
- mkdir deploy
- cd ui
- npm install --network-timeout=100000
- npm run build
- tar chjf ../deploy/static.tar.bz2 build
- name: vet
image: golang:1-alpine
commands:
- apk --no-cache add build-base
- go vet -v -buildvcs=false
- name: backend armv7
image: golang:1-alpine
commands:
- apk --no-cache add build-base
- go get -v
- go build -v -buildvcs=false -ldflags="-s -w"
environment:
GOARM: 7
- name: publish
image: plugins/docker
settings:
repo: nemunaire/atsebay.t
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
event:
- cron
- push
- tag
---
kind: pipeline
type: docker
name: build-arm64
platform:
@ -9,7 +59,7 @@ platform:
steps:
- name: build front
image: node:21-alpine
image: node:19-alpine
commands:
- mkdir deploy
- cd ui
@ -69,4 +119,5 @@ trigger:
- tag
depends_on:
- build-arm
- build-arm64

View file

@ -1,4 +1,4 @@
FROM node:21-alpine as nodebuild
FROM node:19-alpine as nodebuild
WORKDIR /ui
@ -22,7 +22,7 @@ RUN go get -d -v && \
go build -v -buildvcs=false -ldflags="-s -w" -o atsebay.t
FROM alpine:3.19
FROM alpine:3.16
EXPOSE 8081

4
api.go
View file

@ -13,11 +13,9 @@ func declareAPIRoutes(router *gin.Engine) {
apiRoutes.Use(authMiddleware())
declareAPIAuthRoutes(apiRoutes)
declareAPICategoriesRoutes(apiRoutes)
declareAPISurveysRoutes(apiRoutes)
declareAPIWorksRoutes(apiRoutes)
declareAPIKeysRoutes(apiRoutes)
declareAPISharesRoutes(apiRoutes)
declareCallbacksRoutes(apiRoutes)
authRoutes := router.Group("")
@ -52,9 +50,7 @@ func declareAPIRoutes(router *gin.Engine) {
declareAPIAdminAuthRoutes(apiAdminRoutes)
declareAPIAdminAsksRoutes(apiAdminRoutes)
declareAPIAdminCategoriesRoutes(apiRoutes)
declareAPIAuthGradesRoutes(apiAdminRoutes)
declareAPIAdminGradationRoutes(apiAdminRoutes)
declareAPIAdminHelpRoutes(apiAdminRoutes)
declareAPIAdminQuestionsRoutes(apiAdminRoutes)
declareAPIAuthRepositoriesRoutes(apiAdminRoutes)

View file

@ -10,7 +10,7 @@ import (
"net/http"
)
//go:embed all:ui/build
//go:embed ui/build/* ui/build/css/* ui/build/surveys/* ui/build/_app/* ui/build/_app/immutable/* ui/build/_app/immutable/pages/* ui/build/_app/immutable/pages/surveys/* ui/build/_app/immutable/pages/surveys/_sid_/* ui/build/_app/immutable/pages/surveys/_sid_/responses/* ui/build/_app/immutable/pages/grades/* ui/build/_app/immutable/pages/works/* ui/build/_app/immutable/pages/works/_wid_/* ui/build/_app/immutable/pages/users/* ui/build/_app/immutable/pages/users/_uid_/* ui/build/_app/immutable/pages/users/_uid_/surveys/* ui/build/_app/immutable/chunks/* ui/build/_app/immutable/assets/* ui/build/img/* ui/build/works/*
var _assets embed.FS
var Assets http.FileSystem

29
auth.go
View file

@ -13,7 +13,6 @@ import (
var LocalAuthFunc = checkAuthKrb5
var allowLocalAuth bool
var localAuthUsers arrayFlags
var mainBanner string
type loginForm struct {
Login string `json:"username"`
@ -48,18 +47,16 @@ func declareAPIAdminAuthRoutes(router *gin.RouterGroup) {
session.Update()
c.JSON(http.StatusOK, authToken{
User: newuser,
CurrentPromo: currentPromo,
MessageBanner: mainBanner,
User: newuser,
CurrentPromo: currentPromo,
})
})
}
type authToken struct {
*User
CurrentPromo uint `json:"current_promo"`
Groups []string `json:"groups"`
MessageBanner string `json:"banner,omitempty"`
CurrentPromo uint `json:"current_promo"`
Groups []string `json:"groups"`
}
func validateAuthToken(c *gin.Context) {
@ -67,7 +64,7 @@ func validateAuthToken(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not connected"})
return
} else {
t := authToken{User: u.(*User), CurrentPromo: currentPromo, MessageBanner: mainBanner}
t := authToken{User: u.(*User), CurrentPromo: currentPromo}
t.Groups = strings.Split(strings.TrimFunc(t.User.Groups, func(r rune) bool { return !unicode.IsLetter(r) }), ",")
@ -80,7 +77,7 @@ func logout(c *gin.Context) {
c.JSON(http.StatusOK, true)
}
func completeAuth(c *gin.Context, username string, email string, firstname string, lastname string, promo uint, groups string, session *Session) (usr *User, err error) {
func completeAuth(c *gin.Context, username string, email string, firstname string, lastname string, promo uint, groups string, face_url string, session *Session) (usr *User, err error) {
if !userExists(username) {
if promo == 0 {
promo = currentPromo
@ -117,10 +114,14 @@ func completeAuth(c *gin.Context, username string, email string, firstname strin
if session == nil {
session, err = usr.NewSession()
} else {
_, err = session.SetUser(usr)
if err != nil {
return
}
}
if face_url != "" {
session.SetKey("picture", face_url)
}
_, err = session.SetUser(usr)
if err != nil {
return
}
@ -156,10 +157,10 @@ func dummyAuth(c *gin.Context) {
return
}
if usr, err := completeAuth(c, lf["username"], lf["email"], lf["firstname"], lf["lastname"], currentPromo, "", nil); err != nil {
if usr, err := completeAuth(c, lf["username"], lf["email"], lf["firstname"], lf["lastname"], currentPromo, "", "", nil); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": err.Error()})
return
} else {
c.JSON(http.StatusOK, authToken{User: usr, CurrentPromo: currentPromo, MessageBanner: mainBanner})
c.JSON(http.StatusOK, authToken{User: usr, CurrentPromo: currentPromo})
}
}

View file

@ -83,7 +83,7 @@ func checkAuthKrb5(c *gin.Context) {
return
}
if usr, err := completeAuth(c, lf.Login, lf.Login+"@epita.fr", "", "", 0, "", nil); err != nil {
if usr, err := completeAuth(c, lf.Login, lf.Login+"@epita.fr", "", "", currentPromo, "", "", nil); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": err.Error()})
return
} else {

View file

@ -48,7 +48,7 @@ func initializeOIDC(router *gin.Engine) {
Endpoint: provider.Endpoint(),
// "openid" is a required scope for OpenID Connect flows.
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "epita"},
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "epita", "picture"},
}
oidcConfig := oidc.Config{
@ -112,6 +112,9 @@ func OIDC_CRI_complete(c *gin.Context) {
Groups []map[string]interface{} `json:"groups"`
Campuses []string `json:"campuses"`
GraduationYears []uint `json:"graduation_years"`
Picture string `json:"picture"`
PictureSquare string `json:"picture_square"`
PictureThumb string `json:"picture_thumb"`
}
if err := idToken.Claims(&claims); err != nil {
log.Println("Unable to extract claims to Claims:", err.Error())
@ -135,7 +138,7 @@ func OIDC_CRI_complete(c *gin.Context) {
}
}
if _, err := completeAuth(c, claims.Username, claims.Email, claims.Firstname, claims.Lastname, promo, groups, session); err != nil {
if _, err := completeAuth(c, claims.Username, claims.Email, claims.Firstname, claims.Lastname, promo, groups, claims.PictureSquare, session); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}

View file

@ -1,168 +0,0 @@
package main
import (
"fmt"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func declareAPICategoriesRoutes(router *gin.RouterGroup) {
categoriesRoutes := router.Group("/categories/:cid")
categoriesRoutes.Use(categoryHandler)
categoriesRoutes.GET("", func(c *gin.Context) {
c.JSON(http.StatusOK, c.MustGet("category").(*Category))
})
}
func declareAPIAdminCategoriesRoutes(router *gin.RouterGroup) {
router.GET("categories", func(c *gin.Context) {
categories, err := getCategories()
if err != nil {
log.Println("Unable to getCategories:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve categories. Please try again later."})
return
}
c.JSON(http.StatusOK, categories)
})
router.POST("categories", func(c *gin.Context) {
var new Category
if err := c.ShouldBindJSON(&new); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if new.Promo == 0 {
new.Promo = currentPromo
}
if cat, err := NewCategory(new.Label, new.Promo, new.Expand); err != nil {
log.Println("Unable to NewCategory:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during category creation: %s", err.Error())})
return
} else {
c.JSON(http.StatusOK, cat)
}
})
categoriesRoutes := router.Group("/categories/:cid")
categoriesRoutes.Use(categoryHandler)
categoriesRoutes.PUT("", func(c *gin.Context) {
current := c.MustGet("category").(*Category)
var new Category
if err := c.ShouldBindJSON(&new); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
new.Id = current.Id
if _, err := new.Update(); err != nil {
log.Println("Unable to Update category:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to update the given category. Please try again later."})
return
} else {
c.JSON(http.StatusOK, new)
}
})
categoriesRoutes.DELETE("", func(c *gin.Context) {
current := c.MustGet("category").(*Category)
if _, err := current.Delete(); err != nil {
log.Println("Unable to Delete category:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to delete the given category. Please try again later."})
return
} else {
c.JSON(http.StatusOK, nil)
}
})
}
func categoryHandler(c *gin.Context) {
var category *Category
cid, err := strconv.Atoi(string(c.Param("cid")))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad category identifier."})
return
} else {
category, err = getCategory(cid)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Category not found."})
return
}
}
c.Set("category", category)
c.Next()
}
type Category struct {
Id int64 `json:"id"`
Label string `json:"label"`
Promo uint `json:"promo"`
Expand bool `json:"expand,omitempty"`
}
func getCategories() (categories []Category, err error) {
if rows, errr := DBQuery("SELECT id_category, label, promo, expand FROM categories ORDER BY promo DESC, expand DESC, id_category DESC"); errr != nil {
return nil, errr
} else {
defer rows.Close()
for rows.Next() {
var c Category
if err = rows.Scan(&c.Id, &c.Label, &c.Promo, &c.Expand); err != nil {
return
}
categories = append(categories, c)
}
if err = rows.Err(); err != nil {
return
}
return
}
}
func getCategory(id int) (c *Category, err error) {
c = new(Category)
err = DBQueryRow("SELECT id_category, label, promo, expand FROM categories WHERE id_category=?", id).Scan(&c.Id, &c.Label, &c.Promo, &c.Expand)
return
}
func NewCategory(label string, promo uint, expand bool) (*Category, error) {
if res, err := DBExec("INSERT INTO categories (label, promo, expand) VALUES (?, ?, ?)", label, promo, expand); err != nil {
return nil, err
} else if cid, err := res.LastInsertId(); err != nil {
return nil, err
} else {
return &Category{cid, label, promo, expand}, nil
}
}
func (c *Category) Update() (int64, error) {
if res, err := DBExec("UPDATE categories SET label = ?, promo = ?, expand = ? WHERE id_category = ?", c.Label, c.Promo, c.Expand, c.Id); err != nil {
return 0, err
} else if nb, err := res.RowsAffected(); err != nil {
return 0, err
} else {
return nb, err
}
}
func (c *Category) Delete() (int64, error) {
if res, err := DBExec("DELETE FROM categories WHERE id_category = ?", c.Id); err != nil {
return 0, err
} else if nb, err := res.RowsAffected(); err != nil {
return 0, err
} else {
return nb, err
}
}

36
db.go
View file

@ -93,7 +93,6 @@ CREATE TABLE IF NOT EXISTS user_keys(
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS surveys(
id_survey INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
id_category INTEGER NOT NULL,
title VARCHAR(255),
promo MEDIUMINT NOT NULL,
grp VARCHAR(255) NOT NULL,
@ -101,8 +100,7 @@ CREATE TABLE IF NOT EXISTS surveys(
direct INTEGER DEFAULT NULL,
corrected BOOLEAN NOT NULL DEFAULT FALSE,
start_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(id_category) REFERENCES categories(id_category)
end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin;
`); err != nil {
return err
@ -202,7 +200,6 @@ CREATE TABLE IF NOT EXISTS user_need_help(
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS works(
id_work INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
id_category INTEGER NOT NULL,
title VARCHAR(255),
promo MEDIUMINT NOT NULL,
grp VARCHAR(255) NOT NULL,
@ -210,11 +207,9 @@ CREATE TABLE IF NOT EXISTS works(
description TEXT NOT NULL,
tag VARCHAR(255) NOT NULL,
submission_URL VARCHAR(255) NULL,
gradation_repo VARCHAR(255) NULL,
corrected BOOLEAN NOT NULL DEFAULT FALSE,
start_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(id_category) REFERENCES categories(id_category)
end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin;
`); err != nil {
return err
@ -242,8 +237,6 @@ CREATE TABLE IF NOT EXISTS user_work_repositories(
secret BLOB NOT NULL,
last_check TIMESTAMP NULL DEFAULT NULL,
droneref VARCHAR(255) NOT NULL,
last_tests TIMESTAMP NULL DEFAULT NULL,
testsref VARCHAR(255) NOT NULL,
FOREIGN KEY(id_user) REFERENCES users(id_user),
FOREIGN KEY(id_work) REFERENCES works(id_work),
UNIQUE one_repo_per_work (id_user, id_work)
@ -252,33 +245,12 @@ CREATE TABLE IF NOT EXISTS user_work_repositories(
return err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS categories(
id_category INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
label VARCHAR(255) NOT NULL,
promo MEDIUMINT NOT NULL,
expand BOOLEAN NOT NULL DEFAULT FALSE
) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin;
CREATE VIEW IF NOT EXISTS student_scores AS SELECT T.id_user, T.id_survey, Q.id_question, MAX(R.score) AS score FROM (SELECT DISTINCT R.id_user, S.id_survey FROM survey_responses R INNER JOIN survey_quests Q ON R.id_question = Q.id_question INNER JOIN surveys S ON Q.id_survey = S.id_survey) T LEFT OUTER JOIN survey_quests Q ON T.id_survey = Q.id_survey LEFT OUTER JOIN survey_responses R ON R.id_user = T.id_user AND Q.id_question = R.id_question GROUP BY id_user, id_survey, id_question;
`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS survey_shared(
id_share INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
id_survey INTEGER NOT NULL,
secret BLOB NOT NULL,
count MEDIUMINT UNSIGNED NOT NULL DEFAULT 0,
FOREIGN KEY(id_survey) REFERENCES surveys(id_survey)
) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin;
`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE VIEW IF NOT EXISTS student_scores AS SELECT "survey" AS kind, T.id_user, T.id_survey AS id, Q.id_question, MAX(R.score) AS score FROM (SELECT DISTINCT R.id_user, S.id_survey FROM survey_responses R INNER JOIN survey_quests Q ON R.id_question = Q.id_question INNER JOIN surveys S ON Q.id_survey = S.id_survey) T LEFT OUTER JOIN survey_quests Q ON T.id_survey = Q.id_survey LEFT OUTER JOIN survey_responses R ON R.id_user = T.id_user AND Q.id_question = R.id_question GROUP BY id_user, kind, id, id_question UNION SELECT "work" AS kind, G.id_user, G.id_work AS id, 0 AS id_question, G.grade AS score FROM works W RIGHT OUTER JOIN user_work_grades G ON G.id_work = W.id_work GROUP BY id_user, kind, id, id_question;
`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE VIEW IF NOT EXISTS all_works AS SELECT "work" AS kind, id_work AS id, id_category, title, promo, grp, shown, NULL AS direct, submission_url, corrected, start_availability, end_availability FROM works UNION SELECT "survey" AS kind, id_survey AS id, id_category, title, promo, grp, shown, direct, NULL AS submission_url, corrected, start_availability, end_availability FROM surveys;
CREATE VIEW IF NOT EXISTS all_works AS SELECT "work" AS kind, id_work AS id, title, promo, grp, shown, NULL AS direct, submission_url, corrected, start_availability, end_availability FROM works UNION SELECT "survey" AS kind, id_survey AS id, title, promo, grp, shown, direct, NULL AS submission_url, corrected, start_availability, end_availability FROM surveys;
`); err != nil {
return err
}

View file

@ -2,10 +2,8 @@ package main
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
@ -282,62 +280,6 @@ func getCorrectionString(qid int64) (ret map[string]int) {
return
}
func getResponsesStats(qid int64) map[string]interface{} {
q, err := getQuestion(int(qid))
if err != nil {
return nil
}
responses, err := q.GetResponses()
if err != nil {
log.Println("Unable to retrieve responses:", err)
return nil
}
labels := []string{}
values := []uint{}
if q.Kind == "mcq" || q.Kind == "ucq" {
proposals, err := q.GetProposals()
if err != nil {
return nil
}
proposal_idx := map[string]int{}
for _, p := range proposals {
proposal_idx[fmt.Sprintf("%d", p.Id)] = len(labels)
labels = append(labels, p.Label)
values = append(values, 0)
}
for _, r := range responses {
for _, v := range strings.Split(r.Answer, ",") {
values[proposal_idx[v]]++
}
}
} else {
stats := map[string]uint{}
for _, r := range responses {
stats[r.Answer]++
}
for k, v := range stats {
labels = append(labels, k)
values = append(values, v)
}
}
return map[string]interface{}{
"labels": labels,
"datasets": []map[string][]uint{
map[string][]uint{
"values": values,
},
},
}
}
func SurveyWSAdmin(c *gin.Context) {
u := c.MustGet("LoggedUser").(*User)
survey := c.MustGet("survey").(*Survey)
@ -367,7 +309,6 @@ func SurveyWSAdmin(c *gin.Context) {
go func(c chan WSMessage, sid int) {
var v WSMessage
var err error
var surveyTimer *time.Timer
for {
// Reset variable state
v.Corrected = false
@ -385,35 +326,19 @@ func SurveyWSAdmin(c *gin.Context) {
if survey, err := getSurvey(sid); err != nil {
log.Println("Unable to retrieve survey:", err)
} else {
// Skip any existing scheduled timer
if surveyTimer != nil {
if !surveyTimer.Stop() {
<-surveyTimer.C
}
surveyTimer = nil
}
survey.Direct = v.QuestionId
if v.Timer > 0 {
survey.Corrected = false
survey.Update()
// Save corrected state for the callback
corrected := v.Corrected
with_stats := v.Stats != nil
go func(corrected bool) {
time.Sleep(time.Duration(OffsetQuestionTimer+v.Timer) * time.Millisecond)
surveyTimer = time.AfterFunc(time.Duration(OffsetQuestionTimer+v.Timer)*time.Millisecond, func() {
surveyTimer = nil
if corrected {
survey.Corrected = v.Corrected
survey.Update()
var stats map[string]interface{}
if with_stats {
stats = getResponsesStats(*v.QuestionId)
}
survey.WSWriteAll(WSMessage{Action: "new_question", QuestionId: v.QuestionId, Corrected: true, Stats: stats, Corrections: getCorrectionString(*v.QuestionId)})
survey.WSWriteAll(WSMessage{Action: "new_question", QuestionId: v.QuestionId, Corrected: true, Corrections: getCorrectionString(*v.QuestionId)})
} else {
var z int64 = 0
survey.Direct = &z
@ -422,20 +347,12 @@ func SurveyWSAdmin(c *gin.Context) {
survey.WSWriteAll(WSMessage{Action: "pause"})
WSAdminWriteAll(WSMessage{Action: "pause", SurveyId: &survey.Id})
}
})
}(v.Corrected)
v.Corrected = false
v.Stats = nil
} else {
survey.Corrected = v.Corrected
if v.Corrected {
v.Corrections = getCorrectionString(*v.QuestionId)
if v.Stats != nil {
v.Stats = getResponsesStats(*v.QuestionId)
} else {
v.Stats = nil
}
} else {
v.Stats = nil
}
}
_, err = survey.Update()

View file

@ -170,13 +170,11 @@ type GitLabUser struct {
Username string
Name string
State string
Email string
}
type GitLabUserKey struct {
ID int
Key string
UsageType string `json:"usage_type"`
ID int
Key string
}
type GitLabRepository struct {
@ -271,7 +269,7 @@ func GitLab_getUsersRepositories(c context.Context, u *User) ([]*GitLabRepositor
return repositories, err
}
func GitLab_getUser(c context.Context, u *User) (*GitLabUser, error) {
func GitLab_getUserId(c context.Context, u *User) (int, error) {
client := gitlaboauth2Config.Client(c, gitlabToken())
val := url.Values{}
@ -279,35 +277,26 @@ func GitLab_getUser(c context.Context, u *User) (*GitLabUser, error) {
req, err := http.NewRequest("GET", gitlabBaseURL+fmt.Sprintf("/api/v4/users?%s", val.Encode()), nil)
if err != nil {
return nil, err
return 0, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
return 0, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Bad status code from the API")
return 0, fmt.Errorf("Bad status code from the API")
}
var users []*GitLabUser
err = json.NewDecoder(resp.Body).Decode(&users)
if len(users) == 0 {
return nil, fmt.Errorf("Login not found in GitLab")
return 0, fmt.Errorf("Login not found in GitLab")
}
return users[0], nil
}
func GitLab_getUserId(c context.Context, u *User) (int, error) {
user, err := GitLab_getUser(c, u)
if err != nil {
return 0, err
}
return user.ID, nil
return users[0].ID, nil
}
func GitLab_getUserPGPKeys(c context.Context, u *User) ([]byte, error) {
@ -345,33 +334,3 @@ func GitLab_getUserPGPKeys(c context.Context, u *User) ([]byte, error) {
return b.Bytes(), nil
}
func GitLab_getUserSSHKeys(c context.Context, u *User) ([]*GitLabUserKey, error) {
userid, err := GitLab_getUserId(c, u)
if err != nil {
return nil, err
}
client := gitlaboauth2Config.Client(c, gitlabToken())
req, err := http.NewRequest("GET", gitlabBaseURL+fmt.Sprintf("/api/v4/users/%d/keys", userid), nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
rep, _ := ioutil.ReadAll(resp.Body)
log.Printf("%d %s", resp.StatusCode, rep)
return nil, fmt.Errorf("Bad status code from the API")
}
var keys []*GitLabUserKey
err = json.NewDecoder(resp.Body).Decode(&keys)
return keys, err
}

64
go.mod
View file

@ -1,36 +1,28 @@
module git.nemunai.re/atsebay.t
go 1.21
toolchain go1.22.1
go 1.18
require (
github.com/ProtonMail/go-crypto v1.0.0
github.com/aws/aws-sdk-go v1.51.9
github.com/coreos/go-oidc/v3 v3.10.0
github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4
github.com/aws/aws-sdk-go v1.44.135
github.com/coreos/go-oidc/v3 v3.4.0
github.com/drone/drone-go v1.7.1
github.com/gin-gonic/gin v1.9.1
github.com/go-sql-driver/mysql v1.8.1
github.com/jcmturner/gokrb5/v8 v8.4.4
github.com/gin-gonic/gin v1.8.1
github.com/go-sql-driver/mysql v1.6.0
github.com/jcmturner/gokrb5/v8 v8.4.3
github.com/russross/blackfriday/v2 v2.1.0
golang.org/x/oauth2 v0.18.0
nhooyr.io/websocket v1.8.10
golang.org/x/oauth2 v0.2.0
nhooyr.io/websocket v1.8.7
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/cloudflare/circl v1.3.3 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/cloudflare/circl v1.1.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.10.0 // indirect
github.com/goccy/go-json v0.9.7 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
@ -39,22 +31,18 @@ require (
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.10.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.31.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.1 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
golang.org/x/net v0.2.0 // indirect
golang.org/x/sys v0.2.0 // indirect
golang.org/x/text v0.4.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

882
go.sum

File diff suppressed because it is too large Load diff

View file

@ -1,121 +0,0 @@
package main
import (
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
"github.com/drone/drone-go/drone"
"github.com/gin-gonic/gin"
)
func declareAPIAdminGradationRoutes(router *gin.RouterGroup) {
router.GET("/gradation_repositories", func(c *gin.Context) {
client := drone.NewClient(droneEndpoint, droneConfig)
result, err := client.RepoList()
if err != nil {
log.Println("Unable to retrieve the repository list:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve the repository list."})
return
}
c.JSON(http.StatusOK, result)
})
router.POST("/gradation_repositories/sync", func(c *gin.Context) {
client := drone.NewClient(droneEndpoint, droneConfig)
result, err := client.RepoListSync()
if err != nil {
log.Println("Unable to retrieve the repository list:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve the repository list."})
return
}
c.JSON(http.StatusOK, result)
})
}
type TestsWebhook struct {
Login string `json:"login"`
RepositoryId int `json:"repository_id"`
BuildNumber int `json:"build_number"`
UpTo float64 `json:"upto"`
Steps map[string]float64 `json:"steps,omitempty"`
}
func (tw *TestsWebhook) fetchRepoTests(r *Repository) error {
tmp := strings.Split(r.TestsRef, "/")
if len(tmp) < 3 {
return fmt.Errorf("This repository tests reference is not filled properly.")
}
work, err := getWork(r.IdWork)
if err != nil {
return fmt.Errorf("Unable to retrieve the related work: %w", err)
}
client := drone.NewClient(droneEndpoint, droneConfig)
result, err := client.Build(tmp[0], tmp[1], tw.BuildNumber)
if err != nil {
return fmt.Errorf("Unable to find the referenced build (%d): %w", tw.BuildNumber, err)
}
if result.Finished > 0 {
return fmt.Errorf("The test phase is not finished")
}
var grade float64
for _, stage := range result.Stages {
for _, step := range stage.Steps {
if g, ok := tw.Steps[fmt.Sprintf("%d", step.Number)]; ok {
log.Printf("Step %q (%d) in status %q", step.Name, step.Number, step.Status)
// Give the point if it succeed
if step.Status == "success" {
grade += g
}
continue
}
if g, ok := tw.Steps[step.Name]; ok {
log.Printf("Step %q (%d) in status %q", step.Name, step.Number, step.Status)
// Give the point if it succeed
if step.Status == "success" {
grade += g
}
continue
}
logs, err := client.Logs(tmp[0], tmp[1], tw.BuildNumber, stage.Number, step.Number)
if err != nil {
log.Printf("Unable to retrieve build logs %s/%s/%d/%d/%d: %s", tmp[0], tmp[1], tw.BuildNumber, stage.Number, step.Number, err.Error())
continue
}
if len(logs) < 2 {
continue
}
line := logs[len(logs)-1]
if strings.HasPrefix(logs[len(logs)-2].Message, "+ echo grade:") && strings.HasPrefix(line.Message, "grade:") {
g, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimPrefix(line.Message, "grade:")), 64)
if err == nil {
grade += g
} else {
log.Println("Unable to parse grade:", err.Error())
}
}
}
}
if tw.UpTo != 0 {
grade = math.Trunc(grade*2000/tw.UpTo) / 100
}
work.AddGrade(WorkGrade{
IdUser: r.IdUser,
IdWork: work.Id,
Grade: grade,
})
return nil
}

View file

@ -1,10 +1,8 @@
package main
import (
"fmt"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
@ -67,44 +65,28 @@ func declareAPIAuthGradesRoutes(router *gin.RouterGroup) {
})
}
func gradeHandler(c *gin.Context) {
work := c.MustGet("work").(*Work)
if gid, err := strconv.Atoi(string(c.Param("gid"))); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad grade identifier."})
return
} else if grade, err := work.GetGrade(int64(gid)); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Grade not found."})
return
} else {
c.Set("grade", grade)
c.Next()
}
}
func GetAllGrades() (scores map[int64]map[string]*float64, err error) {
if rows, errr := DBQuery("SELECT id_user, kind, id, SUM(score)/COUNT(*) FROM student_scores GROUP BY id_user, kind, id"); errr != nil {
func GetAllGrades() (scores map[int64]map[int64]*float64, err error) {
if rows, errr := DBQuery("SELECT id_user, id_survey, SUM(score)/COUNT(*) FROM student_scores GROUP BY id_user, id_survey"); errr != nil {
return nil, errr
} else {
defer rows.Close()
scores = map[int64]map[string]*float64{}
scores = map[int64]map[int64]*float64{}
for rows.Next() {
var id_user int64
var kind string
var id int64
var id_survey int64
var score *float64
if err = rows.Scan(&id_user, &kind, &id, &score); err != nil {
if err = rows.Scan(&id_user, &id_survey, &score); err != nil {
return
}
if scores[id_user] == nil {
scores[id_user] = map[string]*float64{}
scores[id_user] = map[int64]*float64{}
}
scores[id_user][fmt.Sprintf("%c.%d", kind[0], id)] = score
scores[id_user][id_survey] = score
}
if err = rows.Err(); err != nil {
return
@ -115,7 +97,7 @@ func GetAllGrades() (scores map[int64]map[string]*float64, err error) {
}
func (s Survey) GetGrades() (scores map[int64]*float64, err error) {
if rows, errr := DBQuery("SELECT id_question, SUM(score)/COUNT(*) FROM student_scores WHERE kind = 'survey' AND id=? GROUP BY id_question", s.Id); errr != nil {
if rows, errr := DBQuery("SELECT id_question, SUM(score)/COUNT(*) FROM student_scores WHERE id_survey=? GROUP BY id_question", s.Id); errr != nil {
return nil, errr
} else {
defer rows.Close()
@ -140,7 +122,7 @@ func (s Survey) GetGrades() (scores map[int64]*float64, err error) {
}
func (s Survey) GetUserGrades(u *User) (scores map[int64]*float64, err error) {
if rows, errr := DBQuery("SELECT id_question, MAX(score) FROM student_scores WHERE kind = 'survey' AND id=? AND id_user = ? GROUP BY id_question", s.Id, u.Id); errr != nil {
if rows, errr := DBQuery("SELECT id_question, MAX(score) FROM student_scores WHERE id_survey=? AND id_user = ? GROUP BY id_question", s.Id, u.Id); errr != nil {
return nil, errr
} else {
defer rows.Close()

38
keys.go
View file

@ -50,44 +50,6 @@ func declareAPIKeysRoutes(router *gin.RouterGroup) {
c.Data(http.StatusOK, "application/pgp-keys", ret)
})
usersRoutes.GET("/allowed_signers", func(c *gin.Context) {
var u *User
if user, ok := c.Get("user"); ok {
u = user.(*User)
} else {
u = c.MustGet("LoggedUser").(*User)
}
user, err := GitLab_getUser(c.Request.Context(), u)
if err != nil {
log.Println("Unable to GitLab_getUser:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve your GitLab user. Please try again in a few moment."})
return
}
keys, err := GitLab_getUserSSHKeys(c.Request.Context(), u)
if err != nil {
log.Println("Unable to GitLab_getUserSSHKeys:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve your keys from GitLab. Please try again in a few moment."})
return
}
var ret string
for _, k := range keys {
if k.UsageType != "auth_and_signing" && k.UsageType != "signing" {
continue
}
if len(user.Email) > 0 {
ret += fmt.Sprintf("%s %s\n", user.Email, k.Key)
} else {
ret += fmt.Sprintf("*@epita.fr %s\n", k.Key)
}
}
c.Data(http.StatusOK, "text/plain", []byte(ret))
})
}
func declareAPIAuthKeysRoutes(router *gin.RouterGroup) {

View file

@ -58,7 +58,6 @@ func main() {
var bind = flag.String("bind", ":8081", "Bind port/socket")
var dsn = flag.String("dsn", DSNGenerator(), "DSN to connect to the MySQL server")
var dummyauth = flag.Bool("dummy-auth", false, "If set, allow any authentication credentials")
flag.StringVar(&mainBanner, "banner-message", mainBanner, "Display a message to connected user, at the top of the screen")
flag.StringVar(&DevProxy, "dev", DevProxy, "Proxify traffic to this host for static assets")
flag.StringVar(&baseURL, "baseurl", baseURL, "URL prepended to each URL")
flag.UintVar(&currentPromo, "current-promo", currentPromo, "Year of the current promotion")

View file

@ -39,7 +39,7 @@ func declareAPIAuthQuestionsRoutes(router *gin.RouterGroup) {
c.JSON(http.StatusOK, questions)
}
} else {
if s.Direct != nil && !u.IsAdmin {
if (!s.Shown || s.Direct != nil) && !u.IsAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible"})
return
}
@ -60,10 +60,26 @@ func declareAPIAuthQuestionsRoutes(router *gin.RouterGroup) {
questionsRoutes := router.Group("/questions/:qid")
questionsRoutes.Use(questionHandler)
questionsRoutes.Use(questionUserAccessHandler)
questionsRoutes.GET("", func(c *gin.Context) {
c.JSON(http.StatusOK, c.MustGet("question").(*Question))
q := c.MustGet("question").(*Question)
u := c.MustGet("LoggedUser").(*User)
if !u.IsAdmin {
s, err := getSurvey(int(q.IdSurvey))
if err != nil {
log.Println("Unable to getSurvey:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during survey retrieval. Please try again later."})
return
}
if !s.Shown || (s.Direct != nil && *s.Direct != q.Id) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not authorized"})
return
}
}
c.JSON(http.StatusOK, q)
})
declareAPIAuthProposalsRoutes(questionsRoutes)
@ -98,7 +114,6 @@ func declareAPIAdminQuestionsRoutes(router *gin.RouterGroup) {
questionsRoutes := router.Group("/questions/:qid")
questionsRoutes.Use(questionHandler)
questionsRoutes.Use(questionUserAccessHandler)
questionsRoutes.PUT("", func(c *gin.Context) {
current := c.MustGet("question").(*Question)
@ -139,7 +154,6 @@ func declareAPIAdminQuestionsRoutes(router *gin.RouterGroup) {
func declareAPIAdminUserQuestionsRoutes(router *gin.RouterGroup) {
questionsRoutes := router.Group("/questions/:qid")
questionsRoutes.Use(questionHandler)
questionsRoutes.Use(questionUserAccessHandler)
questionsRoutes.GET("", func(c *gin.Context) {
question := c.MustGet("question").(*Question)
@ -189,38 +203,6 @@ func questionHandler(c *gin.Context) {
c.Next()
}
func questionUserAccessHandler(c *gin.Context) {
var survey *Survey
if s, ok := c.Get("survey"); ok {
survey = s.(*Survey)
}
u := c.MustGet("LoggedUser").(*User)
question := c.MustGet("question").(*Question)
if survey == nil {
s, err := getSurvey(int(question.IdSurvey))
if err != nil {
log.Println("Unable to getSurvey:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during survey retrieval. Please try again later."})
return
}
survey = s
}
if !u.IsAdmin && (!survey.checkUserAccessToSurvey(u) || (survey.Direct != nil && *survey.Direct != question.Id)) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not authorized"})
return
}
if !u.IsAdmin && survey.StartAvailability.After(time.Now()) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible yet"})
return
}
c.Next()
}
type Question struct {
Id int64 `json:"id"`
IdSurvey int64 `json:"id_survey"`

View file

@ -1,7 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>iac/renovate-config",
"local>iac/renovate-config//automerge-common"
"packageRules": [
{
"matchPackageNames": ["alpine", "golang.org/x/oauth2", "github.com/aws/aws-sdk-go"],
"automerge": true,
"automergeType": "branch"
}
]
}

View file

@ -7,30 +7,24 @@ import (
"fmt"
"log"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/drone/drone-go/drone"
"github.com/gin-gonic/gin"
"golang.org/x/oauth2"
)
var (
droneToken = ""
droneConfig *http.Client
droneEndpoint string
testsCallbackToken string
droneToken = ""
droneConfig *http.Client
droneEndpoint string
)
func init() {
flag.StringVar(&droneToken, "drone-token", droneToken, "Token for Drone Oauth")
flag.StringVar(&droneEndpoint, "drone-endpoint", droneEndpoint, "Drone Endpoint")
flag.StringVar(&testsCallbackToken, "tests-callback-token", testsCallbackToken, "Token of the callback token")
}
func initializeDroneOauth() {
@ -45,11 +39,6 @@ func initializeDroneOauth() {
}
}
type RepositoryAdminPull struct {
Tag *string `json:"tag"`
OptionalSignature bool `json:"sig_optional"`
}
func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
router.GET("/repositories", func(c *gin.Context) {
var u *User
@ -73,6 +62,7 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
if r.IdWork == work.(*Work).Id {
// Is the URL used elsewhere?
repos, _ := getRepositoriesByURI(r.URI)
log.Println(repos)
if len(repos) > 1 {
r.AlreadyUsed = true
}
@ -100,39 +90,12 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
return
}
uri, err := url.Parse(repository.URI)
if err != nil {
tmp := strings.Split(repository.URI, ":")
if len(tmp) == 2 {
uri, err = url.Parse(fmt.Sprintf("ssh://%s/%s", tmp[0], tmp[1]))
} else if len(tmp) == 3 {
uri, err = url.Parse(fmt.Sprintf("%s://%s/%s", tmp[0], tmp[1], tmp[2]))
}
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("invalid repository URL: %s", err.Error())})
return
}
}
if uri.Scheme != "ssh" && uri.Scheme != "git+ssh" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Unrecognized URL scheme. You need to provide a SSH repository URL."})
return
}
if strings.Contains(uri.Host, "epita.fr") {
if !strings.HasPrefix(uri.Path, fmt.Sprintf("/%s/", u.Login)) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "repository URL forbidden"})
return
}
}
var w *Work
if work, ok := c.Get("work"); ok {
w = work.(*Work)
} else if repository.IdWork > 0 {
var err error
w, err = getWork(repository.IdWork)
w, err = getWork(int(repository.IdWork))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Unable to find the given work identifier."})
return
@ -160,6 +123,7 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
// Is the URL used elsewhere?
repos, _ := getRepositoriesByURI(repo.URI)
log.Println(repos)
if len(repos) > 1 {
repo.AlreadyUsed = true
}
@ -192,9 +156,8 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
}
})
repositoriesRoutes.DELETE("", func(c *gin.Context) {
loggeduser := c.MustGet("LoggedUser").(*User)
repository := c.MustGet("repository").(*Repository)
work, err := getWork(repository.IdWork)
work, err := getWork(int(repository.IdWork))
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
return
@ -203,7 +166,7 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
now := time.Now()
if !loggeduser.IsAdmin && (!work.Shown || work.Corrected || work.StartAvailability.After(now) || work.EndAvailability.Before(now)) {
if !work.Shown || work.Corrected || work.StartAvailability.After(now) || work.EndAvailability.Before(now) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "The submission is closed."})
return
}
@ -226,10 +189,11 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
u = loggeduser
}
repo := c.MustGet("repository").(*Repository)
work, err := getWork(repo.IdWork)
work, err := getWork(int(repo.IdWork))
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
return
}
now := time.Now()
@ -239,12 +203,12 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
return
}
var rap RepositoryAdminPull
var tag *string
if loggeduser.IsAdmin {
c.ShouldBindJSON(&rap)
c.ShouldBindJSON(&tag)
}
TriggerTagUpdate(c, work, repo, u, rap.Tag, rap.OptionalSignature)
TriggerTagUpdate(c, work, repo, u, tag)
})
repositoriesRoutes.GET("/state", func(c *gin.Context) {
@ -301,65 +265,6 @@ func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) {
c.JSON(http.StatusOK, result)
}
})
repositoriesRoutes.POST("/gradation", func(c *gin.Context) {
loggeduser := c.MustGet("LoggedUser").(*User)
if !loggeduser.IsAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Permission denied."})
return
}
var u *User
if user, ok := c.Get("user"); ok {
u = user.(*User)
} else {
u = loggeduser
}
repo := c.MustGet("repository").(*Repository)
work, err := getWork(repo.IdWork)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
return
}
TriggerTests(c, work, repo, u)
})
repositoriesRoutes.GET("/gradation_status", func(c *gin.Context) {
loggeduser := c.MustGet("LoggedUser").(*User)
if !loggeduser.IsAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Permission denied."})
return
}
repo := c.MustGet("repository").(*Repository)
slug := strings.Split(repo.TestsRef, "/")
if len(slug) < 3 {
return
}
buildn, err := strconv.ParseInt(slug[2], 10, 32)
if err != nil {
return
}
client := drone.NewClient(droneEndpoint, droneConfig)
build, err := client.Build(slug[0], slug[1], int(buildn))
if err != nil {
log.Println("Unable to communicate with Drone:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to communicate with Drone"})
return
}
c.JSON(http.StatusOK, build)
})
repositoriesRoutes.GET("/traces", func(c *gin.Context) {
repo := c.MustGet("repository").(*Repository)
c.Redirect(http.StatusFound, fmt.Sprintf("%s/%s", droneEndpoint, repo.TestsRef))
})
}
type GitLabWebhook struct {
@ -404,7 +309,7 @@ func declareCallbacksRoutes(router *gin.RouterGroup) {
return
}
work, err := getWork(repo.IdWork)
work, err := getWork(int(repo.IdWork))
if err != nil {
log.Println("Unable to getWork:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to find related work."})
@ -422,7 +327,7 @@ func declareCallbacksRoutes(router *gin.RouterGroup) {
tmp := strings.SplitN(hook.Ref, "/", 3)
if len(tmp) != 3 {
TriggerTagUpdate(c, work, repo, user, nil, false)
TriggerTagUpdate(c, work, repo, user, nil)
return
}
@ -430,7 +335,7 @@ func declareCallbacksRoutes(router *gin.RouterGroup) {
// Allow to use a secret for another tag
if len(repos) > 1 {
for _, r := range repos {
w, err := getWork(r.IdWork)
w, err := getWork(int(r.IdWork))
if err != nil {
log.Println("Unable to getWork:", err.Error())
continue
@ -450,37 +355,7 @@ func declareCallbacksRoutes(router *gin.RouterGroup) {
}
}
TriggerTagUpdate(c, work, repo, user, &tmp[2], false)
})
router.POST("/callbacks/tests.json", func(c *gin.Context) {
// Check auth token
if c.GetHeader("X-Authorization") != testsCallbackToken {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Authorization token is invalid"})
return
}
// Get form data
hook := TestsWebhook{}
if err := c.ShouldBindJSON(&hook); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
// Retrieve corresponding repository
repo, err := getRepository(hook.RepositoryId)
if err != nil {
log.Printf("Unable to getRepository(%d): %s", hook.RepositoryId, err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve repository."})
return
}
err = hook.fetchRepoTests(repo)
if err != nil {
log.Printf("Unable to fetchRepoTests(%d): %s", hook.RepositoryId, err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to fetch tests results."})
return
}
TriggerTagUpdate(c, work, repo, user, &tmp[2])
})
}
@ -512,11 +387,11 @@ func repositoryHandler(c *gin.Context) {
}
}
func TriggerTagUpdate(c *gin.Context, work *Work, repo *Repository, u *User, tag *string, sig_optional bool) {
func TriggerTagUpdate(c *gin.Context, work *Work, repo *Repository, u *User, tag *string) {
loggeduser := c.MustGet("LoggedUser").(*User)
now := time.Now()
if (loggeduser == nil || !loggeduser.IsAdmin) && (!work.Shown || work.Corrected || work.StartAvailability.After(now) || work.EndAvailability.Add(time.Hour).Before(now)) {
if !loggeduser.IsAdmin && (!work.Shown || work.Corrected || work.StartAvailability.After(now) || work.EndAvailability.Add(time.Hour).Before(now)) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "The submission is closed."})
return
}
@ -536,27 +411,21 @@ func TriggerTagUpdate(c *gin.Context, work *Work, repo *Repository, u *User, tag
}
}
env := map[string]string{
client := drone.NewClient(droneEndpoint, droneConfig)
result, err := client.BuildCreate("srs", "atsebay.t-worker", "", "master", map[string]string{
"REPO_URL": repo.URI,
"REPO_TAG": repo_tag,
"LOGIN": login,
"GROUPS": groups,
"DEST": fmt.Sprintf("%d", work.Id),
}
if sig_optional {
env["TAG_SIG_OPTIONAL"] = "1"
}
client := drone.NewClient(droneEndpoint, droneConfig)
result, err := client.BuildCreate("teach", "atsebay.t-worker", "", "master", env)
})
if err != nil {
log.Println("Unable to communicate with Drone:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to communication with the extraction service."})
return
}
repo.DroneRef = fmt.Sprintf("%s/%s/%d", "teach", "atsebay.t-worker", result.Number)
repo.DroneRef = fmt.Sprintf("%s/%s/%d", "srs", "atsebay.t-worker", result.Number)
repo.LastCheck = &now
repo.Update()
@ -564,116 +433,6 @@ func TriggerTagUpdate(c *gin.Context, work *Work, repo *Repository, u *User, tag
c.JSON(http.StatusOK, repo)
}
func TriggerTests(c *gin.Context, work *Work, repo *Repository, u *User) {
if work.GradationRepo == nil || len(*work.GradationRepo) == 0 {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "No tests defined for this work."})
return
}
slug := strings.SplitN(*work.GradationRepo, "/", 2)
if len(slug) != 2 {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Graduation repository is invalid."})
return
}
login := u.Login
groups := u.Groups
if u.Id != repo.IdUser {
user, _ := getUser(int(repo.IdUser))
if user != nil {
login = user.Login
groups = user.Groups
}
}
branch := "master"
if len(work.Tag) > 0 {
branch = work.Tag
}
if branch[len(branch)-1] == '-' {
branch += "grades"
}
s, err := s3NewSession()
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Something goes wrong."})
return
}
req, _ := s3.New(s).GetObjectRequest(&s3.GetObjectInput{
Bucket: aws.String(s3_bucket),
Key: aws.String(filepath.Join(fmt.Sprintf("%d", work.Id), fmt.Sprintf("rendu-%s.tar.xz", login))),
})
url, err := req.Presign(SharingTime * 20)
if err != nil {
log.Println("Unable to create presign URL:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Something goes wrong when creating the presigned URL."})
return
}
env := map[string]string{
"SUBMISSION_URL": repo.URI,
"REPO_ID": fmt.Sprintf("%d", repo.Id),
"META_URL": url,
"LOGIN": login,
"GROUPS": groups,
"DEST": fmt.Sprintf("%d", work.Id),
}
client := drone.NewClient(droneEndpoint, droneConfig)
result, err := client.BuildCreate(slug[0], slug[1], "", branch, env)
if err != nil {
log.Println("Unable to communicate with Drone:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to communication with the gradation service."})
return
}
now := time.Now()
repo.TestsRef = fmt.Sprintf("%s/%d", *work.GradationRepo, result.Number)
repo.LastTests = &now
repo.Update()
repo.Secret = []byte{}
c.JSON(http.StatusOK, repo)
}
func (w *Work) stopTests() error {
repos, err := w.GetRepositories()
if err != nil {
return err
}
client := drone.NewClient(droneEndpoint, droneConfig)
for _, repo := range repos {
slug := strings.Split(repo.TestsRef, "/")
if len(slug) < 3 {
continue
}
buildn, err := strconv.ParseInt(slug[2], 10, 32)
if err != nil {
continue
}
build, err := client.Build(slug[0], slug[1], int(buildn))
if err != nil {
log.Println("Unable to communicate with Drone:", err.Error())
continue
}
if build.Status == "pending" {
err := client.BuildCancel(slug[0], slug[1], int(buildn))
if err != nil {
log.Println("Unable to cancel the build:", err.Error())
continue
}
}
}
return nil
}
type Repository struct {
Id int64 `json:"id"`
IdUser int64 `json:"id_user"`
@ -682,41 +441,18 @@ type Repository struct {
Secret []byte `json:"secret,omitempty"`
LastCheck *time.Time `json:"last_check"`
DroneRef string `json:"drone_ref,omitempty"`
LastTests *time.Time `json:"last_tests"`
TestsRef string `json:"tests_ref,omitempty"`
AlreadyUsed bool `json:"already_used,omitempty"`
}
func (u *User) GetRepositories() (repositories []*Repository, err error) {
if rows, errr := DBQuery("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE id_user=?", u.Id); errr != nil {
if rows, errr := DBQuery("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref FROM user_work_repositories WHERE id_user=?", u.Id); errr != nil {
return nil, errr
} else {
defer rows.Close()
for rows.Next() {
var repo Repository
if err = rows.Scan(&repo.Id, &repo.IdUser, &repo.IdWork, &repo.URI, &repo.Secret, &repo.LastCheck, &repo.DroneRef, &repo.LastTests, &repo.TestsRef); err != nil {
return
}
repositories = append(repositories, &repo)
}
if err = rows.Err(); err != nil {
return
}
return
}
}
func (w *Work) GetRepositories() (repositories []*Repository, err error) {
if rows, errr := DBQuery("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE id_work=?", w.Id); errr != nil {
return nil, errr
} else {
defer rows.Close()
for rows.Next() {
var repo Repository
if err = rows.Scan(&repo.Id, &repo.IdUser, &repo.IdWork, &repo.URI, &repo.Secret, &repo.LastCheck, &repo.DroneRef, &repo.LastTests, &repo.TestsRef); err != nil {
if err = rows.Scan(&repo.Id, &repo.IdUser, &repo.IdWork, &repo.URI, &repo.Secret, &repo.LastCheck, &repo.DroneRef); err != nil {
return
}
repositories = append(repositories, &repo)
@ -730,14 +466,14 @@ func (w *Work) GetRepositories() (repositories []*Repository, err error) {
}
func getRepositoriesByURI(uri string) (repositories []*Repository, err error) {
if rows, errr := DBQuery("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE uri=?", uri); errr != nil {
if rows, errr := DBQuery("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref FROM user_work_repositories WHERE uri=?", uri); errr != nil {
return nil, errr
} else {
defer rows.Close()
for rows.Next() {
var repo Repository
if err = rows.Scan(&repo.Id, &repo.IdUser, &repo.IdWork, &repo.URI, &repo.Secret, &repo.LastCheck, &repo.DroneRef, &repo.LastTests, &repo.TestsRef); err != nil {
if err = rows.Scan(&repo.Id, &repo.IdUser, &repo.IdWork, &repo.URI, &repo.Secret, &repo.LastCheck, &repo.DroneRef); err != nil {
return
}
repositories = append(repositories, &repo)
@ -752,19 +488,13 @@ func getRepositoriesByURI(uri string) (repositories []*Repository, err error) {
func getRepository(id int) (r *Repository, err error) {
r = new(Repository)
err = DBQueryRow("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE id_repository=?", id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.Secret, &r.LastCheck, &r.DroneRef, &r.LastTests, &r.TestsRef)
err = DBQueryRow("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref FROM user_work_repositories WHERE id_repository=?", id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.Secret, &r.LastCheck, &r.DroneRef)
return
}
func (u *User) getRepository(id int) (r *Repository, err error) {
r = new(Repository)
err = DBQueryRow("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE id_repository=? AND id_user=?", id, u.Id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.Secret, &r.LastCheck, &r.DroneRef, &r.LastTests, &r.TestsRef)
return
}
func (u *User) getRepositoryByWork(id int64) (r *Repository, err error) {
r = new(Repository)
err = DBQueryRow("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref, last_tests, testsref FROM user_work_repositories WHERE id_work=? AND id_user=? ORDER BY last_tests DESC LIMIT 1", id, u.Id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.Secret, &r.LastCheck, &r.DroneRef, &r.LastTests, &r.TestsRef)
err = DBQueryRow("SELECT id_repository, id_user, id_work, uri, secret, last_check, droneref FROM user_work_repositories WHERE id_repository=? AND id_user=?", id, u.Id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.Secret, &r.LastCheck, &r.DroneRef)
return
}
@ -775,17 +505,17 @@ func (u *User) NewRepository(w *Work, uri string) (*Repository, error) {
return nil, err
}
if res, err := DBExec("INSERT INTO user_work_repositories (id_user, id_work, uri, secret, droneref, testsref) VALUES (?, ?, ?, ?, ?, '')", u.Id, w.Id, uri, secret, ""); err != nil {
if res, err := DBExec("INSERT INTO user_work_repositories (id_user, id_work, uri, secret, droneref) VALUES (?, ?, ?, ?, ?)", u.Id, w.Id, uri, secret, ""); err != nil {
return nil, err
} else if rid, err := res.LastInsertId(); err != nil {
return nil, err
} else {
return &Repository{rid, u.Id, w.Id, uri, secret, nil, "", nil, "", false}, nil
return &Repository{rid, u.Id, w.Id, uri, secret, nil, "", false}, nil
}
}
func (r *Repository) Update() (*Repository, error) {
if _, err := DBExec("UPDATE user_work_repositories SET id_user = ?, id_work = ?, uri = ?, secret = ?, last_check = ?, droneref = ?, last_tests = ?, testsref = ? WHERE id_repository = ?", r.IdUser, r.IdWork, r.URI, r.Secret, r.LastCheck, r.DroneRef, r.LastTests, r.TestsRef, r.Id); err != nil {
if _, err := DBExec("UPDATE user_work_repositories SET id_user = ?, id_work = ?, uri = ?, secret = ?, last_check = ?, droneref = ? WHERE id_repository = ?", r.IdUser, r.IdWork, r.URI, r.Secret, r.LastCheck, r.DroneRef, r.Id); err != nil {
return nil, err
} else {
return r, err

187
shares.go
View file

@ -1,187 +0,0 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"fmt"
"log"
"net/http"
"net/url"
"path/filepath"
"github.com/gin-gonic/gin"
)
func declareAPISharesRoutes(router *gin.RouterGroup) {
surveysRoutes := router.Group("/s/surveys/:sid")
surveysRoutes.Use(surveyHandler)
surveysRoutes.Use(sharesAccessHandler)
surveysRoutes.GET("/", func(c *gin.Context) {
share := c.MustGet("survey_share").(*SurveyShared)
share.Count += 1
share.Update()
c.JSON(http.StatusOK, c.MustGet("survey").(*Survey))
})
surveysRoutes.GET("/questions", func(c *gin.Context) {
s := c.MustGet("survey").(*Survey)
if questions, err := s.GetQuestions(); err != nil {
log.Println("Unable to getQuestions:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve questions. Please try again later."})
return
} else {
c.JSON(http.StatusOK, questions)
}
})
questionsRoutes := surveysRoutes.Group("/questions/:qid")
questionsRoutes.Use(questionHandler)
questionsRoutes.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, c.MustGet("question").(*Question))
})
questionsRoutes.GET("/proposals", func(c *gin.Context) {
q := c.MustGet("question").(*Question)
proposals, err := q.GetProposals()
if err != nil {
log.Printf("Unable to GetProposals(qid=%d): %s", q.Id, err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during proposals retrieving"})
return
}
c.JSON(http.StatusOK, proposals)
})
questionsRoutes.GET("/responses", func(c *gin.Context) {
q := c.MustGet("question").(*Question)
res, err := q.GetResponses()
if err != nil {
log.Printf("Unable to GetResponses(qid=%d): %s", q.Id, err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during responses retrieval."})
return
}
c.JSON(http.StatusOK, res)
})
}
func sharesAccessHandler(c *gin.Context) {
s := c.MustGet("survey").(*Survey)
secret := c.Query("secret")
shares, err := s.getShares()
if err != nil {
log.Printf("Unable to getShares(sid=%d): %s", s.Id, err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Something went wrong when authenticating the query."})
return
}
for _, share := range shares {
if share.Authenticate(secret) {
c.Set("survey_share", share)
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not authorized"})
}
type SurveyShared struct {
Id int64 `json:"id"`
IdSurvey int64 `json:"id_survey"`
Count int64 `json:"count"`
secret []byte
}
func (s *Survey) Share() (*SurveyShared, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, err
}
if res, err := DBExec("INSERT INTO survey_shared (id_survey, secret) VALUES (?, ?)", s.Id, secret); err != nil {
return nil, err
} else if sid, err := res.LastInsertId(); err != nil {
return nil, err
} else {
return &SurveyShared{sid, s.Id, 0, secret}, nil
}
}
func (sh *SurveyShared) getMAC() []byte {
mac := hmac.New(sha512.New, sh.secret)
mac.Write([]byte(fmt.Sprintf("%d", sh.IdSurvey)))
return mac.Sum(nil)
}
func (sh *SurveyShared) GetURL() (*url.URL, error) {
u, err := url.Parse(oidcRedirectURL)
if err != nil {
return nil, err
}
u.Path = filepath.Join(baseURL, "results")
u.RawQuery = url.Values{
"secret": []string{base64.RawURLEncoding.EncodeToString(sh.getMAC())},
"survey": []string{fmt.Sprintf("%d", sh.IdSurvey)},
}.Encode()
return u, nil
}
func (s *Survey) getShares() (shares []*SurveyShared, err error) {
if rows, errr := DBQuery("SELECT id_share, id_survey, secret, count FROM survey_shared"); errr != nil {
return nil, errr
} else {
defer rows.Close()
for rows.Next() {
var sh SurveyShared
if err = rows.Scan(&sh.Id, &sh.IdSurvey, &sh.secret, &sh.Count); err != nil {
return
}
shares = append(shares, &sh)
}
if err = rows.Err(); err != nil {
return
}
return
}
}
func (sh *SurveyShared) Authenticate(secret string) bool {
messageMAC, err := base64.RawURLEncoding.DecodeString(secret)
if err != nil {
return false
}
return hmac.Equal(messageMAC, sh.getMAC())
}
func (sh *SurveyShared) Update() (*SurveyShared, error) {
if _, err := DBExec("UPDATE survey_shared SET id_survey = ?, secret = ?, count = ? WHERE id_share = ?", sh.IdSurvey, sh.secret, sh.Count, sh.Id); err != nil {
return nil, err
} else {
return sh, err
}
}
func (sh SurveyShared) Delete() (int64, error) {
if res, err := DBExec("DELETE FROM survey_shared WHERE id_share = ?", sh.Id); err != nil {
return 0, err
} else if nb, err := res.RowsAffected(); err != nil {
return 0, err
} else {
return nb, err
}
}

View file

@ -48,18 +48,15 @@ func serveOrReverse(forced_url string) func(c *gin.Context) {
}
func declareStaticRoutes(router *gin.Engine) {
router.GET("/@fs/*_", serveOrReverse(""))
router.GET("/", serveOrReverse(""))
router.GET("/_app/*_", serveOrReverse(""))
router.GET("/auth/", serveOrReverse("/"))
router.GET("/bug-bounty", serveOrReverse("/"))
router.GET("/categories", serveOrReverse("/"))
router.GET("/categories/*_", serveOrReverse("/"))
router.GET("/donnees-personnelles", serveOrReverse("/"))
router.GET("/grades", serveOrReverse("/"))
router.GET("/grades/*_", serveOrReverse("/"))
router.GET("/help", serveOrReverse("/"))
router.GET("/keys", serveOrReverse("/"))
router.GET("/results", serveOrReverse("/"))
router.GET("/surveys", serveOrReverse("/"))
router.GET("/surveys/*_", serveOrReverse("/"))
router.GET("/users", serveOrReverse("/"))
@ -74,7 +71,7 @@ func declareStaticRoutes(router *gin.Engine) {
router.GET("/.svelte-kit/*_", serveOrReverse(""))
router.GET("/node_modules/*_", serveOrReverse(""))
router.GET("/@vite/*_", serveOrReverse(""))
router.GET("/@fs/*_", serveOrReverse(""))
router.GET("/__vite_ping", serveOrReverse(""))
router.GET("/src/*_", serveOrReverse(""))
}
}

View file

@ -17,7 +17,7 @@ import (
"github.com/gin-gonic/gin"
)
const SharingTime = 10 * time.Minute
const SharingTime = 15 * time.Minute
var (
s3_endpoint string

View file

@ -3,7 +3,6 @@ package main
import (
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
@ -63,14 +62,19 @@ func declareAPISurveysRoutes(router *gin.RouterGroup) {
return
}
c.JSON(http.StatusOK, c.MustGet("survey").(*Survey))
s := c.MustGet("survey").(*Survey)
if (s.Promo == u.Promo && (s.Group == "" || strings.Contains(u.Groups, ","+s.Group+",") && s.Shown)) || u.IsAdmin {
c.JSON(http.StatusOK, s)
} else {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible"})
}
})
}
func declareAPIAuthSurveysRoutes(router *gin.RouterGroup) {
surveysRoutes := router.Group("/surveys/:sid")
surveysRoutes.Use(surveyHandler)
surveysRoutes.Use(surveyUserAccessHandler)
surveysRoutes.GET("/score", func(c *gin.Context) {
var u *User
@ -81,34 +85,7 @@ func declareAPIAuthSurveysRoutes(router *gin.RouterGroup) {
}
s := c.MustGet("survey").(*Survey)
if u.IsAdmin {
questions, err := s.GetQuestions()
if err != nil {
log.Println("Unable to getQuestions:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve questions. Please try again later."})
return
}
itemCount := 0
itemCorrected := 0
for _, q := range questions {
res, err := q.GetResponses()
if err != nil {
log.Printf("Unable to GetResponses(qid=%d): %s", q.Id, err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during responses retrieval."})
return
}
for _, r := range res {
itemCount += 1
if r.TimeScored != nil && (r.TimeReported == nil || r.TimeScored.After(*r.TimeReported)) {
itemCorrected += 1
}
}
}
c.JSON(http.StatusOK, map[string]int{"count": itemCount, "corrected": itemCorrected})
} else if s.Promo == u.Promo && s.Shown {
if (s.Promo == u.Promo && s.Shown) || (u != nil && u.IsAdmin) {
score, err := s.GetScore(u)
if err != nil {
log.Printf("Unable to GetScore(uid=%d;sid=%d): %s", u.Id, s.Id, err.Error())
@ -119,7 +96,7 @@ func declareAPIAuthSurveysRoutes(router *gin.RouterGroup) {
if score == nil {
c.JSON(http.StatusOK, map[string]string{"score": "N/A"})
} else {
c.JSON(http.StatusOK, map[string]float64{"score": math.Round(*score*10) / 10})
c.JSON(http.StatusOK, map[string]float64{"score": *score})
}
} else {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Not accessible"})
@ -146,7 +123,7 @@ func declareAPIAdminSurveysRoutes(router *gin.RouterGroup) {
new.Promo = currentPromo
}
if s, err := NewSurvey(new.IdCategory, new.Title, new.Promo, new.Group, new.Shown, new.Direct, new.StartAvailability, new.EndAvailability); err != nil {
if s, err := NewSurvey(new.Title, new.Promo, new.Group, new.Shown, new.Direct, new.StartAvailability, new.EndAvailability); err != nil {
log.Println("Unable to NewSurvey:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey creation: %s", err.Error())})
return
@ -204,33 +181,6 @@ func declareAPIAdminSurveysRoutes(router *gin.RouterGroup) {
}
})
surveysRoutes.GET("shares", func(c *gin.Context) {
survey := c.MustGet("survey").(*Survey)
if sh, err := survey.getShares(); err != nil {
log.Println("Unable to getShares survey:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey shares listing: %s", err.Error())})
return
} else {
c.JSON(http.StatusOK, sh)
}
})
surveysRoutes.POST("shares", func(c *gin.Context) {
survey := c.MustGet("survey").(*Survey)
if sh, err := survey.Share(); err != nil {
log.Println("Unable to Share survey:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey sharing: %s", err.Error())})
return
} else if url, err := sh.GetURL(); err != nil {
log.Println("Unable to GetURL share:", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey sharing: %s", err.Error())})
return
} else {
c.JSON(http.StatusOK, url.String())
}
})
declareAPIAdminAsksRoutes(surveysRoutes)
declareAPIAdminDirectRoutes(surveysRoutes)
declareAPIAdminQuestionsRoutes(surveysRoutes)
@ -249,25 +199,22 @@ func surveyHandler(c *gin.Context) {
}
}
func (s *Survey) checkUserAccessToSurvey(u *User) bool {
return u.IsAdmin || (u.Promo == s.Promo && s.Shown && (s.Group == "" || strings.Contains(u.Groups, ","+s.Group+",")))
}
func surveyUserAccessHandler(c *gin.Context) {
u := c.MustGet("LoggedUser").(*User)
s := c.MustGet("survey").(*Survey)
w := c.MustGet("survey").(*Survey)
if !s.checkUserAccessToSurvey(u) {
if u.IsAdmin {
c.Next()
} else if w.Shown && (w.Group == "" || strings.Contains(u.Groups, ","+w.Group+",")) {
c.Next()
} else {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Survey not found."})
return
}
c.Next()
}
type Survey struct {
Id int64 `json:"id"`
IdCategory int64 `json:"id_category"`
Title string `json:"title"`
Promo uint `json:"promo"`
Group string `json:"group"`
@ -279,14 +226,14 @@ type Survey struct {
}
func getSurveys(cnd string, param ...interface{}) (surveys []*Survey, err error) {
if rows, errr := DBQuery("SELECT id_survey, id_category, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys "+cnd, param...); errr != nil {
if rows, errr := DBQuery("SELECT id_survey, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys "+cnd, param...); errr != nil {
return nil, errr
} else {
defer rows.Close()
for rows.Next() {
var s Survey
if err = rows.Scan(&s.Id, &s.IdCategory, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability); err != nil {
if err = rows.Scan(&s.Id, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability); err != nil {
return
}
surveys = append(surveys, &s)
@ -308,7 +255,7 @@ func getSurvey(id int) (s *Survey, err error) {
}
s = new(Survey)
err = DBQueryRow("SELECT id_survey, id_category, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys WHERE id_survey=?", id).Scan(&s.Id, &s.IdCategory, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability)
err = DBQueryRow("SELECT id_survey, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys WHERE id_survey=?", id).Scan(&s.Id, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability)
_surveys_cache_mutex.Lock()
_surveys_cache[int64(id)] = s
@ -316,13 +263,13 @@ func getSurvey(id int) (s *Survey, err error) {
return
}
func NewSurvey(id_category int64, title string, promo uint, group string, shown bool, direct *int64, startAvailability time.Time, endAvailability time.Time) (*Survey, error) {
if res, err := DBExec("INSERT INTO surveys (id_category, title, promo, grp, shown, direct, start_availability, end_availability) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", id_category, title, promo, group, shown, direct, startAvailability, endAvailability); err != nil {
func NewSurvey(title string, promo uint, group string, shown bool, direct *int64, startAvailability time.Time, endAvailability time.Time) (*Survey, error) {
if res, err := DBExec("INSERT INTO surveys (title, promo, grp, shown, direct, start_availability, end_availability) VALUES (?, ?, ?, ?, ?, ?, ?)", title, promo, group, shown, direct, startAvailability, endAvailability); err != nil {
return nil, err
} else if sid, err := res.LastInsertId(); err != nil {
return nil, err
} else {
return &Survey{sid, id_category, title, promo, group, shown, direct, false, startAvailability, endAvailability}, nil
return &Survey{sid, title, promo, group, shown, direct, false, startAvailability, endAvailability}, nil
}
}
@ -342,7 +289,7 @@ func (s Survey) GetScore(u *User) (score *float64, err error) {
if ok {
score = v
} else {
err = DBQueryRow("SELECT SUM(score)/COUNT(*) FROM student_scores WHERE kind = 'survey' AND id=? AND id_user=?", s.Id, u.Id).Scan(&score)
err = DBQueryRow("SELECT SUM(score)/COUNT(*) FROM student_scores WHERE id_survey=? AND id_user=?", s.Id, u.Id).Scan(&score)
if score != nil {
*score = *score / 5.0
}
@ -355,7 +302,7 @@ func (s Survey) GetScore(u *User) (score *float64, err error) {
}
func (s Survey) GetScores() (scores map[int64]*float64, err error) {
if rows, errr := DBQuery("SELECT id_user, SUM(score)/COUNT(*) FROM student_scores WHERE kind = 'survey' AND id_survey=? GROUP BY id_user", s.Id); errr != nil {
if rows, errr := DBQuery("SELECT id_user, SUM(score)/COUNT(*) FROM student_scores WHERE id_survey=? GROUP BY id_user", s.Id); errr != nil {
return nil, errr
} else {
defer rows.Close()
@ -380,7 +327,7 @@ func (s Survey) GetScores() (scores map[int64]*float64, err error) {
}
func (s *Survey) Update() (*Survey, error) {
if _, err := DBExec("UPDATE surveys SET id_category = ?, title = ?, promo = ?, grp = ?, shown = ?, direct = ?, corrected = ?, start_availability = ?, end_availability = ? WHERE id_survey = ?", s.IdCategory, s.Title, s.Promo, s.Group, s.Shown, s.Direct, s.Corrected, s.StartAvailability, s.EndAvailability, s.Id); err != nil {
if _, err := DBExec("UPDATE surveys SET title = ?, promo = ?, grp = ?, shown = ?, direct = ?, corrected = ?, start_availability = ?, end_availability = ? WHERE id_survey = ?", s.Title, s.Promo, s.Group, s.Shown, s.Direct, s.Corrected, s.StartAvailability, s.EndAvailability, s.Id); err != nil {
return nil, err
} else {
_surveys_cache_mutex.Lock()
@ -391,7 +338,6 @@ func (s *Survey) Update() (*Survey, error) {
}
func (s Survey) Delete() (int64, error) {
DBExec("DELETE FROM survey_shared WHERE id_survey = ?", s.Id)
if res, err := DBExec("DELETE FROM surveys WHERE id_survey = ?", s.Id); err != nil {
return 0, err
} else if nb, err := res.RowsAffected(); err != nil {

4300
ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,26 +11,25 @@
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@sveltejs/adapter-static": "^1.0.0-next.29",
"@sveltejs/kit": "^1.0.0-next.324",
"@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.14.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-svelte": "^2.35.0",
"prettier": "^3.0.0",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^4.0.0",
"svelte-check": "^3.4.3",
"svelte-preprocess": "^5.0.3",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.6.2",
"prettier-plugin-svelte": "^2.7.0",
"svelte": "^3.48.0",
"svelte-check": "^2.7.0",
"svelte-preprocess": "^4.10.6",
"tslib": "^2.4.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
"typescript": "^4.6.4"
},
"type": "module",
"dependencies": {
"dayjs": "^1.11.5",
"svelte-frappe-charts": "^1.9.1"
"svelte-frappe-charts": "^1.9.1",
"vite": "^3.0.4"
}
}

View file

@ -20,7 +20,7 @@
{:else if state.status == "success"}
<i class="bi bi-check-circle-fill text-success mx-1" title="La récupération s'est bien passée"></i>
{:else if state.status == "failure" || state.status == "killed"}
<i class="bi bi-exclamation-circle-fill text-danger mx-1" title="La récupération ne s'est pas bien passée" style="cursor: pointer" on:click={() => dispatch('show_logs')} on:keypress={() => dispatch('show_logs')}></i>
<i class="bi bi-exclamation-circle-fill text-danger mx-1" title="La récupération ne s'est pas bien passée" style="cursor: pointer" on:click={() => dispatch('show_logs')}></i>
{:else}
{state.status}
{/if}

View file

@ -7,7 +7,7 @@
export let question = null;
function refreshProposals() {
let req = question.getProposals(secret);
let req = question.getProposals();
req.then((proposals) => {
const proposal_idx = { };
@ -17,7 +17,7 @@
proposal_idx[proposal.id] = new String(data.labels.length - 1);
}
req_responses = question.getResponses(secret);
req_responses = question.getResponses();
req_responses.then((responses) => {
for (const res of responses) {
const rsplt = res.value.split(',');
@ -32,7 +32,6 @@
}
let req_proposals = null;
export let proposals = null;
export let secret = null;
let req_responses = null;
let mean = null;
@ -47,7 +46,7 @@
if (!proposals) {
if (question.kind && (question.kind == "int" || question.kind.startsWith("list"))) {
req_responses = question.getResponses(secret);
req_responses = question.getResponses();
req_responses.then((responses) => {
const values = [];
const proposal_idx = { };

View file

@ -1,5 +1,5 @@
<script>
import { CorrectionTemplate } from '$lib/correctionTemplates';
import { CorrectionTemplate } from '../lib/correctionTemplates';
let className = '';
export { className as class };

View file

@ -1,6 +1,6 @@
<script>
import { user } from '$lib/stores/user';
import { autoCorrection } from '$lib/correctionTemplates';
import { user } from '../stores/user';
import { autoCorrection } from '../lib/correctionTemplates';
export let cts = null;
export let rid = 0;
@ -38,37 +38,14 @@
for (const t of templates) {
if (my_tpls[t.id] === undefined && cts[t.id.toString()]) {
my_tpls[t.id] = cts[t.id.toString()][response.id_user] !== undefined;
// Hack to autocorrect only if this has already been checked previously
if (autoCorrectionInProgress && cts[t.id.toString()][response.id_user] !== undefined) {
autoCorrectionInProgress = false;
}
}
}
}
}
let element = null;
let scrollY = 0;
let autoCorrectionInProgress = true;
$: {
if (element && scrollY > element.offsetParent.offsetTop - 500 && !my_correction) {
let tmp = false;
[tmp, autoCorrectionInProgress] = [autoCorrectionInProgress, true];
if (!tmp) {
autoCorrection(response.id_user, my_tpls).then((r) => {
my_correction = r;
autoCorrectionInProgress = false;
})
}
}
}
</script>
<svelte:window bind:scrollY={scrollY}/>
<form
class="row"
bind:this={element}
on:submit|preventDefault={submitCorrection}
>
<div class="col-auto">
@ -92,7 +69,6 @@
>
<label
class="form-check-label"
class:fw-bold={template.regexp && (template.regexp[0] == "!" ? !response.value.match(new RegExp(template.regexp.substring(1))) : response.value.match(new RegExp(template.regexp)))}
for="r{response.id}t{template.id}"
>
{template.label}

View file

@ -4,8 +4,8 @@
import QuestionProposals from './QuestionProposals.svelte';
import ResponseCorrected from './ResponseCorrected.svelte';
import CorrectionResponseFooter from './CorrectionResponseFooter.svelte';
import { autoCorrection } from '$lib/correctionTemplates';
import { getUser } from '$lib/users';
import { autoCorrection } from '../lib/correctionTemplates';
import { getUser } from '../lib/users';
export let cts = null;
export let filter = "";
@ -35,46 +35,20 @@
filteredResponses = responses.filter((r) => (notCorrected || r.time_scored <= r.time_reported || !r.time_scored) && (!filter || ((filter[0] == '!' && !r.value.match(filter.substring(1))) || r.value.match(filter))));
}
function escapeTags(htmlStr) {
return htmlStr.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function hilightText(input, templates) {
for (const { regexp } of templates) {
if (regexp) {
input = input.replace(new RegExp(regexp, 'g'), '<ins class="fw-bold">$&</ins>')
}
}
return input;
}
export async function applyCorrections() {
for (const r of filteredResponses) {
const my_correction = { };
let has_no_lost_answer = false;
let completed_correction = false;
for (const tpl of templates) {
if (tpl.score >= 0) has_no_lost_answer = true;
if (!tpl.regexp && tpl.label) continue;
if (tpl.regexp && (tpl.regexp[0] == '!' && !r.value.match(tpl.regexp.substring(1))) || r.value.match(tpl.regexp)) {
my_correction[tpl.id] = true;
completed_correction = true;
} else {
my_correction[tpl.id] = false;
}
}
// If no valid correction template AND valid answer is defined,
// don't consider the absence of match as valid answer.
if (!completed_correction && has_no_lost_answer) continue;
const auto = await autoCorrection(r.id_user, my_correction);
r.score = auto.score;
r.score_explaination = auto.score_explaination;
@ -114,7 +88,7 @@
class="card-text"
style="white-space: pre-line"
>
{@html hilightText(escapeTags(response.value), templates)}
{response.value}
</p>
{/if}
<ResponseCorrected

View file

@ -47,7 +47,7 @@
</span>
<div>
{#each res[rep] as user}
<a href="users/{user}" target="_blank" rel="noreferrer" class="badge bg-dark rounded-pill">
<a href="users/{user}" target="_blank" class="badge bg-dark rounded-pill">
{#if users && users[user]}
{users[user].login}
{:else}
@ -64,7 +64,7 @@
<span>
{rep}
</span>
<a href="users/{res[rep]}" target="_blank" rel="noreferrer" class="badge bg-dark rounded-pill">
<a href="users/{res[rep]}" target="_blank" class="badge bg-dark rounded-pill">
{#if users && users[res[rep]]}
{users[res[rep]].login}
{:else}

View file

@ -5,7 +5,7 @@
import QuestionHeader from './QuestionHeader.svelte';
import QuestionProposals from './QuestionProposals.svelte';
import ResponseCorrected from './ResponseCorrected.svelte';
import { user } from '$lib/stores/user';
import { user } from '../stores/user';
const dispatch = createEventDispatcher();

View file

@ -1,7 +1,7 @@
<script>
import { createEventDispatcher } from 'svelte';
import { user } from '$lib/stores/user';
import { user } from '../stores/user';
const dispatch = createEventDispatcher();

View file

@ -1,7 +1,7 @@
<script>
import { createEventDispatcher } from 'svelte';
import { QuestionProposal } from '$lib/questions';
import { QuestionProposal } from '../lib/questions';
export let edit = false;
export let proposals = [];

View file

@ -1,6 +1,6 @@
<script>
import { user } from '$lib/stores/user';
import { ToastsStore } from '$lib/stores/toasts';
import { user } from '../stores/user';
import { ToastsStore } from '../stores/toasts';
export let response = null;
export let survey = null;

View file

@ -1,10 +1,8 @@
<script>
import { getCategories } from '$lib/categories';
import { getSurveys } from '$lib/surveys';
import { getUsers, getGrades, getPromos } from '$lib/users';
import { getSurveys } from '../lib/surveys';
import { getUsers, getGrades, getPromos } from '../lib/users';
export let promo = null;
export let category = null;
</script>
{#await getPromos() then promos}
@ -17,24 +15,12 @@
</select>
</div>
{/await}
{#await getCategories() then categories}
<div class="float-end me-2">
<select class="form-select" bind:value={category}>
<option value={null}>toutes</option>
{#each categories as categ (categ.id)}
{#if !promo || categ.promo == promo}
<option value={categ.id}>{categ.label}</option>
{/if}
{/each}
</select>
</div>
{/await}
<h2>
Étudiants {#if promo !== null}{promo}{/if}
<small class="text-muted">Notes</small>
</h2>
{#await getSurveys(true)}
{#await getSurveys()}
<div class="d-flex justify-content-center">
<div class="spinner-border me-2" role="status"></div>
Chargement des questionnaires corrigés&hellip;
@ -52,15 +38,9 @@
<tr>
<th>ID</th>
<th>Login</th>
{#each surveys as survey}
{#if survey.corrected && (!promo || survey.promo == promo) && (!category || survey.id_category == category)}
<th>
{#if survey.kind == "survey"}
<a href="surveys/{survey.id}" style="text-decoration: none">{survey.title}</a>
{:else}
<a href="works/{survey.id}" style="text-decoration: none">{survey.title}</a>
{/if}
</th>
{#each surveys as survey (survey.id)}
{#if survey.corrected && (promo === null || survey.promo == promo)}
<th><a href="surveys/{survey.id}" style="text-decoration: none">{survey.title}</a></th>
{/if}
{/each}
</tr>
@ -77,15 +57,13 @@
</tr>
{:then users}
{#each users as user (user.id)}
{#if !promo || user.promo == promo}
{#if promo === null || user.promo === promo}
<tr>
<td><a href="users/{user.id}" style="text-decoration: none">{user.id}</a></td>
<td><a href="users/{user.login}" style="text-decoration: none">{user.login}</a></td>
{#each surveys as survey}
{#if survey.corrected && (!promo || survey.promo == promo) && (!category || survey.id_category == category)}
<td>
{grades[user.id] && grades[user.id][survey.kind + "." + survey.id]?grades[user.id][survey.kind + "." + survey.id]:""}
</td>
{#each surveys as survey (survey.id)}
{#if survey.corrected && (promo === null || survey.promo == promo)}
<td>{grades[user.id] && grades[user.id][survey.id]?grades[user.id][survey.id]:""}</td>
{/if}
{/each}
</tr>

View file

@ -1,8 +1,11 @@
<script>
import { createEventDispatcher } from 'svelte';
import DateFormat from '$lib/components/DateFormat.svelte';
import { getUserRendu } from '$lib/works';
import DateFormat from '../components/DateFormat.svelte';
import { getUserRendu } from '../lib/works';
let className = '';
export { className as class };
export let work = null;
export let user = null;

View file

@ -2,10 +2,9 @@
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
import { getCategories } from '$lib/categories';
import { getQuestions } from '$lib/questions';
import { getQuestions } from '../lib/questions';
import DateTimeInput from './DateTimeInput.svelte';
import { ToastsStore } from '$lib/stores/toasts';
import { ToastsStore } from '../stores/toasts';
const dispatch = createEventDispatcher();
export let survey = null;
@ -34,14 +33,10 @@
})
}
let duplicateInProgress = false;
function duplicateSurvey() {
duplicateInProgress = true;
survey.duplicate().then((response) => {
duplicateInProgress = false;
goto(`surveys/${response.id}`);
}).catch((error) => {
duplicateInProgress = false;
ToastsStore.addErrorToast({
msg: error,
});
@ -72,21 +67,6 @@
</div>
</div>
<div class="row">
<div class="col-sm-3 text-sm-end">
<label for="category" class="col-form-label col-form-label-sm">Catégorie</label>
</div>
<div class="col-sm-8 col-md-4 col-lg-2">
{#await getCategories() then categories}
<select id="category" class="form-select form-select-sm" bind:value={survey.id_category}>
{#each categories as category (category.id)}
<option value={category.id}>{category.label} {category.promo}</option>
{/each}
</select>
{/await}
</div>
</div>
<div class="row">
<div class="col-sm-3 text-sm-end">
<label for="promo" class="col-form-label col-form-label-sm">Promo</label>
@ -128,7 +108,7 @@
<div class="col-sm-3 text-sm-end">
<label for="start_availability" class="col-form-label col-form-label-sm">Date de début</label>
</div>
<div class="col-sm-8 col-md-5 col-lg-3">
<div class="col-sm-8">
<DateTimeInput class="form-control form-control-sm" id="start_availability" bind:date={survey.start_availability} />
</div>
</div>
@ -137,7 +117,7 @@
<div class="col-sm-3 text-sm-end">
<label for="end_availability" class="col-form-label col-form-label-sm">Date de fin</label>
</div>
<div class="col-sm-8 col-md-5 col-lg-3">
<div class="col-8">
<DateTimeInput class="form-control form-control-sm" id="end_availability" bind:date={survey.end_availability} />
</div>
</div>
@ -161,19 +141,14 @@
<div class="form-group row">
<div class="col-sm-10">
<button type="submit" class="btn btn-primary">Enregistrer</button>
{#if survey.id || duplicateInProgress}
<button type="button" class="btn btn-danger" on:click={deleteSurvey} disabled={deleteInProgress || duplicateInProgress}>
{#if survey.id}
<button type="button" class="btn btn-danger" on:click={deleteSurvey} disabled={deleteInProgress}>
{#if deleteInProgress}
<div class="spinner-border spinner-border-sm text-light me-1" role="status"></div>
{/if}
Supprimer
</button>
<button type="button" class="btn btn-secondary" on:click={duplicateSurvey} disabled={duplicateInProgress}>
{#if duplicateInProgress}
<div class="spinner-border spinner-border-sm text-dark me-1" role="status"></div>
{/if}
Dupliquer avec ces nouveaux paramètres
</button>
<button type="button" class="btn btn-secondary" on:click={duplicateSurvey}>Dupliquer avec ces nouveaux paramètres</button>
{/if}
</div>
</div>

View file

@ -0,0 +1,13 @@
<script>
export let survey;
let className = '';
export { className as class };
</script>
{#if survey.direct != null}<span class="badge bg-danger {className}">Direct</span>
{:else if survey.startAvailability() > Date.now()}<span class="badge bg-info {className}">Prévu</span>
{:else if survey.endAvailability() > Date.now()}<span class="badge bg-warning {className}">En cours</span>
{:else if !survey.__start_availability}<span class="badge bg-dark {className}">Nouveau</span>
{:else if !survey.corrected}<span class="badge bg-primary text-light {className}">Terminé</span>
{:else}<span class="badge bg-success {className}">Corrigé</span>
{/if}

View file

@ -1,14 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { user } from '$lib/stores/user';
import DateFormat from '$lib/components/DateFormat.svelte';
import SurveyBadge from '$lib/components/SurveyBadge.svelte';
import ScoreBadge from '$lib/components/ScoreBadge.svelte';
import SubmissionStatus from '$lib/components/SubmissionStatus.svelte';
import { getCategories } from '$lib/categories';
import { getSurveys } from '$lib/surveys';
import { getScore } from '$lib/users';
import { user } from '../stores/user';
import DateFormat from '../components/DateFormat.svelte';
import SurveyBadge from '../components/SurveyBadge.svelte';
import SubmissionStatus from '../components/SubmissionStatus.svelte';
import { getSurveys } from '../lib/surveys';
import { getScore } from '../lib/users';
export let allworks = false;
@ -23,13 +21,6 @@
}
});
let categories = {};
getCategories().then((cs) => {
for (const c of cs) {
categories[c.id] = c;
}
});
function gotoSurvey(survey) {
if (survey.kind === "w") {
goto(`works/${survey.id}`);
@ -49,11 +40,7 @@
<th>Intitulé</th>
<th>Date</th>
{#if $user}
{#if $user.is_admin}
<th>À corriger</th>
{:else}
<th>Score</th>
{/if}
<th>Score</th>
{/if}
</tr>
</thead>
@ -69,39 +56,13 @@
{#each surveys as survey, sid (survey.kind + survey.id)}
{#if (survey.shown || survey.direct == null || ($user && $user.is_admin)) && (!$user || (!$user.was_admin || $user.promo == survey.promo) || $user.is_admin)}
{#if $user && $user.is_admin && (sid == 0 || surveys[sid-1].promo != survey.promo)}
<tr class="bg-warning text-light">
<tr class="bg-info text-light">
<th colspan="5" class="fw-bold">
{survey.promo}
</th>
</tr>
{/if}
{#if $user && (sid == 0 || surveys[sid-1].id_category != survey.id_category) && categories[survey.id_category]}
<tr class="bg-primary text-light">
<th
colspan="5"
class="fw-bold"
on:click={() => categories[survey.id_category].expand = !categories[survey.id_category].expand}
on:keypress={() => categories[survey.id_category].expand = !categories[survey.id_category].expand}
>
{#if categories[survey.id_category].expand}
<i class="bi bi-chevron-down"></i>
{:else}
<i class="bi bi-chevron-right"></i>
{/if}
{categories[survey.id_category].label}
{#if $user && $user.is_admin}
<a href="categories/{survey.id_category}" class="float-end btn btn-sm btn-light" style="margin: -6px;">
<i class="bi bi-pencil"></i>
</a>
{/if}
</th>
</tr>
{/if}
{#if categories[survey.id_category] && categories[survey.id_category].expand}
<tr
on:click={e => gotoSurvey(survey)}
on:keypress={e => gotoSurvey(survey)}
>
<tr on:click={e => gotoSurvey(survey)}>
<td>
{#if !survey.shown}<i class="bi bi-eye-slash-fill" title="Ce questionnaire n'est pas affiché aux étudiants"></i>{/if}
{survey.title}
@ -123,30 +84,14 @@
</td>
{/if}
{#if $user}
{#if !survey.corrected && !$user.is_admin}
{#if !survey.corrected}
<td>N/A</td>
{:else}
<td>
{#await getScore(survey)}
<div class="spinner-border spinner-border-sm" role="status"></div>
{:then score}
{#if score.count !== undefined}
<span
class:fw-bolder={score.count-score.corrected > 0}
class:badge={survey.corrected}
class:bg-danger={survey.corrected && score.count-score.corrected > 0}
class:bg-dark={survey.corrected && score.count-score.corrected <= 0}
title="{score.count-score.corrected}/{score.count}"
>
{#if score.count == 0 || score.corrected == 0 || survey.corrected}
{score.count-score.corrected}
{:else}
{Math.trunc((1-score.corrected/score.count)*100)}&nbsp;%
{/if}
</span>
{:else}
<ScoreBadge score={score.score} />
{/if}
{score.score}
{:catch error}
<i class="bi text-warning bi-exclamation-triangle-fill" title={error}></i>
{/await}
@ -154,7 +99,6 @@
{/if}
{/if}
</tr>
{/if}
{/if}
{/each}
</tbody>

View file

@ -1,8 +1,8 @@
<script>
import { user } from '$lib/stores/user';
import { ToastsStore } from '$lib/stores/toasts';
import QuestionForm from '$lib/components/QuestionForm.svelte';
import { Question } from '$lib/questions';
import { user } from '../stores/user';
import { ToastsStore } from '../stores/toasts';
import QuestionForm from '../components/QuestionForm.svelte';
import { Question } from '../lib/questions';
export let survey = null;
export let id_user = null;
@ -25,7 +25,7 @@
survey.submitAnswers(res, id_user).then((response) => {
submitInProgress = false;
ToastsStore.addToast({
msg: "Vos réponses ont bien été sauvegardées.",
msg: "Vos réponses ont bien étés sauvegardées.",
color: "success",
title: "Questionnaire",
});

View file

@ -1,5 +1,5 @@
<script>
import { ToastsStore } from '$lib/stores/toasts';
import { ToastsStore } from '../stores/toasts';
</script>
<div class="toast-container position-fixed top-0 end-0 p-3">

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { getKeys, getKey, Key } from '$lib/key';
import { getKeys, getKey, Key } from '../lib/key';
export let student = null;
</script>

View file

@ -1,8 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getSurveys } from '$lib/surveys';
import { getUser, getUserGrade, getUserScore } from '$lib/users';
import { getSurveys } from '../lib/surveys';
import { getUser, getUserGrade, getUserScore } from '../lib/users';
export let student = null;
export let allPromos = false;

View file

@ -1,6 +1,6 @@
<script>
import { user } from '$lib/stores/user';
import DateFormat from '$lib/components/DateFormat.svelte';
import { user } from '../stores/user';
import DateFormat from '../components/DateFormat.svelte';
let className = '';
export { className as class };

View file

@ -2,10 +2,8 @@
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
import { getCategories } from '$lib/categories';
import { getGradationRepositories, syncGradationRepositories } from '$lib/gradation';
import DateTimeInput from './DateTimeInput.svelte';
import { ToastsStore } from '$lib/stores/toasts';
import { ToastsStore } from '../stores/toasts';
const dispatch = createEventDispatcher();
export let work = null;
@ -40,7 +38,6 @@
})
}
let grepositoriesP = getGradationRepositories();
</script>
<form on:submit|preventDefault={saveWork}>
@ -65,21 +62,6 @@
</div>
</div>
<div class="row">
<div class="col-sm-3 text-sm-end">
<label for="category" class="col-form-label col-form-label-sm">Catégorie</label>
</div>
<div class="col-sm-8 col-md-4 col-lg-2">
{#await getCategories() then categories}
<select id="category" class="form-select form-select-sm" bind:value={work.id_category}>
{#each categories as category (category.id)}
<option value={category.id}>{category.label} {category.promo}</option>
{/each}
</select>
{/await}
</div>
</div>
<div class="row">
<div class="col-sm-3 text-sm-end">
<label for="promo" class="col-form-label col-form-label-sm">Promo</label>
@ -111,46 +93,16 @@
<div class="col-sm-3 text-sm-end">
<label for="submissionurl" class="col-form-label col-form-label-sm">URL validation la soumission</label>
</div>
<div class="col-sm-10 col-md-8 col-lg-4">
<div class="col-sm-8 col-md-4 col-lg-2">
<input class="form-control form-control-sm" id="submissionurl" bind:value={work.submission_url}>
</div>
</div>
<div class="row">
<div class="col-sm-3 text-sm-end">
<label for="gradationrepo" class="col-form-label col-form-label-sm">Dépôt des tests automatiques</label>
</div>
<div class="col-sm-10 col-md-8 col-lg-4 d-flex align-items-center">
{#await grepositoriesP}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
{:then grepositories}
<div class="input-group">
<select class="form-select form-select-sm" id="gradationrepo" bind:value={work.gradation_repo}>
<option value={null}>-</option>
{#each grepositories as r}
<option value={r.slug}>{r.slug}</option>
{/each}
</select>
<button
type="button"
class="btn btn-light btn-sm"
title="Synchroniser les dépôts"
on:click={() => grepositoriesP = syncGradationRepositories()}
>
<i class="bi bi-arrow-repeat"></i>
</button>
</div>
{/await}
</div>
</div>
<div class="row">
<div class="col-sm-3 text-sm-end">
<label for="start_availability" class="col-form-label col-form-label-sm">Date de début</label>
</div>
<div class="col-sm-8 col-md-5 col-lg-3">
<div class="col-sm-8">
<DateTimeInput class="form-control form-control-sm" id="start_availability" bind:date={work.start_availability} />
</div>
</div>
@ -159,7 +111,7 @@
<div class="col-sm-3 text-sm-end">
<label for="end_availability" class="col-form-label col-form-label-sm">Date de fin</label>
</div>
<div class="col-sm-8 col-md-5 col-lg-3">
<div class="col-8">
<DateTimeInput class="form-control form-control-sm" id="end_availability" bind:date={work.end_availability} />
</div>
</div>

View file

@ -1,10 +1,10 @@
<script>
import { createEventDispatcher } from 'svelte';
import BuildState from '$lib/components/BuildState.svelte';
import DateFormat from '$lib/components/DateFormat.svelte';
import { WorkRepository, getRemoteRepositories, getRepositories } from '$lib/repositories';
import { ToastsStore } from '$lib/stores/toasts';
import BuildState from '../components/BuildState.svelte';
import DateFormat from '../components/DateFormat.svelte';
import { WorkRepository, getRemoteRepositories, getRepositories } from '../lib/repositories';
import { ToastsStore } from '../stores/toasts';
const dispatch = createEventDispatcher();
@ -159,12 +159,12 @@
<span>Récupération de vos dépôts GitLab &hellip;</span>
</div>
{:then rrepos}
<select id="repolist" class="form-select col" disabled={readonly} bind:value={repo_used.uri}>
{#each rrepos as r (r.ssh_url_to_repo)}
<select class="form-select col" disabled={readonly} bind:value={repo_used.uri}>
{#each rrepos as r (r.Id)}
<option value={r.ssh_url_to_repo}>{r.path_with_namespace}</option>
{/each}
</select>
<label for="repolist">Dépôt GitLab pour ce travail&nbsp;:</label>
<label>Dépôt GitLab pour ce travail&nbsp;:</label>
<button
type="submit"
class="mt-2 btn btn-primary"
@ -185,52 +185,6 @@
>
<i class="bi bi-arrow-clockwise"></i>
</button>
<button
type="button"
class="mt-2 btn btn-light"
on:click={() => {repo_used.uri = ""; repo_used.modal = new bootstrap.Modal(document.getElementById('customRepoModal')); repo_used.modal.show();}}
disable={submitInProgress || readonly || !repo_used || !repo_used.uri}
>
Utiliser un autre dépôt
</button>
</form>
{/if}
{/await}
{#if repo_used}
<div class="modal fade" tabindex="-1" id="customRepoModal">
<div class="modal-dialog modal-lg">
<form class="modal-content" on:submit|preventDefault={() => {repo_used.modal.hide(); delete repo_used.modal; submitWorkRepository()}}>
<div class="modal-header">
<h5 class="modal-title">Sélection de dépôt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Vous pouvez utiliser un dépôt hébergé sur une
autre forge, qu'elle soit publique ou
personnelle. Recopiez pour cela l'adresse du dépôt
dans le champ ci-dessous.
</p>
<div class="form-group mb-3">
<label class="form-label" for="repo-address">Adresse du dépôt</label>
<!-- svelte-ignore a11y-autofocus -->
<input class="form-control" id="repo-address" autofocus placeholder="git@git.mydomain.net:path/to/repo.git" bind:value={repo_used.uri}>
</div>
<p>
Assurez-vous bien que votre dépôt <strong>n'est pas public</strong>
et d'avoir <strong>ajouté une clef de déploiement</strong>
à votre dépôt&nbsp;:
</p>
<pre>
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINfzEnTiqwC4EeUG5EqfO0mLCygLU0HDiHTYgroNwjtT</pre>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">
Définir l'adresse de mon dépôt
</button>
</div>
</form>
</div>
</div>
{/if}

6
ui/src/hooks.js Normal file
View file

@ -0,0 +1,6 @@
export async function handle({ event, resolve }) {
const response = await resolve(event, {
ssr: false,
});
return response;
}

View file

@ -1,60 +0,0 @@
export async function getCategories() {
let url = '/api/categories';
const res = await fetch(url, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return (await res.json()).map((r) => new Category(r));
} else {
throw new Error((await res.json()).errmsg);
}
}
export class Category {
constructor(res) {
if (res) {
this.update(res);
}
}
update({ id, label, promo, expand }) {
this.id = id;
this.label = label;
this.promo = promo;
this.expand = expand;
}
async save() {
const res = await fetch(this.id?`api/categories/${this.id}`:'api/categories', {
method: this.id?'PUT':'POST',
headers: {'Accept': 'application/json'},
body: JSON.stringify(this),
});
if (res.status == 200) {
const data = await res.json()
this.update(data);
return data;
} else {
throw new Error((await res.json()).errmsg);
}
}
async delete() {
const res = await fetch(`api/categories/${this.id}`, {
method: 'DELETE',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return true;
} else {
throw new Error((await res.json()).errmsg);
}
}
}
export async function getCategory(cid) {
const res = await fetch(`api/categories/${cid}`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return new Category(await res.json());
} else {
throw new Error((await res.json()).errmsg);
}
}

View file

@ -1,92 +0,0 @@
<script>
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
import DateTimeInput from './DateTimeInput.svelte';
import { ToastsStore } from '$lib/stores/toasts';
const dispatch = createEventDispatcher();
export let category = null;
function saveCategory() {
category.save().then((response) => {
dispatch('saved', response);
}, (error) => {
ToastsStore.addErrorToast({
msg: error,
});
})
}
function deleteCategory() {
category.delete().then((response) => {
goto(`categories`);
}, (error) => {
ToastsStore.addErrorToast({
msg: error,
});
})
}
function duplicateCategory() {
category.duplicate().then((response) => {
goto(`categories/${response.id}`);
}).catch((error) => {
ToastsStore.addErrorToast({
msg: error,
});
})
}
</script>
<form on:submit|preventDefault={saveCategory}>
{#if category.id}
<div class="row">
<div class="col-sm-3 text-sm-end">
<label for="cid" class="col-form-label col-form-label-sm">Identifiant de la catégorie</label>
</div>
<div class="col-sm-8">
<input type="text" class="form-control-plaintext form-control-sm" id="cid" value={category.id}>
</div>
</div>
{/if}
<div class="row">
<div class="col-sm-3 text-sm-end">
<label for="title" class="col-form-label col-form-label-sm">Titre de la catégorie</label>
</div>
<div class="col-sm-8">
<input type="text" class="form-control form-control-sm" id="title" bind:value={category.label}>
</div>
</div>
<div class="row">
<div class="col-sm-3 text-sm-end">
<label for="promo" class="col-form-label col-form-label-sm">Promo</label>
</div>
<div class="col-sm-8 col-md-4 col-lg-2">
<input type="number" step="1" min="0" max="2068" class="form-control form-control-sm" id="promo" bind:value={category.promo}>
</div>
</div>
<div class="row row-cols-3 mx-1 my-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="expand" bind:checked={category.expand}>
<label class="form-check-label" for="expand">
Étendre par défaut
</label>
</div>
</div>
<div class="form-group row">
<div class="col-sm-10">
<button type="submit" class="btn btn-primary">Enregistrer</button>
{#if category.id}
<button type="button" class="btn btn-danger" on:click={deleteCategory}>Supprimer</button>
{/if}
</div>
</div>
</form>

View file

@ -1,14 +0,0 @@
<script>
export let score = 0;
</script>
<span
class="badge"
class:bg-success={score >= 18}
class:bg-info={score < 18 && score >= 15}
class:bg-warning={score < 15 && score >= 9}
class:bg-danger={score < 9}
class:bg-dark={score == "N/A"}
>
{score}
</span>

View file

@ -1,13 +0,0 @@
<script>
export let survey;
let className = '';
export { className as class };
</script>
{#if survey.direct != null}<span class="badge bg-danger {className}">Direct</span>
{:else if survey.startAvailability() > Date.now()}<span class="badge bg-info {className}" title="Le questionnaire ouvre le {survey.startAvailability()}">Prévu</span>
{:else if survey.endAvailability() > Date.now()}<span class="badge bg-warning {className}" title="Le questionnaire se termine le {survey.endAvailability()}">En cours</span>
{:else if !survey.__start_availability}<span class="badge bg-dark {className}">Nouveau</span>
{:else if !survey.corrected}<span class="badge bg-primary text-light {className}" title="Le questionnaire s'est terminé le {survey.endAvailability()}">Terminé</span>
{:else}<span class="badge bg-success {className}" title="Le questionnaire s'est terminé le {survey.endAvailability()} et est désormais corrigé">Corrigé</span>
{/if}

View file

@ -1,15 +0,0 @@
<script>
export let status = null;
</script>
{#if status}
<span
class="badge"
class:bg-success={status == "success"}
class:bg-danger={status == "failure" || status == "killed"}
class:bg-warning={status == "pending" || status == "running"}
class:bg-dark={status != "success" && status != "failure" && status != "killed" && status != "pending" && status != "running"}
>
{status}
</span>
{/if}

View file

@ -1,192 +0,0 @@
<script>
import { createEventDispatcher } from 'svelte';
import ScoreBadge from '$lib/components/ScoreBadge.svelte';
import { ToastsStore } from '$lib/stores/toasts';
const dispatch = createEventDispatcher();
export let work;
let gradesP = null;
let gradationStatus = {};
let stats = {"mean": 0, "min": 999, "max": 0};
let chgrade = {grade: null, modal: null};
$: refresh_grades(work);
function refresh_grades(w) {
gradesP = w.getGrades();
gradesP.then((grades) => {
if (grades.length <= 0) return;
let sum = 0;
for (const grade of grades) {
if (!gradationStatus[grade.id])
gradationStatus[grade.id] = grade.gradationStatus();
sum += grade.score;
if (stats.min > grade.score && grade.comment != "- Non rendu -") stats.min = grade.score;
if (stats.max < grade.score) stats.max = grade.score;
}
stats.mean = sum / grades.length;
});
}
async function addMissingStudents(w) {
await w.addMissingGrades();
refresh_grades(w);
}
</script>
<div class="d-flex justify-content-between align-items-center">
<h3 class="mt-3">
Notes
<small class="text-muted">
{#if stats.mean > 0}(moyenne&nbsp;: {Math.round(stats.mean*100)/100}, min&nbsp;: {stats.min}, max&nbsp;: {stats.max}){/if}
</small>
</h3>
<div>
<button
class="btn btn-outline-primary"
title="Afficher le résumé par étapes"
on:click={() => dispatch("switch_steps")}
>
<i class="bi bi-bar-chart-steps"></i>
</button>
<button
class="btn btn-outline-info"
title="Ajouter les étudiants manquant"
on:click={() => addMissingStudents(work)}
>
<i class="bi bi-people"></i>
</button>
<button
class="btn btn-light"
title="Rafraîchir l'affichage des notes"
on:click={() => refresh_grades(work)}
>
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<div class="card mt-3 mb-5">
{#await gradesP}
<div class="text-center my-5">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des notes &hellip;</span>
</div>
{:then grades}
<table class="table table-hover table-striped table-sm mb-0">
<thead>
<tr>
<th>Login</th>
<th>Note</th>
<th>Commentaire</th>
<th>Date de la note</th>
</tr>
</thead>
<tbody>
{#if !grades}
<div class="text-center">
Aucune note n'a encore été envoyée pour ce travail.
</div>
{:else}
{#each grades as grade, gid (grade.id)}
<tr>
<td>
<a href="users/{grade.id_user}">{grade.login}</a>
</td>
<td><ScoreBadge score={grade.score} /></td>
<td>{#if grade.comment}{grade.comment}{:else}-{/if}</td>
<td>{grade.date}</td>
<td>
<a
href="/api/users/{grade.id_user}/works/{work.id}/grades/{grade.id}/traces"
target="_blank"
class="btn btn-sm btn-outline-info mr-1"
title="Voir le détail de la notation"
>
<i class="bi bi-list-check"></i>
</a>
<a
href="/api/users/{grade.id_user}/works/{work.id}/grades/{grade.id}/forge"
target="_blank"
class="btn btn-sm btn-outline-primary mr-1"
title="Voir le contenu du dépôt lié"
>
<i class="bi bi-git"></i>
</a>
{#if gradationStatus[grade.id]}
{#await gradationStatus[grade.id]}
<button
class="btn btn-sm btn-outline-success mr-1"
title="Relancer la notation"
on:click={() => { grade.redoGradation().then(() => gradationStatus[grade.id] = grade.gradationStatus()); }}
>
<div class="spinner-border spinner-border-sm" role="status"></div>
</button>
{:then status}
<button
class="btn btn-sm mr-1"
class:btn-success={status.status == "success"}
class:btn-danger={status.status == "failure"}
class:btn-outline-danger={status.status == "killed"}
class:btn-outline-warning={status.status == "pending" || status.status == "running"}
title="Relancer la notation"
on:click={() => { grade.redoGradation(); gradationStatus[grade.id] = null; }}
>
<i class="bi bi-arrow-clockwise"></i>
</button>
{/await}
{/if}
<button
class="btn btn-sm btn-primary mr-1"
title="Changer la note"
on:click={() => { chgrade = { grade, modal: new bootstrap.Modal(document.getElementById('chgradeModal'))}; chgrade.modal.show(); }}
>
<i class="bi bi-pencil"></i>
</button>
<button
class="btn btn-sm btn-danger mr-1"
title="Supprimer la note"
on:click={() => { grade.delete().then(() => refresh_grades(work)); }}
>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
{/await}
</div>
<div class="modal fade" tabindex="-1" id="chgradeModal">
<div class="modal-dialog">
<form class="modal-content" on:submit|preventDefault={() => {chgrade.modal.hide(); try { chgrade.grade.save().then(() => refresh_grades(work)); } catch(err) { ToastsStore.addToast({color: "danger", title: "Impossible de changer la note", msg: err}) };}}>
<div class="modal-header">
<h5 class="modal-title">Changer la note {#if chgrade.grade}de {chgrade.grade.login}{/if}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{#if chgrade.grade}
<div class="form-group row mb-2">
<label class="col-2 col-form-label" for="new-grade">Note</label>
<!-- svelte-ignore a11y-autofocus -->
<input type="number" class="form-control col" id="new-grade" autofocus placeholder="15" bind:value={chgrade.grade.score}>
</div>
<div class="form-group row mb-2">
<label class="col-2 col-form-label" for="new-comment">Commentaire</label>
<input class="form-control col" id="new-comment" bind:value={chgrade.grade.comment}>
</div>
{/if}
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">
Changer la note
</button>
</div>
</form>
</div>
</div>

View file

@ -1,214 +0,0 @@
<script>
import { createEventDispatcher } from 'svelte';
import ScoreBadge from '$lib/components/ScoreBadge.svelte';
const dispatch = createEventDispatcher();
export let work;
let gradesP = null;
let grade_idx = {};
let gradationStatus = {};
let stats = [];
$: refresh_grades(work);
function refresh_grades(w) {
gradesP = w.getGrades();
gradesP.then((grades) => {
if (grades.length <= 0) return;
for (const grade of grades) {
grade_idx[grade.id] = grade;
if (!gradationStatus[grade.id]) {
gradationStatus[grade.id] = grade.gradationStatus();
gradationStatus[grade.id].then((status) => {
for (const istage in status.stages) {
const stage = status.stages[istage];
if (stats.length <= istage) {
stats.push({
arch: stage.arch,
name: stage.name,
number: stage.number,
status: [],
steps: [],
});
}
stats[istage].status.push(stage.status);
for (const istep in stage.steps) {
const step = stage.steps[istep];
if (stats[istage].steps.length <= istep) {
stats[istage].steps.push({
name: step.name,
number: step.number,
status: [],
});
}
stats[istage].steps[istep].status.push(step.status);
}
}
stats = stats;
});
}
}
});
}
let view_step = null;
</script>
<div class="d-flex justify-content-between align-items-center">
<h3 class="mt-3">
Réussite des étapes
</h3>
<div>
<button
class="btn btn-primary"
title="Afficher le résumé par étapes"
on:click={() => dispatch("switch_steps")}
>
<i class="bi bi-bar-chart-steps"></i>
</button>
<button
class="btn btn-light"
title="Rafraîchir l'affichage des notes"
on:click={() => refresh_grades(work)}
>
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
{#each stats as stage, istage}
<h5>
{stage.name}
<small>{stage.arch}</small>
</h5>
<div class="row row-cols-5">
{#each stage.steps as step, istep}
<div class="col">
<div class="card mb-3">
<div class="card-body fw-bolder text-truncate" title={step.name}>
{step.number}. {step.name}
</div>
<div
class="card-footer text-center"
class:bg-success={step.status.filter((e) => e == "success").length/step.status.length > 0.5}
on:click={() => view_step = {istage, istep, status: "success"}}
>
<i class="bi bi-check me-2 fw-bolder"></i>
{step.status.filter((e) => e == "success").length}
({Math.trunc(step.status.filter((e) => e == "success").length*100/step.status.length)}&nbsp;%)
</div>
<div
class="card-footer text-center"
class:bg-danger={step.status.filter((e) => e == "failure").length/step.status.length >= 0.5}
on:click={() => view_step = {istage, istep, status: "failure"}}
>
<i class="bi bi-x me-2 fw-bolder"></i>
{step.status.filter((e) => e == "failure").length}
({Math.trunc(step.status.filter((e) => e == "failure").length*100/step.status.length)}&nbsp;%)
</div>
{#if step.status.filter((e) => e == "skipped").length > 0}
<div
class="card-footer text-center"
class:fw-bold={step.status.filter((e) => e == "skipped").length/step.status.length >= 0.5}
on:click={() => view_step = {istage, istep, status: "skipped"}}
>
<i class="bi bi-skip-end me-2 fw-bolder"></i>
{step.status.filter((e) => e == "skipped").length}
({Math.trunc(step.status.filter((e) => e == "skipped").length*100/step.status.length)}&nbsp;%)
</div>
{/if}
</div>
</div>
{/each}
</div>
{/each}
{#if view_step}
<h3>
Étudiants correspondant
<small class="text-muted">
{"{"}
{stats[view_step.istage].name}
<i class="bi bi-arrow-right"></i>
<em>{stats[view_step.istage].steps[view_step.istep].name}</em>
"{view_step.status}"
{"}"}
</small>
</h3>
<div class="row row-cols-6">
{#each Object.keys(gradationStatus) as gsi}
{#await gradationStatus[gsi] then gs}
{#if gs.stages[view_step.istage] && gs.stages[view_step.istage].steps[view_step.istep] && gs.stages[view_step.istage].steps[view_step.istep].status == view_step.status}
<div class="col">
<div class="card mb-3">
<div
class="card-header text-monospace text-truncate"
title={grade_idx[gsi].login}
>
<a href="/users/{grade_idx[gsi].id_user}">
{grade_idx[gsi].login}
</a>
</div>
<ul class="list-group list-group-flush">
{#each gs.stages[view_step.istage].steps as step}
<li
class="list-group-item text-truncate p-2"
class:bg-success={step.status == "success"}
class:bg-light={step.status == "skipped"}
class:bg-danger={step.status == "failure"}
class:bg-warning={step.status == "pending" || step.status == "running"}
class:bg-info={step.status == "killed"}
>
<a
href="/api/users/{grade_idx[gsi].id_user}/works/{work.id}/grades/{grade_idx[gsi].id}/traces/{gs.stages[view_step.istage].number}/{step.number}"
target="_blank"
title="Voir le détail de cette étape"
>
{step.number}.
</a>
{step.name}
</li>
{/each}
</ul>
<div
class="card-footer d-flex justify-content-around align-items-center px-0"
>
<ScoreBadge score={grade_idx[gsi].score} />
<a
href="/api/users/{grade_idx[gsi].id_user}/works/{work.id}/grades/{grade_idx[gsi].id}/traces"
target="_blank"
class="btn btn-sm btn-outline-info"
title="Voir le détail de la notation"
>
<i class="bi bi-list-check"></i>
</a>
<a
href="/api/users/{grade_idx[gsi].id_user}/works/{work.id}/grades/{grade_idx[gsi].id}/forge"
target="_blank"
class="btn btn-sm btn-outline-primary"
title="Voir le contenu du dépôt lié"
>
<i class="bi bi-git"></i>
</a>
<button
class="btn btn-sm btn-outline-success"
title="Relancer la notation"
on:click={() => { grade_idx[gsi].redoGradation(); gradationStatus[gsi] = null; }}
>
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
</div>
{/if}
{/await}
{/each}
</div>
{/if}

View file

@ -1,39 +0,0 @@
<script>
import { user } from '$lib/stores/user';
import DateFormat from '$lib/components/DateFormat.svelte';
import SubmissionStatus from '$lib/components/SubmissionStatus.svelte';
export let work;
export let my_submission;
</script>
<dl style="columns: 3">
<dt>Date de début</dt>
<dd><DateFormat date={new Date(work.start_availability)} dateStyle="medium" timeStyle="medium" /></dd>
<dt>Date de fin</dt>
<dd><DateFormat date={new Date(work.end_availability)} dateStyle="medium" timeStyle="medium" /></dd>
{#if work.submission_url != "-"}
<dt>Rendu&nbsp;?</dt>
<dd>
{#if work.submission_url}
<SubmissionStatus work={w} user={$user} />
{:else}
{#await my_submission}
<div class="spinner-grow spinner-grow-sm mx-1" role="status"></div>
{:then submission}
<i
class="bi bi-check-circle text-success"
title="Oui !"
></i>
<DateFormat date={new Date(submission.date)} dateStyle="medium" timeStyle="medium" />
{:catch}
<i
class="bi bi-x-circle text-danger"
title="Pas de rendu trouvé"
></i>
Non
{/await}
{/if}
</dd>
{/if}
</dl>

View file

@ -1,19 +0,0 @@
export async function getGradationRepositories() {
let url = '/api/gradation_repositories';
const res = await fetch(url, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function syncGradationRepositories() {
let url = '/api/gradation_repositories/sync';
const res = await fetch(url, {method: 'post', headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}

View file

@ -1,70 +0,0 @@
export class Grade {
constructor(res) {
if (res) {
this.update(res);
}
}
update({ id, login, id_user, id_work, date, score, comment }) {
this.id = id;
this.login = login;
this.id_user = id_user;
this.id_work = id_work;
this.date = date;
this.score = score;
this.comment = comment;
}
async save() {
const res = await fetch(`api/works/${this.id_work}/grades/${this.id}`, {
method: 'PUT',
headers: {'Accept': 'application/json'},
body: JSON.stringify(this),
});
if (res.status == 200) {
const data = await res.json()
this.update(data);
return data;
} else {
throw new Error((await res.json()).errmsg);
}
}
async delete() {
if (this.id) {
const res = await fetch(`api/works/${this.id_work}/grades/${this.id}`, {
method: 'DELETE',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return true;
} else {
throw new Error((await res.json()).errmsg);
}
}
}
async redoGradation() {
const res = await fetch(this.id_user?`api/users/${this.id_user}/works/${this.id_work}/grades/${this.id}/traces`:`api/works/${this.id_work}/grades/${this.id}/traces`, {
method: 'POST',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
async gradationStatus() {
const res = await fetch(this.id_user?`api/users/${this.id_user}/works/${this.id_work}/grades/${this.id}/status`:`api/works/${this.id_work}/grades/${this.id}/status`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
}

View file

@ -62,10 +62,8 @@ export class Question {
this.kind = kind;
}
async getProposals(secret) {
let url = `/questions/${this.id}/proposals`;
if (secret) url = `/s/surveys/${this.id_survey}` + url + `?secret=${secret}`;
const res = await fetch('api' + url, {
async getProposals() {
const res = await fetch(`api/questions/${this.id}/proposals`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
@ -93,10 +91,8 @@ export class Question {
}
}
async getResponses(secret) {
let url = `/surveys/${this.id_survey}/questions/${this.id}/responses`;
if (secret) url = `/s` + url + `?secret=${secret}`;
const res = await fetch('api' + url, {
async getResponses() {
const res = await fetch(`api/surveys/${this.id_survey}/questions/${this.id}/responses`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
@ -165,17 +161,3 @@ export async function getQuestions(sid) {
throw new Error((await res.json()).errmsg);
}
}
export async function getSharedQuestions(sid, secret) {
const res = await fetch(`api/s/surveys/${sid}/questions?secret=${secret}`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
const data = await res.json();
if (data === null) {
return [];
} else {
return (data).map((q) => new Question(q))
}
} else {
throw new Error((await res.json()).errmsg);
}
}

View file

@ -15,14 +15,8 @@ export class WorkRepository {
this.already_used = already_used == true;
}
async delete(userid) {
let url = this.id_work?`works/${this.id_work}/repositories/${this.id}`:`repositories/${this.id}`;
if (userid) {
url = `users/${userid}/` + url;
}
const res = await fetch("api/" + url, {
async delete() {
const res = await fetch(this.id_work?`api/works/${this.id_work}/repositories/${this.id}`:`api/repositories/${this.id}`, {
method: 'DELETE',
headers: {'Accept': 'application/json'}
});
@ -61,11 +55,11 @@ export class WorkRepository {
}
}
async retrieveWork(admin_struct) {
async retrieveWork(tag) {
const res = await fetch(this.id_work?`api/works/${this.id_work}/repositories/${this.id}/trigger`:`api/repositories/${this.id}/trigger`, {
method: 'POST',
headers: {'Accept': 'application/json'},
body: !admin_struct?{}:JSON.stringify(admin_struct)
body: !tag || tag.length == 0?null:JSON.stringify(tag)
});
if (res.status == 200) {
const data = await res.json();
@ -76,30 +70,6 @@ export class WorkRepository {
}
}
async runGradation() {
const res = await fetch(this.id_work?`api/works/${this.id_work}/repositories/${this.id}/gradation`:`api/repositories/${this.id}/gradation`, {
method: 'POST',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
async gradationStatus() {
const res = await fetch(this.id_work?`api/works/${this.id_work}/repositories/${this.id}/gradation_status`:`api/repositories/${this.id}/gradation_status`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
async save(user) {
let url = this.id?`repositories/${this.id}`:'repositories';

View file

@ -11,9 +11,8 @@ export class Survey {
}
}
update({ id, id_category, title, promo, group, shown, direct, corrected, start_availability, end_availability }) {
update({ id, title, promo, group, shown, direct, corrected, start_availability, end_availability }) {
this.id = id;
this.id_category = id_category;
this.title = title;
this.promo = promo;
this.group = group;
@ -73,18 +72,6 @@ export class Survey {
}
}
async share() {
const res = await fetch(`api/surveys/${this.id}/shares`, {
method: 'POST',
headers: {'Accept': 'application/json'}
});
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
async save() {
const res = await fetch(this.id?`api/surveys/${this.id}`:'api/surveys', {
method: this.id?'PUT':'POST',
@ -117,35 +104,31 @@ export class Survey {
for (const q of questions) {
const oldQuestionId = q.id;
// This will create a new question with the same parameters
delete q.id;
// Also alter id_survey
q.id_survey = response.id;
q.save().then((question) => {
q.id = oldQuestionId;
// This save will create
const question = await q.save();
// Revert to the old question ID to perform the next retrievals
q.id = oldQuestionId;
// Now recopy proposals
if (q.kind == "mcq" || q.kind == "ucq") {
const proposals = await q.getProposals();
for (const p of proposals) {
delete p.id;
p.id_question = question.id;
await p.save();
// Now recopy proposals
if (q.kind == "mcq" || q.kind == "ucq") {
q.getProposals().then((proposals) => {
for (const p of proposals) {
delete p.id;
p.id_question = question.id;
p.save();
}
});
}
}
// Now recopy correction templates
const cts = await getCorrectionTemplates(oldQuestionId);
for (const ct of cts) {
delete ct.id;
ct.id_question = question.id;
ct.save();
}
// Now recopy correction templates
getCorrectionTemplates(oldQuestionId).then((cts) => {
for (const ct of cts) {
delete ct.id;
ct.id_question = question.id;
ct.save();
}
});
});
}
return response;
@ -202,12 +185,3 @@ export async function getSurvey(sid) {
throw new Error((await res.json()).errmsg);
}
}
export async function getSharedSurvey(sid, secret) {
const res = await fetch(`api/s/surveys/${sid}?secret=${secret}`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return new Survey(await res.json());
} else {
throw new Error((await res.json()).errmsg);
}
}

View file

@ -19,18 +19,6 @@ export async function getUsers(promo, group) {
}
}
export async function anonOldAccounts() {
const res = await fetch('api/users', {
method: 'PATCH',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return await res.json()
} else {
throw new Error((await res.json()).errmsg);
}
}
export class User {
constructor(res) {
if (res) {

View file

@ -1,5 +1,3 @@
import { Grade } from '$lib/grades';
export class Work {
constructor(res) {
this.kind = "w";
@ -8,9 +6,8 @@ export class Work {
}
}
update({ id, id_category, title, promo, group, shown, tag, description, descr_raw, submission_url, gradation_repo, corrected, start_availability, end_availability }) {
update({ id, title, promo, group, shown, tag, description, descr_raw, submission_url, corrected, start_availability, end_availability }) {
this.id = id;
this.id_category = id_category;
this.title = title;
this.promo = promo;
this.group = group;
@ -19,7 +16,6 @@ export class Work {
this.description = description;
this.descr_raw = descr_raw;
this.submission_url = submission_url;
this.gradation_repo = gradation_repo;
this.corrected = corrected;
if (this.start_availability != start_availability) {
this.start_availability = start_availability;
@ -95,32 +91,6 @@ export class Work {
}
}
async stopTests() {
if (this.id) {
const res = await fetch(`api/works/${this.id}/tests`, {
method: 'DELETE',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return true;
} else {
throw new Error((await res.json()).errmsg);
}
}
}
async addMissingGrades() {
const res = await fetch(`api/works/${this.id}/grades`, {
method: 'PATCH',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return (await res.json()).map((g) => new Grade(g));
} else {
throw new Error((await res.json()).errmsg);
}
}
async getSubmission(uid) {
const res = await fetch(uid?`api/users/${uid}/works/${this.id}/submission`:`api/works/${this.id}/submission`, {
headers: {'Accept': 'application/json'}
@ -132,25 +102,13 @@ export class Work {
}
}
async getMyTraces() {
const res = await fetch(`api/works/${this.id}/traces`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
async getGrades() {
const res = await fetch(`api/works/${this.id}/grades`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return (await res.json()).map((g) => new Grade(g));
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}

View file

@ -1,16 +0,0 @@
import { refresh_auth, user } from '$lib/stores/user';
export const ssr = false;
let refresh_interval_auth = null;
export async function load({ url }) {
refresh_interval_auth = setInterval(refresh_auth, Math.floor(Math.random() * 200000) + 200000);
refresh_auth();
const rroutes = url.pathname.split('/');
return {
rroute: rroutes.length>1?rroutes[1]:'',
};
}

View file

@ -1,9 +1,51 @@
<script>
import AuthButton from '$lib/components/AuthButton.svelte';
import Toaster from '$lib/components/Toaster.svelte';
import { refresh_auth, user } from '$lib/stores/user';
<script context="module">
import { user } from '../stores/user';
let stop_refresh = false;
export let data;
let refresh_interval_auth = null;
async function refresh_auth(cb=null, interval=null) {
if (refresh_interval_auth)
clearInterval(refresh_interval_auth);
if (interval === null) {
interval = Math.floor(Math.random() * 200000) + 200000;
}
if (stop_refresh) {
return;
}
refresh_interval_auth = setInterval(refresh_auth, interval);
const res = await fetch('api/auth', {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
const auth = await res.json();
user.set(auth);
} else {
user.set(null);
}
}
export async function load({ props, stuff, url }) {
refresh_auth();
const rroutes = url.pathname.split('/');
return {
props: {
...props,
rroute: rroutes.length>1?rroutes[1]:'',
},
stuff: {
...stuff,
refresh_auth,
}
};
}
</script>
<script>
import AuthButton from '../components/AuthButton.svelte';
import Toaster from '../components/Toaster.svelte';
export let rroute = '';
function switchAdminMode() {
var tmp = $user.is_admin;
@ -29,11 +71,6 @@
<title>ЕРІТА: MCQ and others courses related stuff</title>
</svelte:head>
{#if $user && $user.banner}
<div class="bg-danger text-white text-center py-1 fw-bolder">
{$user.banner}
</div>
{/if}
{#if isSRS}
<div style="position: fixed; bottom: 20px; right: 20px; z-index: -1; background-image: url('img/srstamps.png'); background-size: cover; width: 125px; height: 125px;"></div>
{/if}
@ -49,11 +86,7 @@
<div class="collapse navbar-collapse" id="loggedMenu">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
{#if $user && $user.promo != $user.current_promo}
<a class="nav-link" href="adlin/{$user.promo}" target="_self">AdLin</a>
{:else}
<a class="nav-link" href="adlin" target="_self">AdLin</a>
{/if}
<a class="nav-link" href="adlin" target="_self">AdLin</a>
</li>
{#if isSRS}
<li class="nav-item">
@ -61,25 +94,19 @@
</li>
{/if}
<li class="nav-item">
<a class="nav-link" class:active={data.rroute === 'surveys'} href="surveys">
<a class="nav-link" class:active={rroute === 'surveys'} href="surveys">
Questionnaires
</a>
</li>
<li class="nav-item">
<a class="nav-link" class:active={data.rroute === 'works'} href="works">
<a class="nav-link" class:active={rroute === 'works'} href="works">
Travaux
</a>
</li>
{#if $user && $user.is_admin}
<li class="nav-item"><a class="nav-link" class:active={data.rroute === 'users'} href="users">Étudiants</a></li>
<li class="nav-item"><a class="nav-link" class:active={rroute === 'users'} href="users">Étudiants</a></li>
{/if}
<li class="nav-item">
{#if $user && $user.promo != $user.current_promo}
<a class="nav-link" href="virli/{$user.promo}" target="_self">VIRLI</a>
{:else}
<a class="nav-link" href="virli" target="_self">VIRLI</a>
{/if}
</li>
<li class="nav-item"><a class="nav-link" href="virli" target="_self">VIRLI</a></li>
</ul>
<ul class="navbar-nav ms-auto">
@ -98,12 +125,13 @@
</li>
{/if}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#nav" role="button" aria-expanded="false">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false">
<img class="rounded-circle" src="//photos.cri.epita.fr/square/{$user.login}" alt="Menu" style="margin: -0.75em 0; max-height: 2.5em; border: 2px solid white;">
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" class:active={data.rroute === 'keys'} href="keys">Clef PGP</a></li>
<li><a class="dropdown-item" class:active={data.rroute === 'bug-bounty'} href="bug-bounty">Bug Bounty</a></li>
<li><a class="dropdown-item" class:active={rroute === 'keys'} href="keys">Clef PGP</a></li>
<li><a class="dropdown-item" class:active={rroute === 'help'} href="help">Besoin d'aide&nbsp;?</a></li>
<li><a class="dropdown-item" class:active={rroute === 'bug-bounty'} href="bug-bounty">Bug Bounty</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item" on:click={disconnectCurrentUser}>

View file

@ -2,9 +2,9 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores'
import AuthButton from '$lib/components/AuthButton.svelte';
import { ToastsStore } from '$lib/stores/toasts';
import { user } from '$lib/stores/user';
import AuthButton from '../components/AuthButton.svelte';
import { ToastsStore } from '../stores/toasts';
import { user } from '../stores/user';
let auth = { username: "", password: "" };
let pleaseWait = false;
@ -55,7 +55,6 @@
<form class="col" on:submit|preventDefault={logmein}>
<h2>Accès à votre compte</h2>
<div class="form-floating mb-3">
<!-- svelte-ignore a11y-autofocus -->
<input type="text" class="form-control" id="login" bind:value={auth.username} placeholder="xavier.login" autofocus>
<label for="login">CRI login</label>
</div>

View file

@ -59,28 +59,12 @@
<div class="alert alert-warning d-flex">
<i class="bi bi-exclamation-triangle me-3"></i>
<span>
À toute fin utile, l'usage et la non-divulgation d'une vulnérabilité sont <a href="https://www.legifrance.gouv.fr/codes/id/LEGISCTA000006149839/" target="_blank" rel="noreferrer">sanctionnables</a>.
À toute fin utile, l'usage et la non-divulgation d'une vulnérabilité sont <a href="https://www.legifrance.gouv.fr/codes/id/LEGISCTA000006149839/" target="_blank">sanctionnables</a>.
</span>
</div>
<h3 class="mt-5 mb-3">Hall of Fame</h3>
<div class="card mb-3">
<div class="card-header">
L'accès aux questionnaires n'était pas filtré selon les groupes ou les promos.
<span class="badge bg-success shadow-lg">+2&nbsp;pts</span>
</div>
<div class="card-body">
<div class="row row-cols-6">
<img class="img-thumbnail" src="//photos.cri.epita.fr/francois.dautreme" alt="francois.dautreme">
</div>
<p class="card-text mt-3">
Divulguée et corrigée le 19 novembre 2022.
<a href="https://git.nemunai.re/teach/atsebay.t/commit/f675047ce8f6636aa45336b56c069172330b050f" target="_blank" rel="noreferrer">Commit</a>
</p>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
Il était toujours possible de répondre aux questionnaires après l'heure de clôture.
@ -97,7 +81,7 @@
</div>
<p class="card-text mt-3">
Divulguée et corrigée le 19 novembre 2021.
<a href="https://git.nemunai.re/teach/atsebay.t/commit/5c53d2eaea9e7233bc8a08de2f40c040c0700c3e" target="_blank" rel="noreferrer">Commit</a>
<a href="https://git.nemunai.re/srs/atsebay.t/commit/5c53d2eaea9e7233bc8a08de2f40c040c0700c3e" target="_blank">Commit</a>
</p>
</div>
</div>

View file

@ -1,70 +0,0 @@
<script>
import { goto } from '$app/navigation';
import { user } from '$lib/stores/user';
import { getCategories } from '$lib/categories';
import { getPromos } from '$lib/users';
function showCategory(category) {
goto(`categories/${category.id}`)
}
let filterPromo = "";
</script>
{#if $user && $user.is_admin}
<a href="categories/new" class="btn btn-primary ml-1 float-end" title="Ajouter une catégorie">
<i class="bi bi-plus"></i>
</a>
{#await getPromos() then promos}
<div class="float-end me-2">
<select class="form-select" bind:value={filterPromo}>
<option value="">-</option>
{#each promos as promo, pid (pid)}
<option value={promo}>{promo}</option>
{/each}
</select>
</div>
{/await}
{/if}
<h2>
Catégories // cours
</h2>
{#await getCategories()}
<div class="text-center">
<div class="spinner-border text-danger mx-3" role="status"></div>
<span>Chargement des catégories &hellip;</span>
</div>
{:then categories}
<table class="table table-sm table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Nom</th>
<th>Promo</th>
<th>Étendre</th>
</tr>
</thead>
<tbody>
{#each categories.filter((c) => (filterPromo === "" || filterPromo === c.promo)) as c (c.id)}
<tr>
<td>{c.id}</td>
<td>
<a href="categories/{c.id}">{c.label}</a>
</td>
<td>{c.promo}</td>
<td>
<span
class="badge"
class:bg-success={c.expand}
class:bg-danger={!c.expand}
>
{c.expand?"Oui":"Non"}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
{/await}

View file

@ -1,5 +0,0 @@
export async function load({ params }) {
return {
cid: params.cid,
};
}

View file

@ -1,25 +0,0 @@
<script>
import { goto } from '$app/navigation';
import { user } from '$lib/stores/user';
import CategoryAdmin from '$lib/components/CategoryAdmin.svelte';
import { Category, getCategory } from '$lib/categories';
export let data;
let categoryP = null;
$: categoryP = getCategory(data.cid);
</script>
{#await categoryP then category}
<div class="d-flex align-items-center">
<h2>
<a href="categories/" class="text-muted" style="text-decoration: none">&lt;</a>
{category.label}
</h2>
</div>
{#if $user && $user.is_admin}
<CategoryAdmin {category} on:saved={(e) => { goto(`categories/`)}} />
{/if}
{/await}

View file

@ -1,20 +0,0 @@
<script>
import { goto } from '$app/navigation';
import { user } from '$lib/stores/user';
import CategoryAdmin from '$lib/components/CategoryAdmin.svelte';
import { Category } from '$lib/categories';
let category = new Category();
</script>
<div class="d-flex align-items-center">
<h2>
<a href="categories/" class="text-muted" style="text-decoration: none">&lt;</a>
Nouvelle catégorie
</h2>
</div>
{#if $user && $user.is_admin}
<CategoryAdmin {category} on:saved={(e) => { goto(`categories/${e.detail.id}`)}} />
{/if}

View file

@ -131,7 +131,7 @@
<h3>Mesure d'audience</h3>
<p>
Le site <code>lessons.nemunai.re</code> utilise un outil de mesure d'audience&nbsp;: <a href="https://umami.is" target="_blank" rel="noreferrer">Umami</a>.
Le site <code>lessons.nemunai.re</code> utilise un outil de mesure d'audience&nbsp;: <a href="https://umami.is" target="_blank">Umami</a>.
Cet outil collecte des informations sur les pages visitées en anonymisant les données personnelles (l'IP notamment).
</p>

View file

@ -1,5 +0,0 @@
<script>
import StudentGrades from '$lib/components/StudentGrades.svelte';
</script>
<StudentGrades />

View file

@ -0,0 +1,17 @@
<script context="module">
export async function load({ params }) {
return {
props: {
promo: params.promo,
}
};
}
</script>
<script>
import StudentGrades from '../../components/StudentGrades.svelte';
export let promo;
</script>
<StudentGrades {promo} />

View file

@ -1,5 +0,0 @@
export async function load({ params }) {
return {
promo: parseInt(params.promo),
};
}

View file

@ -1,7 +0,0 @@
<script>
import StudentGrades from '$lib/components/StudentGrades.svelte';
export let data;
</script>
<StudentGrades promo={data.promo} />

View file

@ -1,6 +0,0 @@
export async function load({ params }) {
return {
promo: parseInt(params.promo),
cid: parseInt(params.cid),
};
}

View file

@ -1,7 +0,0 @@
<script>
import StudentGrades from '$lib/components/StudentGrades.svelte';
export let data;
</script>
<StudentGrades promo={data.promo} category={data.cid} />

View file

@ -0,0 +1,5 @@
<script>
import StudentGrades from '../../components/StudentGrades.svelte';
</script>
<StudentGrades />

View file

@ -1,6 +1,6 @@
<script>
import { user } from '$lib/stores/user';
import { ToastsStore } from '$lib/stores/toasts';
import { user } from '../stores/user';
import { ToastsStore } from '../stores/toasts';
function needhelp() {
fetch('api/help', {
@ -44,7 +44,7 @@
<p>
Si tu souhaites me parler d'une situation qui t'a troublé&middot;e, d'un problème que tu rencontres ou me faire une remarque,
n'hésite pas à venir me voir lors d'un cours, par exemple à la pause ou à la fin{#if $user}&nbsp;;
je suis aussi joignable <a href="mailto:nemunaire@nemunai.re" data-umami-event="need-help-mail">par e-mail</a> ou bien <a href="https://matrix.to/#/@nemunaire:nemunai.re" data-umami-event="need-help-matrix">sur Matrix</a> ou Teams{/if}.
je suis aussi joignable <a href="mailto:nemunaire@nemunai.re" class="umami--click--need-help-mail">par e-mail</a> ou bien <a href="https://matrix.to/#/@nemunaire:nemunai.re" class="umami--click--need-help-matrix">sur Matrix</a> ou Teams{/if}.
</p>
{#if $user}
@ -52,8 +52,7 @@
Si tu souhaites juste avoir un peu plus d'attention, soit parce que tu te sens à l'écart, en difficulté ou autre&nbsp;:
<button
type="button"
class="btn btn-sm btn-primary"
data-umami-event="need-help"
class="btn btn-sm btn-primary umami--click--need-help"
on:click={needhelp}
>
Clique ce bouton

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { user } from '$lib/stores/user';
import { getUser, getUserNeedingHelp } from '$lib/users';
import DateFormat from '$lib/components/DateFormat.svelte';
import SurveyList from '$lib/components/SurveyList.svelte';
import ValidateSubmissions from '$lib/components/ValidateSubmissions.svelte';
import { user } from '../stores/user';
import { getUser, getUserNeedingHelp } from '../lib/users';
import DateFormat from '../components/DateFormat.svelte';
import SurveyList from '../components/SurveyList.svelte';
import ValidateSubmissions from '../components/ValidateSubmissions.svelte';
let direct = null;

View file

@ -1,7 +1,7 @@
<script>
import { deleteKey, getKeys, getKey, Key } from '$lib/key';
import { user } from '$lib/stores/user';
import { ToastsStore } from '$lib/stores/toasts';
import { deleteKey, getKeys, getKey, Key } from '../lib/key';
import { user } from '../stores/user';
import { ToastsStore } from '../stores/toasts';
let keysP = getKeys();
@ -125,7 +125,7 @@ ZWxxdWUgY2hvc2UK ...
Votre clef PGP peut vous servir à sécuriser les échanges de courriers électroniques. Par exemple les courriels officiels sur les listes de diffusion sensibles sont systématiquement signés (pour attester que c'est le responsable du projet qui en est bien à l'origine) ou encore si vous ne voulez pas que des tiers exploitent vos communications et/ou données personnelles (dans ce cas, on chiffre le contenu pour qu'il ne soit lisible que par le(s) destinataire(s) attendus).
</p>
<p>
Avec git, vous pouvez signer chacun de <a href="https://docs.gitlab.com/ee/user/project/repository/gpg_signed_commits/" target="_blank" rel="noreferrer">vos commits</a>, ou <a href="https://dev.to/shostarsson/how-to-use-pgp-to-sign-your-commits-on-github-gitlab-bitbucket-3dae#fountainpen-sign-tags-using-your-gpg-key" target="_blank" rel="noreferrer">vos tags</a>.<br>
Avec git, vous pouvez signer chacun de <a href="https://docs.gitlab.com/ee/user/project/repository/gpg_signed_commits/" target="_blank">vos commits</a>, ou <a href="https://dev.to/shostarsson/how-to-use-pgp-to-sign-your-commits-on-github-gitlab-bitbucket-3dae#fountainpen-sign-tags-using-your-gpg-key" target="_blank">vos tags</a>.<br>
Si vous souhaitez contribuer au <a href="https://www.kernel.org/doc/html/latest/process/maintainer-pgp-guide.html">noyau Linux</a> (ou tout autre projet d'envergure), il est nécessaire de signer vos contributions. Cela permet d'éviter <a href="https://www.theregister.com/2021/03/29/php_repository_infected/">un certain nombre d'attaques</a>.
</p>
<p>

View file

@ -1,7 +0,0 @@
export async function load({ url }) {
return {
secret: url.searchParams.get("secret"),
idsurvey: url.searchParams.get("survey"),
exportview_list: url.searchParams.get("graph_list")?false:true,
};
}

View file

@ -1,48 +0,0 @@
<script>
import { getSharedQuestions } from '$lib/questions';
import { getSharedSurvey } from '$lib/surveys';
import CorrectionPieChart from '$lib/components/CorrectionPieChart.svelte';
import SurveyBadge from '$lib/components/SurveyBadge.svelte';
export let data;
let surveyP = null;
$: surveyP = getSharedSurvey(data.idsurvey, data.secret);
</script>
{#await surveyP then survey}
<div class="d-flex align-items-center">
<h2>
{survey.title}
<small class="text-muted">Réponses</small>
</h2>
<SurveyBadge class="ms-2" {survey} />
</div>
{#await getSharedQuestions(survey.id, data.secret)}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des questions &hellip;</span>
</div>
{:then questions}
{#each questions as question (question.id)}
<h3>{question.title}</h3>
{#if question.kind == "text" || (data.exportview_list && question.kind.indexOf("list") == 0)}
{#await question.getResponses(data.secret) then responses}
{#each responses as response (response.id)}
<div class="card mb-2">
<div class="card-body">
<p class="card-text" style:white-space="pre-line">
{response.value}
</p>
</div>
</div>
{/each}
{/await}
{:else}
<CorrectionPieChart {question} secret={data.secret} />
{/if}
<hr class="mb-3">
{/each}
{/await}
{/await}

View file

@ -1,9 +0,0 @@
import { getSurvey } from '$lib/surveys';
export async function load({ params }) {
const survey = getSurvey(params.sid);
return {
survey,
};
}

View file

@ -1,5 +0,0 @@
export async function load({ parent }) {
const stuff = await parent();
return stuff;
}

View file

@ -1,60 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { user } from '$lib/stores/user';
import SurveyAdmin from '$lib/components/SurveyAdmin.svelte';
import SurveyBadge from '$lib/components/SurveyBadge.svelte';
import SurveyQuestions from '$lib/components/SurveyQuestions.svelte';
import { getQuestions } from '$lib/questions';
export let data;
let survey = null;
$: survey = data.survey;
$: if (survey.direct && !$user.is_admin) goto(`surveys/${survey.id}/live`);
let edit = false;
</script>
{#if $user && $user.is_admin}
<button class="btn btn-primary ms-1 float-end" on:click={() => { edit = !edit; } } title="Éditer"><i class="bi bi-pencil"></i></button>
<a href="surveys/{survey.id}/responses" class="btn btn-success ms-1 float-end" title="Voir les réponses"><i class="bi bi-files"></i></a>
{#if survey.direct}
<a href="surveys/{survey.id}/live" class="btn btn-danger ms-1 float-end" title="Aller au direct"><i class="bi bi-film"></i></a>
{/if}
{/if}
<div class="d-flex align-items-center">
<h2>
<a href="surveys/" class="text-muted" style="text-decoration: none">&lt;</a>
{survey.title}
</h2>
<SurveyBadge class="ms-2" {survey} />
</div>
{#if $user && $user.is_admin && edit}
<SurveyAdmin {survey} on:saved={() => edit = false} />
{/if}
{#await getQuestions(survey.id)}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des questions &hellip;</span>
</div>
{:then questions}
<SurveyQuestions {survey} {questions} />
{:catch error}
<div class="row mt-5">
<div class="d-none d-sm-block col-sm">
<hr>
</div>
<h3 class="col-sm-auto text-center text-muted mb-3"><label for="askquestion">Ce questionnaire n'est pas accessible</label></h3>
<div class="d-none d-sm-block col-sm">
<hr>
</div>
</div>
{#if survey.direct != null}
<div class="alert alert-warning">
<strong><a href="surveys/{survey.id}/live">Cliquez ici pour accéder au direct</a>.</strong> Il s'agit d'un questionnaire en direct, le questionnaire n'est pas accessible sur cette page.
</div>
{/if}
{/await}

View file

@ -1,8 +1,27 @@
<script lang="ts">
export let data;
<script context="module">
import { getSurvey } from '../../../lib/surveys';
export async function load({ params, stuff }) {
const survey = getSurvey(params.sid);
return {
props: {
survey,
},
stuff: {
...stuff,
survey,
}
};
}
</script>
{#await data.survey}
<script lang="ts">
export let survey;
</script>
{#await survey}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement du questionnaire &hellip;</span>

View file

@ -0,0 +1,704 @@
<script context="module">
export async function load({ params, stuff }) {
return {
props: {
surveyP: stuff.survey,
sid: params.sid,
},
};
}
</script>
<script>
import { user } from '../../../stores/user';
import CorrectionPieChart from '../../../components/CorrectionPieChart.svelte';
import ListInputResponses from '../../../components/ListInputResponses.svelte';
import QuestionForm from '../../../components/QuestionForm.svelte';
import StartStopLiveSurvey from '../../../components/StartStopLiveSurvey.svelte';
import SurveyAdmin from '../../../components/SurveyAdmin.svelte';
import SurveyBadge from '../../../components/SurveyBadge.svelte';
import { getSurvey } from '../../../lib/surveys';
import { getQuestion, getQuestions, Question } from '../../../lib/questions';
import { getUsers } from '../../../lib/users';
export let surveyP;
export let sid;
let survey;
let req_questions;
surveyP.then((s) => {
survey = s;
updateQuestions();
if (survey.direct !== null) {
wsconnect();
}
});
function updateSurvey() {
surveyP = getSurvey(survey.id);
surveyP.then((s) => {
survey = s;
updateQuestions();
if (survey.direct !== null) {
wsconnect();
}
});
}
function updateQuestions() {
req_questions = getQuestions(survey.id);
}
function deleteQuestion(question) {
edit_question = null;
question.delete();
}
let ws = null;
let ws_up = false;
let wsstats = null;
let current_question = null;
let edit_question = null;
let responses = {};
let corrected = false;
let next_corrected = false;
let timer = 20;
let timer_end = null;
let timer_remain = 0;
let timer_cancel = null;
function updTimer() {
const now = new Date().getTime();
if (now > timer_end) {
timer_remain = 0;
clearInterval(timer_cancel);
timer_cancel = null;
} else {
timer_remain = Math.floor((timer_end - now) / 100)/10;
}
}
let users = {};
function updateUsers() {
getUsers().then((usr) => {
const tmp = { };
for (const u of usr) {
tmp[u.id.toString()] = u;
}
users = tmp;
});
}
updateUsers();
let scroll_states = { };
let scroll_mean = 0;
$: {
let mean = 0;
for (const k in scroll_states) {
mean += scroll_states[k];
}
scroll_mean = mean / Object.keys(scroll_states).length;
}
let responsesbyid = { };
$: {
const tmp = { };
for (const response in responses) {
if (!tmp[response]) tmp[response] = [];
for (const r in responses[response]) {
tmp[response].push(responses[response][r]);
}
}
responsesbyid = tmp;
}
let graph_data = {labels:[], datasets:[]};
async function reset_graph_data(questionid) {
if (questionid) {
const labels = [];
const flabels = [];
let question = null;
for (const q of await req_questions) {
if (q.id == current_question) {
question = q;
}
}
if (question) {
for (const p of await question.getProposals()) {
flabels.push(p.id.toString());
labels.push(p.label);
}
}
graph_data = {
labels,
flabels,
datasets: [
{
values: labels.map(() => 0)
}
]
}
}
if (current_question && responses[current_question] && graph_data.labels.length != 0) {
const values = graph_data.datasets[0].values.map(() => 0);
for (const u in responses[current_question]) {
const res = responses[current_question][u];
for (const r of res.split(',')) {
let idx = graph_data.flabels.indexOf(r);
values[idx] += 1;
}
}
graph_data.datasets[0].values = values;
}
}
let asks = [];
function wsconnect() {
if (ws !== null) return;
ws = new WebSocket((window.location.protocol == 'https:'?'wss://':'ws://') + window.location.host + `/api/surveys/${sid}/ws-admin`);
ws.addEventListener("open", () => {
ws_up = true;
ws.send('{"action":"get_responses"}');
ws.send('{"action":"get_stats"}');
ws.send('{"action":"get_asks"}');
});
ws.addEventListener("close", (e) => {
ws_up = false;
console.log('Socket is closed. Reconnect will be attempted in 1 second.');
setTimeout(function() {
ws = null;
updateSurvey();
}, 1500);
});
ws.addEventListener("error", (err) => {
ws_up = false;
console.log('Socket closed due to error.', err);
});
ws.addEventListener("message", (message) => {
const data = JSON.parse(message.data);
if (data.action && data.action == "new_question") {
current_question = data.question;
corrected = data.corrected == true;
if (timer_cancel) {
clearInterval(timer_cancel);
timer_cancel = null;
}
if (data.timer) {
timer_end = new Date().getTime() + data.timer;
timer_cancel = setInterval(updTimer, 250);
} else {
timer_end = null;
}
reset_graph_data(data.question);
} else if (data.action && data.action == "stats") {
wsstats = data.stats;
} else if (data.action && data.action == "new_response") {
if (!responses[data.question]) responses[data.question] = { };
responses[data.question][data.user] = data.value;
reset_graph_data();
} else if (data.action && data.action == "new_ask") {
asks.push({"id": data.question, "content": data.value, "userid": data.user});
asks = asks;
} else if (data.action && data.action == "myscroll" && wsstats && wsstats.users) {
scroll_states[data.user] = parseFloat(data.value);
for (const k in wsstats.users) {
if (wsstats.users[k].id == data.user) {
wsstats.users[k].myscroll = scroll_states[data.user];
}
}
} else if (data.action && data.action == "end") {
ws.close();
updateSurvey();
} else {
current_question = null;
timer_end = null;
if (timer_cancel) {
clearInterval(timer_cancel);
timer_cancel = null;
}
}
});
}
</script>
{#await surveyP then survey}
{#if $user && $user.is_admin}
<StartStopLiveSurvey
{survey}
class="ms-1 float-end"
on:update={() => updateSurvey()}
on:end={() => { if (confirm("Sûr ?")) ws.send('{"action":"end"}') }}
/>
<a href="surveys/{survey.id}/responses" class="btn btn-success ms-1 float-end" title="Voir les réponses"><i class="bi bi-files"></i></a>
{/if}
<div class="d-flex align-items-center">
<h2>
<a href="surveys/" class="text-muted" style="text-decoration: none">&lt;</a>
{survey.title}
<small class="text-muted">
Administration
</small>
{#if asks.length}
<a href="surveys/{sid}/admin#questions_part">
<i class="bi bi-patch-question-fill text-danger"></i>
</a>
{/if}
</h2>
{#if survey.direct !== null}
<div
class="badge rounded-pill ms-2"
class:bg-success={ws_up}
class:bg-danger={!ws_up}
>
{#if ws_up}Connecté{:else}Déconnecté{/if}
</div>
{:else}
<SurveyBadge
class="mx-2"
{survey}
/>
{/if}
</div>
{#if survey.direct === null}
<SurveyAdmin
{survey}
on:saved={updateSurvey}
/>
{:else}
{#await req_questions}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des questions &hellip;</span>
</div>
{:then questions}
<div class="card my-3">
<table class="table table-hover table-striped mb-0">
<thead>
<tr>
<th>
Question
{#if timer_end}
<div class="input-group input-group-sm float-end" style="max-width: 150px;">
<input
type="number"
class="form-control"
disabled
value={timer_remain}
>
<span class="input-group-text">s</span>
</div>
{:else}
<div class="input-group input-group-sm float-end" style="max-width: 150px;">
<input
type="number"
class="form-control"
bind:value={timer}
placeholder="Valeur du timer"
>
<span class="input-group-text">s</span>
</div>
{/if}
<button
type="button"
class="btn btn-sm btn-info ms-1"
on:click={updateQuestions}
title="Rafraîchir les questions"
>
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</th>
<th>
Réponses
</th>
<th>
Actions
<button
type="button"
class="btn btn-sm btn-primary"
disabled={!current_question || !ws_up}
on:click={() => { ws.send('{"action":"pause"}')} }
title="Passer sur une scène sans question"
>
<i class="bi bi-pause-fill"></i>
</button>
<button
type="button"
class="btn btn-sm"
class:btn-outline-success={!next_corrected}
class:btn-success={next_corrected}
on:click={() => { next_corrected = !next_corrected } }
title="La prochaine question est affichée corrigée"
>
<i class="bi bi-eye"></i>
</button>
<button
type="button"
class="btn btn-sm btn-info"
on:click={() => { edit_question = new Question({ id_survey: survey.id }) } }
title="Ajouter une question"
>
<i class="bi bi-plus"></i>
</button>
<button
type="button"
class="btn btn-sm btn-outline-danger"
on:click={() => { fetch('api/cache', {method: 'DELETE'}) } }
title="Vider les caches"
>
<i class="bi bi-bandaid-fill"></i>
</button>
</th>
</tr>
</thead>
<tbody>
{#each questions as question (question.id)}
<tr>
<td>
{#if responses[question.id]}
<a href="surveys/{sid}/admin#q{question.id}_res">
{question.title}
</a>
{:else}
{question.title}
{/if}
</td>
<td>
{#if responses[question.id]}
{Object.keys(responses[question.id]).length}
{:else}
0
{/if}
{#if wsstats}/ {wsstats.nb_clients}{/if}
</td>
<td>
<button
type="button"
class="btn btn-sm"
class:btn-primary={!next_corrected}
class:btn-success={next_corrected}
disabled={(question.id === current_question && next_corrected == corrected) || !ws_up}
on:click={() => { ws.send('{"action":"new_question", "corrected": ' + next_corrected + ', "timer": 0, "question":' + question.id + '}')} }
>
<i class="bi bi-play-fill"></i>
</button>
<button
type="button"
class="btn btn-sm btn-danger"
disabled={question.id === current_question || !ws_up}
on:click={() => { ws.send('{"action":"new_question", "corrected": ' + next_corrected + ', "timer": ' + timer * 1000 + ',"question":' + question.id + '}')} }
>
<i class="bi bi-stopwatch-fill"></i>
</button>
<a
href="/surveys/{survey.id}/responses/{question.id}"
target="_blank"
type="button"
class="btn btn-sm btn-success"
>
<i class="bi bi-files"></i>
</a>
<button
type="button"
class="btn btn-sm btn-info"
disabled={question.id === current_question}
on:click={() => { getQuestion(question.id).then((q) => {edit_question = q})} }
>
<i class="bi bi-pencil"></i>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{#if edit_question !== null}
<QuestionForm
{survey}
edit
question={edit_question}
on:delete={() => deleteQuestion(edit_question)}
/>
{/if}
<hr>
<button
type="button"
class="btn btn-sm btn-info ms-1 float-end"
on:click={() => { ws.send('{"action":"get_asks", "value": ""}'); asks = []; }}
title="Rafraîchir les réponses"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-question-diamond"></i>
</button>
<button
type="button"
class="btn btn-sm btn-light ms-1 float-end"
on:click={() => { ws.send('{"action":"get_asks", "value": "unanswered"}'); asks = []; }}
title="Rafraîchir les réponses, en rapportant les réponses déjà répondues"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-question-diamond"></i>
</button>
<button
type="button"
class="btn btn-sm btn-success float-end"
title="Tout marqué comme répondu"
on:click={() => { ws.send('{"action":"mark_answered", "value": "all"}'); asks = [] }}
>
<i class="bi bi-check-all"></i>
</button>
<h3 id="questions_part">
Questions
{#if asks.length}
<small class="text-muted">
{asks.length}&nbsp;question{#if asks.length > 1}s{/if}
</small>
{/if}
</h3>
{#if asks.length}
{#each asks as ask (ask.id)}
<div class="card mb-3">
<div class="card-body">
<p class="card-text">
{ask.content}
</p>
</div>
<div class="card-footer">
<button
type="button"
class="btn btn-sm btn-success float-end"
title="Marqué comme répondu"
on:click={() => { ws.send('{"action":"mark_answered", "question": ' + ask.id + '}'); asks = asks.filter((e) => e.id != ask.id) }}
>
<i class="bi bi-check"></i>
</button>
Par
<a href="users/{ask.userid}" target="_blank">
{#if users && users[ask.userid]}
{users[ask.userid].login}
{:else}
{ask.userid}
{/if}
</a>
</div>
</div>
{/each}
{:else}
<div class="text-center text-muted">
Pas de question pour l'instant.
</div>
{/if}
<hr>
<button
type="button"
class="btn btn-sm btn-info ms-1 float-end"
on:click={() => { ws.send('{"action":"get_responses"}') }}
title="Rafraîchir les réponses"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-card-checklist"></i>
</button>
<h3>
Réponses
</h3>
{#if Object.keys(responses).length}
{#each Object.keys(responses) as q, qid (qid)}
{#await req_questions then questions}
{#each questions as question}
{#if question.id == q}
<h4 id="q{question.id}_res">
{question.title}
</h4>
{#if question.kind == 'ucq'}
{#await question.getProposals()}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des propositions &hellip;</span>
</div>
{:then proposals}
{#if current_question == question.id}
<CorrectionPieChart
{question}
{proposals}
data={graph_data}
/>
{:else}
<CorrectionPieChart
{question}
/>
{/if}
<div class="card mb-4">
<table class="table table-sm table-striped table-hover mb-0">
<tbody>
{#each proposals as proposal (proposal.id)}
<tr>
<td>
{proposal.label}
</td>
<td>
{responsesbyid[q].filter((e) => e == proposal.id.toString()).length}/{responsesbyid[q].length}
</td>
<td>
{Math.trunc(responsesbyid[q].filter((e) => e == proposal.id.toString()).length / responsesbyid[q].length * 1000)/10}&nbsp;%
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{:else if question.kind == 'mcq'}
{#await question.getProposals()}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des propositions &hellip;</span>
</div>
{:then proposals}
{#if current_question == question.id}
<CorrectionPieChart
{question}
{proposals}
data={graph_data}
/>
{:else}
<CorrectionPieChart
{question}
/>
{/if}
<div class="card mb-4">
<table class="table table-sm table-striped table-hover mb-0">
<tbody>
{#each proposals as proposal (proposal.id)}
<tr>
<td>
{proposal.label}
</td>
<td>
{responsesbyid[q].filter((e) => e.indexOf(proposal.id.toString()) >= 0).length}/{responsesbyid[q].length}
</td>
<td>
{Math.trunc(responsesbyid[q].filter((e) => e.indexOf(proposal.id.toString()) >= 0).length / responsesbyid[q].length * 1000)/10}&nbsp;%
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{:else if question.kind && question.kind.startsWith('list')}
<ListInputResponses
responses={responses[q]}
{users}
/>
{:else}
<div class="card mb-4">
<ul class="list-group list-group-flush">
{#each Object.keys(responses[q]) as user, rid (rid)}
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span>
{responses[q][user]}
</span>
<a href="users/{user}" target="_blank" class="badge bg-dark rounded-pill">
{#if users && users[user]}
{users[user].login}
{:else}
{user}
{/if}
</a>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/each}
{/await}
{/each}
{/if}
<hr>
<button
type="button"
class="btn btn-sm btn-info ms-1 float-end"
on:click={() => { ws.send('{"action":"get_stats"}') }}
title="Rafraîchir les stats"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-123"></i>
</button>
<button
type="button"
class="btn btn-sm btn-primary ms-1 float-end"
title="Rafraîchir la liste des utilisateurs"
on:click={updateUsers}
>
<i class="bi bi-arrow-clockwise"></i>
<i class="bi bi-people"></i>
</button>
<button
type="button"
class="btn btn-sm btn-warning ms-1 float-end"
on:click={() => { scroll_states = {}; ws.send('{"action":"where_are_you"}')} }
title="Rapporter l'avancement"
>
<i class="bi bi-geo-fill"></i>
</button>
<h3 id="users">
Connectés
{#if wsstats}
<small class="text-muted">{wsstats.nb_clients} utilisateurs</small>
{/if}
{#if scroll_mean}
<small
class:text-danger={scroll_mean >= 0 && scroll_mean < 0.2}
class:text-warning={scroll_mean >= 0.2 && scroll_mean < 0.6}
class:text-info={scroll_mean >= 0.6 && scroll_mean < 0.9}
class:text-success={scroll_mean >= 0.9}
>Avancement global&nbsp;: {Math.trunc(scroll_mean*10000)/100} %</small>
{/if}
</h3>
{#if wsstats && wsstats.users}
<div class="row row-cols-5 py-3">
{#each wsstats.users as user, lid (lid)}
<div class="col">
<div class="card">
<img alt="{user.login}" src="//photos.cri.epita.fr/thumb/{user.login}" class="card-img-top">
<div class="card-footer text-center text-truncate p-0">
<a href="users/{user.login}" target="_blank">
{user.login}
</a>
</div>
{#if user.myscroll != null}
<div
class="card-footer py-0 px-1"
class:bg-danger={user.myscroll >= 0 && user.myscroll < 0.2}
class:bg-warning={user.myscroll >= 0.2 && user.myscroll < 0.6}
class:bg-info={user.myscroll >= 0.6 && user.myscroll < 0.9}
class:bg-success={user.myscroll >= 0.9}
>
Avancement&nbsp;: {Math.trunc(user.myscroll*10000)/100}&nbsp;%
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}
{/await}

View file

@ -1,8 +0,0 @@
export async function load({ parent, params }) {
const stuff = await parent();
return {
survey: stuff.survey,
sid: params.sid,
};
}

View file

@ -1,697 +0,0 @@
<script>
import { user } from '$lib/stores/user';
import CorrectionPieChart from '$lib/components/CorrectionPieChart.svelte';
import ListInputResponses from '$lib/components/ListInputResponses.svelte';
import QuestionForm from '$lib/components/QuestionForm.svelte';
import StartStopLiveSurvey from '$lib/components/StartStopLiveSurvey.svelte';
import SurveyAdmin from '$lib/components/SurveyAdmin.svelte';
import SurveyBadge from '$lib/components/SurveyBadge.svelte';
import { getSurvey } from '$lib/surveys';
import { getQuestion, getQuestions, Question } from '$lib/questions';
import { getUsers } from '$lib/users';
export let data;
let survey;
let req_questions;
$: {
survey = data.survey;
updateQuestions();
if (survey.direct !== null) {
wsconnect();
}
}
async function updateSurvey() {
survey = await getSurvey(survey.id);
updateQuestions();
if (survey.direct !== null) {
wsconnect();
}
}
function updateQuestions() {
req_questions = getQuestions(survey.id);
}
function deleteQuestion(question) {
edit_question = null;
question.delete();
}
let ws = null;
let ws_up = false;
let wsstats = null;
let current_question = null;
let edit_question = null;
let responses = {};
let corrected = false;
let next_corrected = false;
let with_stats = false;
let timer = 20;
let timer_end = null;
let timer_remain = 0;
let timer_cancel = null;
function updTimer() {
const now = new Date().getTime();
if (now > timer_end) {
timer_remain = 0;
clearInterval(timer_cancel);
timer_cancel = null;
} else {
timer_remain = Math.floor((timer_end - now) / 100)/10;
}
}
let users = {};
function updateUsers() {
getUsers().then((usr) => {
const tmp = { };
for (const u of usr) {
tmp[u.id.toString()] = u;
}
users = tmp;
});
}
updateUsers();
let scroll_states = { };
let scroll_mean = 0;
$: {
let mean = 0;
for (const k in scroll_states) {
mean += scroll_states[k];
}
scroll_mean = mean / Object.keys(scroll_states).length;
}
let responsesbyid = { };
$: {
const tmp = { };
for (const response in responses) {
if (!tmp[response]) tmp[response] = [];
for (const r in responses[response]) {
tmp[response].push(responses[response][r]);
}
}
responsesbyid = tmp;
}
let graph_data = {labels:[], datasets:[]};
async function reset_graph_data(questionid) {
if (questionid) {
const labels = [];
const flabels = [];
let question = null;
for (const q of await req_questions) {
if (q.id == current_question) {
question = q;
}
}
if (question) {
for (const p of await question.getProposals()) {
flabels.push(p.id.toString());
labels.push(p.label);
}
}
graph_data = {
labels,
flabels,
datasets: [
{
values: labels.map(() => 0)
}
]
}
}
if (current_question && responses[current_question] && graph_data.labels.length != 0) {
const values = graph_data.datasets[0].values.map(() => 0);
for (const u in responses[current_question]) {
const res = responses[current_question][u];
for (const r of res.split(',')) {
let idx = graph_data.flabels.indexOf(r);
values[idx] += 1;
}
}
graph_data.datasets[0].values = values;
}
}
let asks = [];
function wsconnect() {
if (ws !== null) return;
ws = new WebSocket((window.location.protocol == 'https:'?'wss://':'ws://') + window.location.host + `/api/surveys/${data.sid}/ws-admin`);
ws.addEventListener("open", () => {
ws_up = true;
ws.send('{"action":"get_responses"}');
ws.send('{"action":"get_stats"}');
ws.send('{"action":"get_asks"}');
});
ws.addEventListener("close", (e) => {
ws_up = false;
console.log('Socket is closed. Reconnect will be attempted in 1 second.');
setTimeout(function() {
ws = null;
updateSurvey();
}, 1500);
});
ws.addEventListener("error", (err) => {
ws_up = false;
console.log('Socket closed due to error.', err);
});
ws.addEventListener("message", (message) => {
const data = JSON.parse(message.data);
if (data.action && data.action == "new_question") {
current_question = data.question;
corrected = data.corrected == true;
if (timer_cancel) {
clearInterval(timer_cancel);
timer_cancel = null;
}
if (data.timer) {
timer_end = new Date().getTime() + data.timer;
timer_cancel = setInterval(updTimer, 250);
} else {
timer_end = null;
}
reset_graph_data(data.question);
} else if (data.action && data.action == "stats") {
wsstats = data.stats;
} else if (data.action && data.action == "new_response") {
if (!responses[data.question]) responses[data.question] = { };
responses[data.question][data.user] = data.value;
reset_graph_data();
} else if (data.action && data.action == "new_ask") {
asks.push({"id": data.question, "content": data.value, "userid": data.user});
asks = asks;
} else if (data.action && data.action == "myscroll" && wsstats && wsstats.users) {
scroll_states[data.user] = parseFloat(data.value);
for (const k in wsstats.users) {
if (wsstats.users[k].id == data.user) {
wsstats.users[k].myscroll = scroll_states[data.user];
}
}
} else if (data.action && data.action == "end") {
ws.close();
updateSurvey();
} else {
current_question = null;
timer_end = null;
if (timer_cancel) {
clearInterval(timer_cancel);
timer_cancel = null;
}
}
});
}
</script>
{#if $user && $user.is_admin}
<StartStopLiveSurvey
{survey}
class="ms-1 float-end"
on:update={() => updateSurvey()}
on:end={() => { if (confirm("Sûr ?")) ws.send('{"action":"end"}') }}
/>
<a href="surveys/{survey.id}/responses" class="btn btn-success ms-1 float-end" title="Voir les réponses"><i class="bi bi-files"></i></a>
{/if}
<div class="d-flex align-items-center">
<h2>
<a href="surveys/" class="text-muted" style="text-decoration: none">&lt;</a>
{survey.title}
<small class="text-muted">
Administration
</small>
{#if asks.length}
<a href="surveys/{data.sid}/admin#questions_part">
<i class="bi bi-patch-question-fill text-danger"></i>
</a>
{/if}
</h2>
{#if survey.direct !== null}
<div
class="badge rounded-pill ms-2"
class:bg-success={ws_up}
class:bg-danger={!ws_up}
>
{#if ws_up}Connecté{:else}Déconnecté{/if}
</div>
{:else}
<SurveyBadge
class="mx-2"
{survey}
/>
{/if}
</div>
{#if survey.direct === null}
<SurveyAdmin
{survey}
on:saved={updateSurvey}
/>
{:else}
{#await req_questions}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des questions &hellip;</span>
</div>
{:then questions}
<div class="card my-3">
<table class="table table-hover table-striped mb-0">
<thead>
<tr>
<th>
Question
{#if timer_end}
<div class="input-group input-group-sm float-end" style="max-width: 150px;">
<input
type="number"
class="form-control"
disabled
value={timer_remain}
>
<span class="input-group-text">s</span>
</div>
{:else}
<div class="input-group input-group-sm float-end" style="max-width: 150px;">
<input
type="number"
class="form-control"
bind:value={timer}
placeholder="Valeur du timer"
>
<span class="input-group-text">s</span>
</div>
{/if}
<button
type="button"
class="btn btn-sm btn-info ms-1"
on:click={updateQuestions}
title="Rafraîchir les questions"
>
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</th>
<th>
Réponses
</th>
<th>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={!current_question || !ws_up}
on:click={() => { ws.send('{"action":"pause"}')} }
title="Passer sur une scène sans question"
>
<i class="bi bi-pause-fill"></i>
</button>
<button
type="button"
class="btn btn-sm"
class:btn-outline-success={!next_corrected}
class:btn-success={next_corrected}
on:click={() => { next_corrected = !next_corrected } }
title="La prochaine question est affichée corrigée"
>
<i class="bi bi-eye"></i>
</button>
<button
type="button"
class="btn btn-sm"
class:btn-outline-success={!with_stats}
class:btn-success={with_stats}
on:click={() => { with_stats = !with_stats } }
title="La prochaine correction sera affichée avec les statistiques"
>
<i class="bi bi-bar-chart-fill"></i>
</button>
<button
type="button"
class="btn btn-sm btn-outline-danger"
on:click={() => { fetch('api/cache', {method: 'DELETE'}) } }
title="Vider les caches"
>
<i class="bi bi-bandaid-fill"></i>
</button>
<button
type="button"
class="btn btn-sm btn-info mt-1"
on:click={() => { edit_question = new Question({ id_survey: survey.id }) } }
title="Ajouter une question"
>
<i class="bi bi-plus"></i>
</button>
</th>
</tr>
</thead>
<tbody>
{#each questions as question (question.id)}
<tr>
<td>
{#if responses[question.id]}
<a href="surveys/{data.sid}/admin#q{question.id}_res">
{question.title}
</a>
{:else}
{question.title}
{/if}
</td>
<td>
{#if responses[question.id]}
{Object.keys(responses[question.id]).length}
{:else}
0
{/if}
{#if wsstats}/ {wsstats.nb_clients}{/if}
</td>
<td>
<button
type="button"
class="btn btn-sm"
class:btn-primary={!next_corrected}
class:btn-success={next_corrected}
disabled={(question.id === current_question && next_corrected == corrected) || !ws_up}
on:click={() => { ws.send('{"action":"new_question", "corrected": ' + next_corrected + (with_stats?', "stats": {}':'') + ', "timer": 0, "question":' + question.id + '}')} }
>
<i class="bi bi-play-fill"></i>
</button>
<button
type="button"
class="btn btn-sm btn-danger"
disabled={question.id === current_question || !ws_up}
on:click={() => { ws.send('{"action":"new_question", "corrected": ' + next_corrected + (with_stats?', "stats": {}':'') + ', "timer": ' + timer * 1000 + ',"question":' + question.id + '}')} }
>
<i class="bi bi-stopwatch-fill"></i>
</button>
<a
href="/surveys/{survey.id}/responses/{question.id}"
target="_blank"
rel="noreferrer"
type="button"
class="btn btn-sm btn-success"
>
<i class="bi bi-files"></i>
</a>
<button
type="button"
class="btn btn-sm btn-info"
disabled={question.id === current_question}
on:click={() => { getQuestion(question.id).then((q) => {edit_question = q})} }
>
<i class="bi bi-pencil"></i>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{#if edit_question !== null}
<QuestionForm
{survey}
edit
question={edit_question}
on:delete={() => deleteQuestion(edit_question)}
/>
{/if}
<hr>
<button
type="button"
class="btn btn-sm btn-info ms-1 float-end"
on:click={() => { ws.send('{"action":"get_asks", "value": ""}'); asks = []; }}
title="Rafraîchir les réponses"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-question-diamond"></i>
</button>
<button
type="button"
class="btn btn-sm btn-light ms-1 float-end"
on:click={() => { ws.send('{"action":"get_asks", "value": "unanswered"}'); asks = []; }}
title="Rafraîchir les réponses, en rapportant les réponses déjà répondues"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-question-diamond"></i>
</button>
<button
type="button"
class="btn btn-sm btn-success float-end"
title="Tout marqué comme répondu"
on:click={() => { ws.send('{"action":"mark_answered", "value": "all"}'); asks = [] }}
>
<i class="bi bi-check-all"></i>
</button>
<h3 id="questions_part">
Questions
{#if asks.length}
<small class="text-muted">
{asks.length}&nbsp;question{#if asks.length > 1}s{/if}
</small>
{/if}
</h3>
{#if asks.length}
{#each asks as ask (ask.id)}
<div class="card mb-3">
<div class="card-body">
<p class="card-text">
{ask.content}
</p>
</div>
<div class="card-footer">
<button
type="button"
class="btn btn-sm btn-success float-end"
title="Marqué comme répondu"
on:click={() => { ws.send('{"action":"mark_answered", "question": ' + ask.id + '}'); asks = asks.filter((e) => e.id != ask.id) }}
>
<i class="bi bi-check"></i>
</button>
Par
<a href="users/{ask.userid}" target="_blank" rel="noreferrer">
{#if users && users[ask.userid]}
{users[ask.userid].login}
{:else}
{ask.userid}
{/if}
</a>
</div>
</div>
{/each}
{:else}
<div class="text-center text-muted">
Pas de question pour l'instant.
</div>
{/if}
<hr>
<button
type="button"
class="btn btn-sm btn-info ms-1 float-end"
on:click={() => { ws.send('{"action":"get_responses"}') }}
title="Rafraîchir les réponses"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-card-checklist"></i>
</button>
<h3>
Réponses
</h3>
{#if Object.keys(responses).length}
{#each Object.keys(responses) as q, qid (qid)}
{#await req_questions then questions}
{#each questions as question}
{#if question.id == q}
<h4 id="q{question.id}_res">
{question.title}
</h4>
{#if question.kind == 'ucq'}
{#await question.getProposals()}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des propositions &hellip;</span>
</div>
{:then proposals}
{#if current_question == question.id}
<CorrectionPieChart
{question}
{proposals}
data={graph_data}
/>
{:else}
<CorrectionPieChart
{question}
/>
{/if}
<div class="card mb-4">
<table class="table table-sm table-striped table-hover mb-0">
<tbody>
{#each proposals as proposal (proposal.id)}
<tr>
<td>
{proposal.label}
</td>
<td>
{responsesbyid[q].filter((e) => e == proposal.id.toString()).length}/{responsesbyid[q].length}
</td>
<td>
{Math.trunc(responsesbyid[q].filter((e) => e == proposal.id.toString()).length / responsesbyid[q].length * 1000)/10}&nbsp;%
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{:else if question.kind == 'mcq'}
{#await question.getProposals()}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des propositions &hellip;</span>
</div>
{:then proposals}
{#if current_question == question.id}
<CorrectionPieChart
{question}
{proposals}
data={graph_data}
/>
{:else}
<CorrectionPieChart
{question}
/>
{/if}
<div class="card mb-4">
<table class="table table-sm table-striped table-hover mb-0">
<tbody>
{#each proposals as proposal (proposal.id)}
<tr>
<td>
{proposal.label}
</td>
<td>
{responsesbyid[q].filter((e) => e.indexOf(proposal.id.toString()) >= 0).length}/{responsesbyid[q].length}
</td>
<td>
{Math.trunc(responsesbyid[q].filter((e) => e.indexOf(proposal.id.toString()) >= 0).length / responsesbyid[q].length * 1000)/10}&nbsp;%
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{:else if question.kind && question.kind.startsWith('list')}
<ListInputResponses
responses={responses[q]}
{users}
/>
{:else}
<div class="card mb-4">
<ul class="list-group list-group-flush">
{#each Object.keys(responses[q]) as user, rid (rid)}
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span>
{responses[q][user]}
</span>
<a href="users/{user}" target="_blank" rel="noreferrer" class="badge bg-dark rounded-pill">
{#if users && users[user]}
{users[user].login}
{:else}
{user}
{/if}
</a>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/each}
{/await}
{/each}
{/if}
<hr>
<button
type="button"
class="btn btn-sm btn-info ms-1 float-end"
on:click={() => { ws.send('{"action":"get_stats"}') }}
title="Rafraîchir les stats"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-123"></i>
</button>
<button
type="button"
class="btn btn-sm btn-primary ms-1 float-end"
title="Rafraîchir la liste des utilisateurs"
on:click={updateUsers}
>
<i class="bi bi-arrow-clockwise"></i>
<i class="bi bi-people"></i>
</button>
<button
type="button"
class="btn btn-sm btn-warning ms-1 float-end"
on:click={() => { scroll_states = {}; ws.send('{"action":"where_are_you"}')} }
title="Rapporter l'avancement"
>
<i class="bi bi-geo-fill"></i>
</button>
<h3 id="users">
Connectés
{#if wsstats}
<small class="text-muted">{wsstats.nb_clients} utilisateurs</small>
{/if}
{#if scroll_mean}
<small
class:text-danger={scroll_mean >= 0 && scroll_mean < 0.2}
class:text-warning={scroll_mean >= 0.2 && scroll_mean < 0.6}
class:text-info={scroll_mean >= 0.6 && scroll_mean < 0.9}
class:text-success={scroll_mean >= 0.9}
>Avancement global&nbsp;: {Math.trunc(scroll_mean*10000)/100} %</small>
{/if}
</h3>
{#if wsstats && wsstats.users}
<div class="row row-cols-5 py-3">
{#each wsstats.users as user, lid (lid)}
<div class="col">
<div class="card">
<img alt="{user.login}" src="//photos.cri.epita.fr/thumb/{user.login}" class="card-img-top">
<div class="card-footer text-center text-truncate p-0">
<a href="users/{user.login}" target="_blank" rel="noreferrer">
{user.login}
</a>
</div>
{#if user.myscroll != null}
<div
class="card-footer py-0 px-1"
class:bg-danger={user.myscroll >= 0 && user.myscroll < 0.2}
class:bg-warning={user.myscroll >= 0.2 && user.myscroll < 0.6}
class:bg-info={user.myscroll >= 0.6 && user.myscroll < 0.9}
class:bg-success={user.myscroll >= 0.9}
>
Avancement&nbsp;: {Math.trunc(user.myscroll*10000)/100}&nbsp;%
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}

View file

@ -0,0 +1,80 @@
<script context="module">
import { getSurvey } from '../../../lib/surveys';
export async function load({ params, stuff }) {
return {
props: {
surveyP: stuff.survey,
},
};
}
</script>
<script lang="ts">
import { goto } from '$app/navigation';
import { user } from '../../../stores/user';
import SurveyAdmin from '../../../components/SurveyAdmin.svelte';
import SurveyBadge from '../../../components/SurveyBadge.svelte';
import SurveyQuestions from '../../../components/SurveyQuestions.svelte';
import { getQuestions } from '../../../lib/questions';
export let surveyP;
$: {
if (surveyP) {
surveyP.then((survey) => {
if (survey.direct && !$user.is_admin) {
goto(`surveys/${survey.id}/live`);
}
})
}
}
let edit = false;
</script>
{#await surveyP then survey}
{#if $user && $user.is_admin}
<button class="btn btn-primary ms-1 float-end" on:click={() => { edit = !edit; } } title="Éditer"><i class="bi bi-pencil"></i></button>
<a href="surveys/{survey.id}/responses" class="btn btn-success ms-1 float-end" title="Voir les réponses"><i class="bi bi-files"></i></a>
{#if survey.direct}
<a href="surveys/{survey.id}/live" class="btn btn-danger ms-1 float-end" title="Aller au direct"><i class="bi bi-film"></i></a>
{/if}
{/if}
<div class="d-flex align-items-center">
<h2>
<a href="surveys/" class="text-muted" style="text-decoration: none">&lt;</a>
{survey.title}
</h2>
<SurveyBadge class="ms-2" {survey} />
</div>
{#if $user && $user.is_admin && edit}
<SurveyAdmin {survey} on:saved={() => edit = false} />
{/if}
{#await getQuestions(survey.id)}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des questions &hellip;</span>
</div>
{:then questions}
<SurveyQuestions {survey} {questions} />
{:catch error}
<div class="row mt-5">
<div class="d-none d-sm-block col-sm">
<hr>
</div>
<h3 class="col-sm-auto text-center text-muted mb-3"><label for="askquestion">Ce questionnaire n'est pas accessible</label></h3>
<div class="d-none d-sm-block col-sm">
<hr>
</div>
</div>
{#if survey.direct != null}
<div class="alert alert-warning">
<strong><a href="surveys/{survey.id}/live">Cliquez ici pour accéder au direct</a>.</strong> Il s'agit d'un questionnaire en direct, le questionnaire n'est pas accessible sur cette page.
</div>
{/if}
{/await}
{/await}

View file

@ -0,0 +1,302 @@
<script context="module">
export async function load({ params, stuff }) {
return {
props: {
surveyP: stuff.survey,
sid: params.sid,
},
};
}
</script>
<script>
import { onDestroy } from 'svelte';
import { user } from '../../../stores/user';
import { ToastsStore } from '../../../stores/toasts';
import SurveyBadge from '../../../components/SurveyBadge.svelte';
import QuestionForm from '../../../components/QuestionForm.svelte';
import { getQuestion } from '../../../lib/questions';
export let surveyP;
export let sid;
let survey;
surveyP.then((s) => survey = s);
let ws_up = false;
let show_question = null;
let value;
let req_question;
let nosend = false;
let timer_init = null;
let timer_end = null;
let timer = 0;
let timer_cancel = null;
function afterQUpdate(q) {
value = undefined;
if (q) {
q.getMyResponse().then((response) => {
if (response && response.value)
value = response.value;
})
}
}
$: {
if (show_question) {
req_question = getQuestion(show_question);
req_question.then(afterQUpdate);
}
}
function updTimer() {
const now = new Date().getTime();
if (now > timer_end) {
timer = 100;
clearInterval(timer_cancel);
timer_cancel = null;
} else {
const dist1 = timer_end - timer_init;
const dist2 = timer_end - now;
timer = Math.ceil(100-dist2*100/dist1);
}
}
let ws = null;
let autoreconnect = true;
onDestroy(() => {
autoreconnect = false;
console.log("destroy", ws)
if (ws) {
ws.close();
}
});
function wsconnect() {
ws = new WebSocket((window.location.protocol == 'https:'?'wss://':'ws://') + window.location.host + `/api/surveys/${sid}/ws`);
ws.addEventListener("open", () => {
ws_up = true;
});
ws.addEventListener("close", (e) => {
ws_up = false;
show_question = false;
console.log('Socket is closed. Reconnect will be attempted in 1 second.', e.reason, e);
if (autoreconnect && e.reason != "end")
setTimeout(function() {
wsconnect();
}, 1500);
});
ws.addEventListener("error", (err) => {
ws_up = false;
console.log('Socket closed due to error.', err.message);
});
ws.addEventListener("message", (message) => {
const data = JSON.parse(message.data);
if (data.action && data.action == "new_question") {
show_question = data.question;
survey.corrected = data.corrected;
if (data.corrected) {
corrections = data.corrections;
} else {
corrections = null;
}
if (timer_cancel) {
clearInterval(timer_cancel);
timer_cancel = null;
}
if (data.timer) {
timer_init = new Date().getTime();;
timer_end = timer_init + data.timer;
updTimer();
timer_cancel = setInterval(updTimer, 150);
} else {
timer_init = null;
}
} else if (data.action && data.action == "where_are_you") {
ws.send('{"action":"myscroll", "value": "' + (window.scrollY/window.scrollMaxY) +'", "question": '+show_question+', "corrected": '+(survey.corrected==true)+'}')
} else {
show_question = null;
if (timer_cancel) {
clearInterval(timer_cancel);
timer_cancel = null;
}
timer_init = null;
}
});
}
wsconnect();
let displaySendInProgress = false;
function sendValue() {
if (show_question && value && !nosend) {
displaySendInProgress = true;
survey.submitAnswers([{"id_question": show_question, "value": value}], $user.id_user).then((response) => {
setTimeout(() => displaySendInProgress = false, 150);
console.log("Vos réponses ont bien étés sauvegardées.");
}, (error) => {
displaySendInProgress = false;
value = null;
ToastsStore.addErrorToast({
msg: "Une erreur s'est produite durant l'envoi de vos réponses : " + error + "\nVeuillez réessayer dans quelques instants.",
});
});
}
}
let myQuestion = "";
let submitQuestionInProgress = false;
function askQuestion() {
if (!myQuestion) {
ToastsStore.addErrorToast({
msg: "Quel est ta question ?",
});
return;
}
submitQuestionInProgress = true;
fetch(`api/surveys/${survey.id}/ask`, {
method: 'POST',
headers: {'Accept': 'application/json'},
body: JSON.stringify({"content": myQuestion}),
}).then((r) => {
submitQuestionInProgress = false;
myQuestion = "";
ToastsStore.addToast({
msg: "Ta question a bien été envoyée.",
title: survey.title,
color: "success",
});
}, (error) => {
ToastsStore.addErrorToast({
msg: "Un problème est survenu : " + error.errmsg,
});
});
}
let corrections = null;
</script>
{#await surveyP then unused}
<div
style={"transition: opacity 150ms ease-out; opacity: " + (displaySendInProgress?1:0)}
class="ms-2 float-end"
>
<div style="position: relative; left: 25%; top: 4px">
<div style="position: absolute">
<i class="bi bi-save"></i>
</div>
</div>
<div class="spinner-border text-primary" role="status"></div>
</div>
{#if $user && $user.is_admin}
<a href="surveys/{survey.id}/admin" class="btn btn-primary ms-1 float-end" title="Aller à l'interface d'administration"><i class="bi bi-pencil"></i></a>
<a href="surveys/{survey.id}/responses" class="btn btn-success ms-1 float-end" title="Voir les réponses"><i class="bi bi-files"></i></a>
{/if}
<div class="d-flex align-items-center mb-3 mb-md-4 mb-lg-5">
<h2>
<a href="surveys/" class="text-muted" style="text-decoration: none">&lt;</a>
{survey.title}
</h2>
<div
class="badge rounded-pill ms-2"
class:bg-success={ws_up}
class:bg-danger={!ws_up}
>
{#if ws_up}Connecté{:else}Déconnecté{/if}
</div>
</div>
<form on:submit|preventDefault={sendValue}>
{#if show_question}
{#await req_question}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement d'une nouvelle question &hellip;</span>
</div>
{:then question}
<QuestionForm
{survey}
{question}
readonly={timer >= 100 || survey.corrected}
{corrections}
bind:value={value}
on:change={sendValue}
>
{#if timer_init}
<div class="progress" style="border-radius: 0; height: 4px">
<div class="progress-bar" class:bg-warning={timer > 85 && timer < 100} class:bg-danger={timer >= 100} role="progressbar" style="width: {timer}%"></div>
</div>
{/if}
</QuestionForm>
{#if question.kind != 'mcq' && question.kind != 'ucq' && question.kind != 'none'}
<button
class="btn btn-primary"
>
Soumettre cette réponse
</button>
{/if}
{/await}
{:else if ws_up}
<h2 class="text-center mb-4">
Pas de question actuellement.
</h2>
<form on:submit|preventDefault={askQuestion}>
<div class="row">
<div class="d-none d-sm-block col-sm">
<hr>
</div>
<h3 class="col-sm-auto text-center text-muted mb-3"><label for="askquestion">Vous avez une question&nbsp;?</label></h3>
<div class="d-none d-sm-block col-sm">
<hr>
</div>
</div>
<div class="row">
<div class="offset-md-1 col-md-10 offset-lg-2 col-lg-8 offset-xl-3 col-xl-6 mb-4">
<div class="input-group">
<textarea
id="askquestion"
class="form-control"
bind:value={myQuestion}
autofocus
placeholder="Remarques, soucis, choses pas claires? Levez la main ou écrivez ici!"
></textarea>
<button
class="d-sm-none btn btn-primary"
disabled={!myQuestion || submitQuestionInProgress}
>
{#if submitQuestionInProgress}
<div class="spinner-border spinner-border-sm me-1" role="status"></div>
{/if}
Poser cette question
</button>
</div>
</div>
</div>
{#if myQuestion}
<div class="d-none d-sm-block text-center mb-4">
<button
class="btn btn-primary"
disabled={submitQuestionInProgress}
>
{#if submitQuestionInProgress}
<div class="spinner-border spinner-border-sm me-1" role="status"></div>
{/if}
Poser cette question
</button>
</div>
{/if}
</form>
{:else}
<h2 class="text-center">
La session est terminée. <small class="text-muted">On se retrouve une prochaine fois&hellip;</small>
</h2>
{/if}
</form>
{/await}

Some files were not shown because too many files have changed in this diff Show more