Compare commits

...

9 commits

31 changed files with 963 additions and 187 deletions

View file

@ -1,9 +1,11 @@
package api package api
import ( import (
"bytes"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"path"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
@ -32,6 +34,8 @@ func declareExercicesRoutes(router *gin.RouterGroup) {
apiExercicesRoutes.PATCH("", partUpdateExercice) apiExercicesRoutes.PATCH("", partUpdateExercice)
apiExercicesRoutes.DELETE("", deleteExercice) apiExercicesRoutes.DELETE("", deleteExercice)
apiExercicesRoutes.POST("/diff-sync", APIDiffExerciceWithRemote)
apiExercicesRoutes.GET("/stats.json", getExerciceStats) apiExercicesRoutes.GET("/stats.json", getExerciceStats)
apiExercicesRoutes.GET("/history.json", getExerciceHistory) apiExercicesRoutes.GET("/history.json", getExerciceHistory)
@ -91,8 +95,8 @@ func declareExercicesRoutes(router *gin.RouterGroup) {
// Remote // Remote
router.GET("/remote/themes/:thid/exercices/:exid", sync.ApiGetRemoteExercice) router.GET("/remote/themes/:thid/exercices/:exid", sync.ApiGetRemoteExercice)
router.GET("/remote/themes/:thid/exercices/:exid/hints", sync.ApiGetRemoteExerciceHints)
router.GET("/remote/themes/:thid/exercices/:exid/flags", sync.ApiGetRemoteExerciceFlags) router.GET("/remote/themes/:thid/exercices/:exid/flags", sync.ApiGetRemoteExerciceFlags)
router.GET("/remote/themes/:thid/exercices/:exid/hints", sync.ApiGetRemoteExerciceHints)
} }
type Exercice struct { type Exercice struct {
@ -130,7 +134,7 @@ func ExerciceHandler(c *gin.Context) {
c.Set("theme", theme) c.Set("theme", theme)
} else { } else {
c.Set("theme", &fic.Theme{Path: sync.StandaloneExercicesDirectory}) c.Set("theme", &fic.StandaloneExercicesTheme)
} }
} }
@ -1275,3 +1279,343 @@ func updateExerciceTags(c *gin.Context) {
exercice.WipeTags() exercice.WipeTags()
addExerciceTag(c) addExerciceTag(c)
} }
type syncDiff struct {
Field string `json:"field"`
Link string `json:"link"`
Before interface{} `json:"be"`
After interface{} `json:"af"`
}
func diffExerciceWithRemote(exercice *fic.Exercice, theme *fic.Theme) ([]syncDiff, error) {
var diffs []syncDiff
// Compare exercice attributes
thid := exercice.Path[:strings.Index(exercice.Path, "/")]
exid := exercice.Path[strings.Index(exercice.Path, "/")+1:]
exercice_remote, err := sync.GetRemoteExercice(thid, exid, theme)
if err != nil {
return nil, err
}
for _, field := range reflect.VisibleFields(reflect.TypeOf(*exercice)) {
if ((field.Name == "Image") && path.Base(reflect.ValueOf(*exercice_remote).FieldByName(field.Name).String()) != path.Base(reflect.ValueOf(*exercice).FieldByName(field.Name).String())) || ((field.Name == "Depend") && (((exercice_remote.Depend == nil || exercice.Depend == nil) && exercice.Depend != exercice_remote.Depend) || (exercice_remote.Depend != nil && exercice.Depend != nil && *exercice.Depend != *exercice_remote.Depend))) || (field.Name != "Image" && field.Name != "Depend" && !reflect.ValueOf(*exercice_remote).FieldByName(field.Name).Equal(reflect.ValueOf(*exercice).FieldByName(field.Name))) {
if !field.IsExported() || field.Name == "Id" || field.Name == "IdTheme" || field.Name == "IssueKind" || field.Name == "Coefficient" || field.Name == "BackgroundColor" {
continue
}
diffs = append(diffs, syncDiff{
Field: field.Name,
Link: fmt.Sprintf("exercices/%d", exercice.Id),
Before: reflect.ValueOf(*exercice).FieldByName(field.Name).Interface(),
After: reflect.ValueOf(*exercice_remote).FieldByName(field.Name).Interface(),
})
}
}
// Compare files
files, err := exercice.GetFiles()
if err != nil {
return nil, fmt.Errorf("Unable to GetFiles: %w", err)
}
files_remote, err := sync.GetRemoteExerciceFiles(thid, exid)
if err != nil {
return nil, fmt.Errorf("Unable to GetRemoteFiles: %w", err)
}
for i, file_remote := range files_remote {
if len(files) <= i {
diffs = append(diffs, syncDiff{
Field: fmt.Sprintf("files[%d]", i),
Link: fmt.Sprintf("exercices/%d", exercice.Id),
Before: nil,
After: file_remote,
})
continue
}
for _, field := range reflect.VisibleFields(reflect.TypeOf(*file_remote)) {
if !field.IsExported() || field.Name == "Id" || field.Name == "IdExercice" {
continue
}
if ((field.Name == "Path") && path.Base(reflect.ValueOf(*file_remote).FieldByName(field.Name).String()) != path.Base(reflect.ValueOf(*files[i]).FieldByName(field.Name).String())) || ((field.Name == "Checksum" || field.Name == "ChecksumShown") && !bytes.Equal(reflect.ValueOf(*file_remote).FieldByName(field.Name).Bytes(), reflect.ValueOf(*files[i]).FieldByName(field.Name).Bytes())) || (field.Name != "Checksum" && field.Name != "ChecksumShown" && field.Name != "Path" && !reflect.ValueOf(*file_remote).FieldByName(field.Name).Equal(reflect.ValueOf(*files[i]).FieldByName(field.Name))) {
diffs = append(diffs, syncDiff{
Field: fmt.Sprintf("files[%d].%s", i, field.Name),
Link: fmt.Sprintf("exercices/%d", exercice.Id),
Before: reflect.ValueOf(*files[i]).FieldByName(field.Name).Interface(),
After: reflect.ValueOf(*file_remote).FieldByName(field.Name).Interface(),
})
}
}
}
// Compare flags
flags, err := exercice.GetFlags()
if err != nil {
return nil, fmt.Errorf("Unable to GetFlags: %w", err)
}
flags_remote, err := sync.GetRemoteExerciceFlags(thid, exid)
if err != nil {
return nil, fmt.Errorf("Unable to GetRemoteFlags: %w", err)
}
var flags_not_found []interface{}
var flags_extra_found []interface{}
for i, flag_remote := range flags_remote {
if key_remote, ok := flag_remote.(*fic.FlagKey); ok {
found := false
for _, flag := range flags {
if key, ok := flag.(*fic.FlagKey); ok && (key.Label == key_remote.Label || key.Order == key_remote.Order) {
found = true
// Parse flag label
if len(key.Label) > 3 && key.Label[0] == '%' {
spl := strings.Split(key.Label, "%")
key.Label = strings.Join(spl[2:], "%")
}
for _, field := range reflect.VisibleFields(reflect.TypeOf(*key_remote)) {
if !field.IsExported() || field.Name == "Id" || field.Name == "IdExercice" {
continue
}
if (field.Name == "Checksum" && !bytes.Equal(key.Checksum, key_remote.Checksum)) || (field.Name == "CaptureRegexp" && ((key.CaptureRegexp == nil || key_remote.CaptureRegexp == nil) && key.CaptureRegexp != key_remote.CaptureRegexp) || (key.CaptureRegexp != nil && key_remote.CaptureRegexp != nil && *key.CaptureRegexp != *key_remote.CaptureRegexp)) || (field.Name != "Checksum" && field.Name != "CaptureRegexp" && !reflect.ValueOf(*key_remote).FieldByName(field.Name).Equal(reflect.ValueOf(*key).FieldByName(field.Name))) {
diffs = append(diffs, syncDiff{
Field: fmt.Sprintf("flags[%d].%s", i, field.Name),
Link: fmt.Sprintf("exercices/%d/flags#flag-%d", exercice.Id, key.Id),
Before: reflect.ValueOf(*key).FieldByName(field.Name).Interface(),
After: reflect.ValueOf(*key_remote).FieldByName(field.Name).Interface(),
})
}
}
break
}
}
if !found {
flags_not_found = append(flags_not_found, key_remote)
}
} else if mcq_remote, ok := flag_remote.(*fic.MCQ); ok {
found := false
for _, flag := range flags {
if mcq, ok := flag.(*fic.MCQ); ok && (mcq.Title == mcq_remote.Title || mcq.Order == mcq_remote.Order) {
found = true
for _, field := range reflect.VisibleFields(reflect.TypeOf(*mcq_remote)) {
if !field.IsExported() || field.Name == "Id" || field.Name == "IdExercice" {
continue
}
if field.Name == "Entries" {
var not_found []*fic.MCQ_entry
var extra_found []*fic.MCQ_entry
for i, entry_remote := range mcq_remote.Entries {
found := false
for j, entry := range mcq.Entries {
if entry.Label == entry_remote.Label {
for _, field := range reflect.VisibleFields(reflect.TypeOf(*entry_remote)) {
if field.Name == "Id" {
continue
}
if !reflect.ValueOf(*entry_remote).FieldByName(field.Name).Equal(reflect.ValueOf(*entry).FieldByName(field.Name)) {
diffs = append(diffs, syncDiff{
Field: fmt.Sprintf("flags[%d].entries[%d].%s", i, j, field.Name),
Link: fmt.Sprintf("exercices/%d/flags#quiz-%d", exercice.Id, mcq.Id),
Before: reflect.ValueOf(*mcq.Entries[j]).FieldByName(field.Name).Interface(),
After: reflect.ValueOf(*entry_remote).FieldByName(field.Name).Interface(),
})
}
}
found = true
break
}
}
if !found {
not_found = append(not_found, entry_remote)
}
}
for _, entry := range mcq.Entries {
found := false
for _, entry_remote := range mcq_remote.Entries {
if entry.Label == entry_remote.Label {
found = true
break
}
}
if !found {
extra_found = append(extra_found, entry)
}
}
if len(not_found) > 0 || len(extra_found) > 0 {
diffs = append(diffs, syncDiff{
Field: fmt.Sprintf("flags[%d].entries", i, field.Name),
Link: fmt.Sprintf("exercices/%d/flags", exercice.Id),
Before: extra_found,
After: not_found,
})
}
} else if !reflect.ValueOf(*mcq_remote).FieldByName(field.Name).Equal(reflect.ValueOf(*mcq).FieldByName(field.Name)) {
diffs = append(diffs, syncDiff{
Field: fmt.Sprintf("flags[%d].%s", i, field.Name),
Link: fmt.Sprintf("exercices/%d/flags", exercice.Id),
Before: reflect.ValueOf(*mcq).FieldByName(field.Name).Interface(),
After: reflect.ValueOf(*mcq_remote).FieldByName(field.Name).Interface(),
})
}
}
break
}
}
if !found {
flags_not_found = append(flags_not_found, mcq_remote)
}
} else if label_remote, ok := flag_remote.(*fic.FlagLabel); ok {
found := false
for _, flag := range flags {
if label, ok := flag.(*fic.FlagLabel); ok && (label.Label == label_remote.Label || label.Order == label_remote.Order) {
found = true
for _, field := range reflect.VisibleFields(reflect.TypeOf(*label_remote)) {
if !field.IsExported() || field.Name == "Id" || field.Name == "IdExercice" {
continue
}
if !reflect.ValueOf(*label_remote).FieldByName(field.Name).Equal(reflect.ValueOf(*label).FieldByName(field.Name)) {
diffs = append(diffs, syncDiff{
Field: fmt.Sprintf("flags[%d].%s", i, field.Name),
Link: fmt.Sprintf("exercices/%d/flags#flag-%d", exercice.Id, label.Id),
Before: reflect.ValueOf(*label).FieldByName(field.Name).Interface(),
After: reflect.ValueOf(*label_remote).FieldByName(field.Name).Interface(),
})
}
}
break
}
}
if !found {
flags_not_found = append(flags_not_found, label_remote)
}
} else {
log.Printf("unknown flag type: %T", flag_remote)
}
}
for _, flag := range flags {
if key, ok := flag.(*fic.FlagKey); ok {
found := false
for _, flag_remote := range flags_remote {
if key_remote, ok := flag_remote.(*fic.FlagKey); ok && (key.Label == key_remote.Label || key.Order == key_remote.Order) {
found = true
break
}
}
if !found {
flags_extra_found = append(flags_extra_found, flag)
}
} else if mcq, ok := flag.(*fic.MCQ); ok {
found := false
for _, flag_remote := range flags_remote {
if mcq_remote, ok := flag_remote.(*fic.MCQ); ok && (mcq.Title == mcq_remote.Title || mcq.Order == mcq_remote.Order) {
found = true
break
}
}
if !found {
flags_extra_found = append(flags_extra_found, flag)
}
} else if label, ok := flag.(*fic.FlagLabel); ok {
found := false
for _, flag_remote := range flags_remote {
if label_remote, ok := flag_remote.(*fic.FlagLabel); ok && (label.Label == label_remote.Label || label.Order == label_remote.Order) {
found = true
break
}
}
if !found {
flags_extra_found = append(flags_extra_found, flag)
}
}
}
if len(flags_not_found) > 0 || len(flags_extra_found) > 0 {
diffs = append(diffs, syncDiff{
Field: "flags",
Link: fmt.Sprintf("exercices/%d/flags", exercice.Id),
Before: flags_extra_found,
After: flags_not_found,
})
}
// Compare hints
hints, err := exercice.GetHints()
if err != nil {
return nil, fmt.Errorf("Unable to GetHints: %w", err)
}
hints_remote, err := sync.GetRemoteExerciceHints(thid, exid)
if err != nil {
return nil, fmt.Errorf("Unable to GetRemoteHints: %w", err)
}
for i, hint_remote := range hints_remote {
hint_remote.Hint.TreatHintContent()
for _, field := range reflect.VisibleFields(reflect.TypeOf(*hint_remote.Hint)) {
if !field.IsExported() || field.Name == "Id" || field.Name == "IdExercice" {
continue
}
if len(hints) <= i {
diffs = append(diffs, syncDiff{
Field: fmt.Sprintf("hints[%d].%s", i, field.Name),
Link: fmt.Sprintf("exercices/%d", exercice.Id),
Before: nil,
After: reflect.ValueOf(*hint_remote.Hint).FieldByName(field.Name).Interface(),
})
} else if !reflect.ValueOf(*hint_remote.Hint).FieldByName(field.Name).Equal(reflect.ValueOf(*hints[i]).FieldByName(field.Name)) {
diffs = append(diffs, syncDiff{
Field: fmt.Sprintf("hints[%d].%s", i, field.Name),
Link: fmt.Sprintf("exercices/%d", exercice.Id),
Before: reflect.ValueOf(*hints[i]).FieldByName(field.Name).Interface(),
After: reflect.ValueOf(*hint_remote.Hint).FieldByName(field.Name).Interface(),
})
}
}
}
return diffs, err
}
func APIDiffExerciceWithRemote(c *gin.Context) {
theme := c.MustGet("theme").(*fic.Theme)
exercice := c.MustGet("exercice").(*fic.Exercice)
diffs, err := diffExerciceWithRemote(exercice, theme)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, diffs)
}

View file

@ -310,6 +310,7 @@ func ApplySettings(config *settings.Settings) {
fic.SubmissionCostBase = config.SubmissionCostBase fic.SubmissionCostBase = config.SubmissionCostBase
fic.SubmissionUniqueness = config.SubmissionUniqueness fic.SubmissionUniqueness = config.SubmissionUniqueness
fic.CountOnlyNotGoodTries = config.CountOnlyNotGoodTries fic.CountOnlyNotGoodTries = config.CountOnlyNotGoodTries
fic.QuestionGainRatio = config.QuestionGainRatio
if config.DiscountedFactor != fic.DiscountedFactor { if config.DiscountedFactor != fic.DiscountedFactor {
fic.DiscountedFactor = config.DiscountedFactor fic.DiscountedFactor = config.DiscountedFactor
@ -329,6 +330,7 @@ func ResetSettings() error {
WChoiceCurCoefficient: 1, WChoiceCurCoefficient: 1,
GlobalScoreCoefficient: 1, GlobalScoreCoefficient: 1,
DiscountedFactor: 0, DiscountedFactor: 0,
QuestionGainRatio: 0,
UnlockedStandaloneExercices: 10, UnlockedStandaloneExercices: 10,
UnlockedStandaloneExercicesByThemeStepValidation: 1, UnlockedStandaloneExercicesByThemeStepValidation: 1,
UnlockedStandaloneExercicesByStandaloneExerciceValidation: 0, UnlockedStandaloneExercicesByStandaloneExerciceValidation: 0,

View file

@ -79,12 +79,14 @@ func declareSyncRoutes(router *gin.RouterGroup) {
c.JSON(http.StatusOK, r) c.JSON(http.StatusOK, r)
}) })
apiSyncRoutes.POST("/local-diff", APIDiffDBWithRemote)
apiSyncDeepRoutes := apiSyncRoutes.Group("/deep/:thid") apiSyncDeepRoutes := apiSyncRoutes.Group("/deep/:thid")
apiSyncDeepRoutes.Use(ThemeHandler) apiSyncDeepRoutes.Use(ThemeHandler)
// Special route to handle standalone exercices // Special route to handle standalone exercices
apiSyncRoutes.POST("/deep/0", func(c *gin.Context) { apiSyncRoutes.POST("/deep/0", func(c *gin.Context) {
var st []string var st []string
for _, se := range multierr.Errors(sync.SyncThemeDeep(sync.GlobalImporter, &fic.Theme{Path: sync.StandaloneExercicesDirectory}, 0, 250, nil)) { for _, se := range multierr.Errors(sync.SyncThemeDeep(sync.GlobalImporter, &fic.StandaloneExercicesTheme, 0, 250, nil)) {
st = append(st, se.Error()) st = append(st, se.Error())
} }
sync.EditDeepReport(&sync.SyncReport{Exercices: st}, false) sync.EditDeepReport(&sync.SyncReport{Exercices: st}, false)
@ -378,3 +380,32 @@ func autoSync(c *gin.Context) {
c.JSON(http.StatusOK, st) c.JSON(http.StatusOK, st)
} }
func diffDBWithRemote() (map[string][]syncDiff, error) {
diffs := map[string][]syncDiff{}
themes, err := fic.GetThemesExtended()
if err != nil {
return nil, err
}
// Compare inner themes
for _, theme := range themes {
diffs[theme.Name], err = diffThemeWithRemote(theme)
if err != nil {
return nil, fmt.Errorf("Unable to diffThemeWithRemote: %w", err)
}
}
return diffs, err
}
func APIDiffDBWithRemote(c *gin.Context) {
diffs, err := diffDBWithRemote()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, diffs)
}

View file

@ -5,7 +5,9 @@ import (
"log" "log"
"net/http" "net/http"
"path" "path"
"reflect"
"strconv" "strconv"
"strings"
"srs.epita.fr/fic-server/admin/sync" "srs.epita.fr/fic-server/admin/sync"
"srs.epita.fr/fic-server/libfic" "srs.epita.fr/fic-server/libfic"
@ -48,6 +50,8 @@ func declareThemesRoutes(router *gin.RouterGroup) {
apiThemesRoutes.PUT("", updateTheme) apiThemesRoutes.PUT("", updateTheme)
apiThemesRoutes.DELETE("", deleteTheme) apiThemesRoutes.DELETE("", deleteTheme)
apiThemesRoutes.POST("/diff-sync", APIDiffThemeWithRemote)
apiThemesRoutes.GET("/exercices_stats.json", getThemedExercicesStats) apiThemesRoutes.GET("/exercices_stats.json", getThemedExercicesStats)
declareExercicesRoutes(apiThemesRoutes) declareExercicesRoutes(apiThemesRoutes)
@ -70,6 +74,9 @@ func ThemeHandler(c *gin.Context) {
return return
} }
if thid == 0 {
c.Set("theme", &fic.StandaloneExercicesTheme)
} else {
theme, err := fic.GetTheme(thid) theme, err := fic.GetTheme(thid)
if err != nil { if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Theme not found"}) c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Theme not found"})
@ -77,6 +84,7 @@ func ThemeHandler(c *gin.Context) {
} }
c.Set("theme", theme) c.Set("theme", theme)
}
c.Next() c.Next()
} }
@ -127,6 +135,10 @@ func listThemes(c *gin.Context) {
return return
} }
if has, _ := fic.HasStandaloneExercice(); has {
themes = append([]*fic.Theme{&fic.StandaloneExercicesTheme}, themes...)
}
c.JSON(http.StatusOK, themes) c.JSON(http.StatusOK, themes)
} }
@ -255,3 +267,110 @@ func getThemedExercicesStats(c *gin.Context) {
} }
c.JSON(http.StatusOK, ret) c.JSON(http.StatusOK, ret)
} }
func diffThemeWithRemote(theme *fic.Theme) ([]syncDiff, error) {
var diffs []syncDiff
// Compare theme attributes
theme_remote, err := sync.GetRemoteTheme(theme.Path)
if err != nil {
return nil, err
}
for _, field := range reflect.VisibleFields(reflect.TypeOf(*theme)) {
if ((field.Name == "Image") && path.Base(reflect.ValueOf(*theme_remote).FieldByName(field.Name).String()) != path.Base(reflect.ValueOf(*theme).FieldByName(field.Name).String())) || (field.Name != "Image" && !reflect.ValueOf(*theme_remote).FieldByName(field.Name).Equal(reflect.ValueOf(*theme).FieldByName(field.Name))) {
if !field.IsExported() || field.Name == "Id" || field.Name == "IdTheme" || field.Name == "IssueKind" || field.Name == "BackgroundColor" {
continue
}
diffs = append(diffs, syncDiff{
Field: field.Name,
Link: fmt.Sprintf("themes/%d", theme.Id),
Before: reflect.ValueOf(*theme).FieldByName(field.Name).Interface(),
After: reflect.ValueOf(*theme_remote).FieldByName(field.Name).Interface(),
})
}
}
// Compare exercices list
exercices, err := theme.GetExercices()
if err != nil {
return nil, fmt.Errorf("Unable to GetExercices: %w", err)
}
exercices_remote, err := sync.ListRemoteExercices(theme.Path)
if err != nil {
return nil, fmt.Errorf("Unable to ListRemoteExercices: %w", err)
}
var not_found []string
var extra_found []string
for _, exercice_remote := range exercices_remote {
found := false
for _, exercice := range exercices {
if exercice.Path[strings.Index(exercice.Path, "/")+1:] == exercice_remote {
found = true
break
}
}
if !found {
not_found = append(not_found, exercice_remote)
}
}
for _, exercice := range exercices {
found := false
for _, exercice_remote := range exercices_remote {
if exercice.Path[strings.Index(exercice.Path, "/")+1:] == exercice_remote {
found = true
break
}
}
if !found {
extra_found = append(extra_found, exercice.Path[strings.Index(exercice.Path, "/")+1:])
}
}
if len(not_found) > 0 || len(extra_found) > 0 {
diffs = append(diffs, syncDiff{
Field: "theme.Exercices",
Link: fmt.Sprintf("themes/%d", theme.Id),
Before: strings.Join(extra_found, ", "),
After: strings.Join(not_found, ", "),
})
}
// Compare inner exercices
for i, exercice := range exercices {
exdiffs, err := diffExerciceWithRemote(exercice, theme)
if err != nil {
return nil, fmt.Errorf("Unable to diffExerciceWithRemote: %w", err)
}
for _, exdiff := range exdiffs {
if theme.Id == 0 {
exdiff.Field = fmt.Sprintf("exercices[%d].%s", exercice.Id, exdiff.Field)
} else {
exdiff.Field = fmt.Sprintf("exercices[%d].%s", i, exdiff.Field)
}
diffs = append(diffs, exdiff)
}
}
return diffs, err
}
func APIDiffThemeWithRemote(c *gin.Context) {
theme := c.MustGet("theme").(*fic.Theme)
diffs, err := diffThemeWithRemote(theme)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, diffs)
}

View file

@ -199,10 +199,10 @@ func main() {
} }
log.Println("Using", sync.GlobalImporter.Kind()) log.Println("Using", sync.GlobalImporter.Kind())
// Update distributed challenge.json
if _, err := os.Stat(path.Join(settings.SettingsDir, settings.ChallengeFile)); os.IsNotExist(err) {
challengeinfo, err := sync.GetFileContent(sync.GlobalImporter, settings.ChallengeFile) challengeinfo, err := sync.GetFileContent(sync.GlobalImporter, settings.ChallengeFile)
if err == nil { if err == nil {
// Initial distribution of challenge.json
if _, err := os.Stat(path.Join(settings.SettingsDir, settings.ChallengeFile)); os.IsNotExist(err) {
if fd, err := os.Create(path.Join(settings.SettingsDir, settings.ChallengeFile)); err != nil { if fd, err := os.Create(path.Join(settings.SettingsDir, settings.ChallengeFile)); err != nil {
log.Fatal("Unable to open SETTINGS/challenge.json:", err) log.Fatal("Unable to open SETTINGS/challenge.json:", err)
} else { } else {
@ -213,6 +213,10 @@ func main() {
} }
} }
} }
if ci, err := settings.ReadChallengeInfo(challengeinfo); err == nil {
fic.StandaloneExercicesTheme.Authors = ci.Authors
}
} }
} }

View file

@ -127,7 +127,6 @@ func declareStaticRoutes(router *gin.RouterGroup, cfg *settings.Settings, baseUR
if st, err := os.Stat(filepath); os.IsNotExist(err) || st.Size() == 0 { if st, err := os.Stat(filepath); os.IsNotExist(err) || st.Size() == 0 {
if st, err := os.Stat(filepath + ".gz"); err == nil { if st, err := os.Stat(filepath + ".gz"); err == nil {
if fd, err := os.Open(filepath + ".gz"); err == nil { if fd, err := os.Open(filepath + ".gz"); err == nil {
log.Println(filepath + ".gz")
c.DataFromReader(http.StatusOK, st.Size(), "application/octet-stream", fd, map[string]string{ c.DataFromReader(http.StatusOK, st.Size(), "application/octet-stream", fd, map[string]string{
"Content-Encoding": "gzip", "Content-Encoding": "gzip",
}) })
@ -136,7 +135,6 @@ func declareStaticRoutes(router *gin.RouterGroup, cfg *settings.Settings, baseUR
} }
} }
log.Println(filepath)
c.File(filepath) c.File(filepath)
}) })
router.GET("/submissions/*_", func(c *gin.Context) { router.GET("/submissions/*_", func(c *gin.Context) {

View file

@ -923,6 +923,21 @@ angular.module("FICApp")
}); });
}); });
}; };
$scope.diffWithRepo = function () {
$scope.diff = null;
$http({
url: "api/sync/local-diff",
method: "POST"
}).then(function (response) {
$scope.diff = response.data;
if (response.data === null) {
$scope.addToast('success', 'Changements par rapport au dépôt', "Tout est pareil !");
}
}, function (response) {
$scope.diff = null;
$scope.addToast('danger', 'An error occurs when synchronizing exercice:', response.data.errmsg);
});
};
}) })
.controller("AuthController", function ($scope, $http) { .controller("AuthController", function ($scope, $http) {
@ -1819,6 +1834,21 @@ angular.module("FICApp")
$scope.addToast('danger', 'An error occurs when trying to delete theme:', response.data.errmsg); $scope.addToast('danger', 'An error occurs when trying to delete theme:', response.data.errmsg);
}); });
} }
$scope.checkExoSync = function () {
$scope.diff = null;
$http({
url: "api/themes/" + $scope.theme.id + "/diff-sync",
method: "POST"
}).then(function (response) {
$scope.diff = response.data;
if (response.data === null) {
$scope.addToast('success', 'Changements par rapport au dépôt', "Tout est pareil !");
}
}, function (response) {
$scope.diff = null;
$scope.addToast('danger', 'An error occurs when synchronizing exercice:', response.data.errmsg);
});
};
}) })
.controller("TagsListController", function ($scope, $http) { .controller("TagsListController", function ($scope, $http) {
@ -1998,6 +2028,21 @@ angular.module("FICApp")
$scope.addToast('danger', 'An error occurs when synchronizing exercice:', response.data.errmsg); $scope.addToast('danger', 'An error occurs when synchronizing exercice:', response.data.errmsg);
}); });
}; };
$scope.checkExoSync = function () {
$scope.diff = null;
$http({
url: ($scope.exercice.id_theme ? ("api/themes/" + $scope.exercice.id_theme + "/exercices/" + $routeParams.exerciceId) : ("api/exercices/" + $routeParams.exerciceId)) + "/diff-sync",
method: "POST"
}).then(function (response) {
$scope.diff = response.data;
if (response.data === null) {
$scope.addToast('success', 'Changements par rapport au dépôt', "Tout est pareil !");
}
}, function (response) {
$scope.diff = null;
$scope.addToast('danger', 'An error occurs when synchronizing exercice:', response.data.errmsg);
});
};
$scope.deleteExercice = function () { $scope.deleteExercice = function () {
var tid = $scope.exercice.id_theme; var tid = $scope.exercice.id_theme;

View file

@ -10,11 +10,27 @@
<div class="ml-auto d-flex flex-row-reverse text-nowrap"> <div class="ml-auto d-flex flex-row-reverse text-nowrap">
<a href="exercices/{{exercice.id}}/resolution" ng-disabled="!exercice.videoURI" class="ml-2 btn btn-sm btn-info"><span class="glyphicon glyphicon-facetime-video" aria-hidden="true"></span> Vidéo</a> <a href="exercices/{{exercice.id}}/resolution" ng-disabled="!exercice.videoURI" class="ml-2 btn btn-sm btn-info"><span class="glyphicon glyphicon-facetime-video" aria-hidden="true"></span> Vidéo</a>
<a href="exercices/{{exercice.id}}/flags" class="ml-2 btn btn-sm btn-success"><span class="glyphicon glyphicon-flag" aria-hidden="true"></span> Flags</a> <a href="exercices/{{exercice.id}}/flags" class="ml-2 btn btn-sm btn-success"><span class="glyphicon glyphicon-flag" aria-hidden="true"></span> Flags</a>
<button type="button" ng-click="syncExo()" ng-class="{'disabled': inSync}" class="ml-2 btn btn-sm btn-light"><span class="glyphicon glyphicon-refresh" aria-hidden="true"></span> Synchroniser</button> <div class="btn-group ml-2" role="group">
<button type="button" ng-click="syncExo()" ng-class="{'disabled': inSync}" class="btn btn-sm btn-light"><span class="glyphicon glyphicon-refresh" aria-hidden="true"></span> Synchroniser</button>
<button type="button" ng-click="checkExoSync()" ng-class="{'disabled': inSync}" class="btn btn-sm btn-light" title="Exporter l'exercice actuel"><span class="glyphicon glyphicon-thumbs-up" aria-hidden="true"></span></button>
</div>
<a href="{{exercice.forge_link}}" target="_blank" class="ml-2 btn btn-sm btn-dark" ng-if="exercice.forge_link"><span class="glyphicon glyphicon-folder-open" aria-hidden="true"></span> Voir sur la forge</a> <a href="{{exercice.forge_link}}" target="_blank" class="ml-2 btn btn-sm btn-dark" ng-if="exercice.forge_link"><span class="glyphicon glyphicon-folder-open" aria-hidden="true"></span> Voir sur la forge</a>
</div> </div>
</div> </div>
<div ng-if="diff">
<h3>Différences par rapport au dépôt</h3>
<div ng-repeat="diffline in diff" class="row">
<a ng-href="{{ diffline.link }}" class="col-2 d-flex justify-content-end align-items-center text-monospace" title="{{ diffline.field }}">{{ diffline.field }}</a>
<div class="col">
<div class="text-danger"><span class="text-monospace">-</span>{{ diffline.be }}</div>
<div class="text-success"><span class="text-monospace">+</span>{{ diffline.af }}</div>
</div>
</div>
</div>
<hr ng-if="diff" class="my-3">
<div class="row mb-5"> <div class="row mb-5">
<form class="col-md-8" ng-submit="saveExercice()"> <form class="col-md-8" ng-submit="saveExercice()">

View file

@ -64,46 +64,60 @@
<hr> <hr>
<div class="form-group row"> <div class="form-group row">
<label for="globalScoreCoefficient" class="col-sm-2 col-form-label col-form-label-sm" ng-class="{'text-primary': config.globalScoreCoefficient != dist_config.globalScoreCoefficient}"><strong>Coefficients</strong></label> <div class="col-sm row">
<div class="col-sm-1"> <label for="globalScoreCoefficient" class="col-form-label col-form-label-sm" ng-class="{'text-primary': config.globalScoreCoefficient != dist_config.globalScoreCoefficient}"><strong>Coefficients</strong></label>
<div class="col">
<input type="text" class="form-control form-control-sm" id="globalScoreCoefficient" ng-model="config.globalScoreCoefficient" float title="Coefficient multiplicateur global du score final (le coefficient est appliqué dans la fonction et vaut pour tout le challenge, présent/passé/futur, sans effet de bord)" ng-class="{'border-primary': config.globalScoreCoefficient != dist_config.globalScoreCoefficient}"> <input type="text" class="form-control form-control-sm" id="globalScoreCoefficient" ng-model="config.globalScoreCoefficient" float title="Coefficient multiplicateur global du score final (le coefficient est appliqué dans la fonction et vaut pour tout le challenge, présent/passé/futur, sans effet de bord)" ng-class="{'border-primary': config.globalScoreCoefficient != dist_config.globalScoreCoefficient}">
</div> </div>
</div>
<label for="hintcoefficient" class="col-sm-2 col-form-label col-form-label-sm text-right" ng-class="{'text-primary font-weight-bold': config.hintCurrentCoefficient != dist_config.hintCurrentCoefficient}">incides</label> <div class="col-sm row">
<div class="col-sm-1"> <label for="hintcoefficient" class="col-form-label col-form-label-sm text-right" ng-class="{'text-primary font-weight-bold': config.hintCurrentCoefficient != dist_config.hintCurrentCoefficient}">incides</label>
<div class="col">
<input type="text" class="form-control form-control-sm" id="hintcoefficient" ng-model="config.hintCurrentCoefficient" float title="Coefficient multiplicateur temporaire du nombre de points que fait perdre un indice (le coefficient est enregistré au moment où l'équipe demande un indice, ce n'est pas global)" ng-class="{'border-primary': config.hintCurrentCoefficient != dist_config.hintCurrentCoefficient}"> <input type="text" class="form-control form-control-sm" id="hintcoefficient" ng-model="config.hintCurrentCoefficient" float title="Coefficient multiplicateur temporaire du nombre de points que fait perdre un indice (le coefficient est enregistré au moment où l'équipe demande un indice, ce n'est pas global)" ng-class="{'border-primary': config.hintCurrentCoefficient != dist_config.hintCurrentCoefficient}">
</div> </div>
<label for="wchoicescoefficient" class="col-sm-2 col-form-label col-form-label-sm text-right" ng-class="{'text-primary font-weight-bold': config.wchoiceCurrentCoefficient != dist_config.wchoiceCurrentCoefficient}">WChoices</label>
<div class="col-sm-1">
<input type="text" class="form-control form-control-sm" id="wchoicescoefficient" ng-model="config.wchoiceCurrentCoefficient" float title="Coefficient multiplicateur temporaire du nombre de points que fait perdre une demande de liste de choix (le coefficient est enregistré au moment où l'équipe demande la liste de choix, ce n'est pas global)" ng-class="{'border-primary': config.wchoiceCurrentCoefficient != dist_config.wchoiceCurrentCoefficient}">
</div> </div>
<label for="exercicecurcoefficient" class="col-sm-2 col-form-label col-form-label-sm text-right" ng-class="{'text-primary font-weight-bold': config.exerciceCurrentCoefficient != dist_config.exerciceCurrentCoefficient}">défis</label> <div class="col-sm row">
<div class="col-sm-1"> <label for="wchoicescoefficient" class="col-form-label col-form-label-sm text-right" ng-class="{'text-primary font-weight-bold': config.wchoiceCurrentCoefficient != dist_config.wchoiceCurrentCoefficient}">WChoices</label>
<div class="col">
<input type="text" class="form-control form-control-sm" id="wchoicescoefficient" ng-model="config.wchoiceCurrentCoefficient" float title="Coefficient multiplicateur temporaire du nombre de points que fait perdre une demande de liste de choix (le coefficient est enregistré au moment où l'équipe demande la liste de choix, ce n'est pas global)" ng-class="{'border-primary': config.wchoiceCurrentCoefficient != dist_config.wchoiceCurrentCoefficient}">
</div>
</div>
<div class="col-sm row">
<label for="exercicecurcoefficient" class="col-form-label col-form-label-sm text-right" ng-class="{'text-primary font-weight-bold': config.exerciceCurrentCoefficient != dist_config.exerciceCurrentCoefficient}">défis</label>
<div class="col">
<input type="text" class="form-control form-control-sm" id="exercicecurcoefficient" ng-model="config.exerciceCurrentCoefficient" float title="Coefficient multiplicateur temporaire du nombre de points que fait gagner un exercice validé (le coefficient est enregistré au moment où l'équipe valide l'exercice, ce n'est pas global)" ng-class="{'border-primary': config.exerciceCurrentCoefficient != dist_config.exerciceCurrentCoefficient}"> <input type="text" class="form-control form-control-sm" id="exercicecurcoefficient" ng-model="config.exerciceCurrentCoefficient" float title="Coefficient multiplicateur temporaire du nombre de points que fait gagner un exercice validé (le coefficient est enregistré au moment où l'équipe valide l'exercice, ce n'est pas global)" ng-class="{'border-primary': config.exerciceCurrentCoefficient != dist_config.exerciceCurrentCoefficient}">
</div> </div>
</div> </div>
</div>
<div class="form-group row"> <div class="form-group row" title="Attribuer ce pourcentage de points bonus supplémentaire à la première équipe qui valide un exercice">
<div class="col-sm row"> <div class="col-sm row">
<label for="firstBlood" class="col-sm-8 col-form-label col-form-label-sm" ng-class="{'text-primary font-weight-bold': config.firstBlood != dist_config.firstBlood}">Bonus premier sang</label> <label for="firstBlood" class="col-sm-8 col-form-label col-form-label-sm" ng-class="{'text-primary font-weight-bold': config.firstBlood != dist_config.firstBlood}">Premier sang</label>
<div class="col-sm-4"> <div class="col-sm-4">
<input type="text" class="form-control form-control-sm" id="firstBlood" ng-model="config.firstBlood" float ng-class="{'border-primary': config.firstBlood != dist_config.firstBlood}"> <input type="text" class="form-control form-control-sm" id="firstBlood" ng-model="config.firstBlood" float ng-class="{'border-primary': config.firstBlood != dist_config.firstBlood}">
</div> </div>
</div> </div>
<div class="col-sm row"> <div class="col-sm row" title="Pour chaque validation supplémentaire d'un exercice donné, on retire ce pourcentage de points à l'exercice. Les points rapportés par un exercice sont alors dynamiques : ils baissent pour toutes les équipes y compris celles ayant validé cet exercice il y a longtemps.">
<label for="discountFactor" class="col-sm-8 col-form-label col-form-label-sm" ng-class="{'text-primary font-weight-bold': config.discountedFactor != dist_config.discountedFactor}">Décote des exercices</label> <label for="discountFactor" class="col-sm-8 col-form-label col-form-label-sm text-truncate" ng-class="{'text-primary font-weight-bold': config.discountedFactor != dist_config.discountedFactor}">Décote exercices</label>
<div class="col-sm-4"> <div class="col-sm-4">
<input type="text" class="form-control form-control-sm" id="discountFactor" ng-model="config.discountedFactor" float ng-class="{'border-primary': config.discountedFactor != dist_config.discountedFactor}"> <input type="text" class="form-control form-control-sm" id="discountFactor" ng-model="config.discountedFactor" float ng-class="{'border-primary': config.discountedFactor != dist_config.discountedFactor}">
</div> </div>
</div> </div>
<div class="col-sm row"> <div class="col-sm row" title="Coefficient de base retiré pour chaque soumission invalide au delà de 10 soumissions">
<label for="submissionCostBase" class="col-sm-8 col-form-label col-form-label-sm text-right" ng-class="{'text-primary font-weight-bold': config.submissionCostBase != dist_config.submissionCostBase}">Coût de base tentative</label> <label for="submissionCostBase" class="col-sm-8 col-form-label col-form-label-sm text-right" ng-class="{'text-primary font-weight-bold': config.submissionCostBase != dist_config.submissionCostBase}">Coût tentative</label>
<div class="col-sm-4"> <div class="col-sm-4">
<input type="text" class="form-control form-control-sm" id="submissionCostBase" ng-model="config.submissionCostBase" float ng-class="{'border-primary': config.submissionCostBase != dist_config.submissionCostBase}"> <input type="text" class="form-control form-control-sm" id="submissionCostBase" ng-model="config.submissionCostBase" float ng-class="{'border-primary': config.submissionCostBase != dist_config.submissionCostBase}">
</div> </div>
</div> </div>
<div class="col-sm row" title="Accorder des points aux exercices partiellement résolu, par questions validée. Ce champ est le pourcentage de points que peut rapporter la complétion de toutes les questions d'un exercice. Par exemple avec 25%, un exercice avec 10 questions, chaque question validée rapportera GAIN * 25% / 10">
<label for="questionGainRatio" class="col-sm-8 col-form-label col-form-label-sm text-right text-truncate" ng-class="{'text-primary font-weight-bold': config.questionGainRatio != dist_config.questionGainRatio}">Gain par questions</label>
<div class="col-sm-4">
<input type="text" class="form-control form-control-sm" id="questionGainRatio" ng-model="config.questionGainRatio" float ng-class="{'border-primary': config.questionGainRatio != dist_config.questionGainRatio}">
</div>
</div>
</div> </div>
<hr> <hr>

View file

@ -120,3 +120,41 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class="card mb-5" ng-controller="ThemesListController">
<div class="card-header">
<button type="button" class="btn btn-primary float-right mx-1" ng-click="diffWithRepo()" title="Calculer les différences avec le dépôt">
<span class="glyphicon glyphicon-refresh" aria-hidden="true"></span>
</button>
<h3 class="mb-0">
Différences avec le dépôts
</h3>
</div>
<div class="card-body" ng-if="!diff">
<div class="alert alert-info">Lancez la génération du rapport pour lister les différences.</div>
</div>
<div ng-repeat="(th, lines) in diff" class="card-body" ng-if="diff">
<div class="d-flex">
<h3>
{{ th }}
</h3>
<div class="d-inline-block" ng-repeat="theme in themes" ng-if="theme.name == th">
<a href="themes/{{ theme.id }}" class="btn btn-link" title="Voir le thème">
<span class="glyphicon glyphicon-hand-right" aria-hidden="true"></span>
</a>
<button class="btn btn-light" title="Resynchroniser uniquement ce thème" ng-click="deepSync(theme)" ng-if="settings.wip || !timeProgression || displayDangerousActions">
<span class="glyphicon glyphicon-hdd" aria-hidden="true"></span>
</button>
</div>
</div>
<ul>
<li ng-repeat="diffline in lines" class="row">
<a ng-href="{{ diffline.link }}" class="col-2 d-flex align-items-center text-truncate text-monospace" title="{{ diffline.field }}">{{ diffline.field }}</a>
<div class="col">
<div class="text-danger"><span class="text-monospace">-</span>{{ diffline.be }}</div>
<div class="text-success"><span class="text-monospace">+</span>{{ diffline.af }}</div>
</div>
</li>
</ul>
</div>
</div>

View file

@ -13,7 +13,7 @@
<th>Date</th> <th>Date</th>
</thead> </thead>
<tbody> <tbody>
<tr ng-repeat="row in scores | filter: query | orderBy:'time'" ng-class="{'bg-danger': row.reason == 'Bonus flag', 'bg-ffound': row.reason == 'First blood', 'bg-wchoices': row.reason == 'Display choices', 'bg-success': row.reason == 'Validation', 'bg-info': row.reason == 'Hint', 'bg-warning': row.reason == 'Tries'}"> <tr ng-repeat="row in scores | filter: query | orderBy:'time'" ng-class="{'bg-danger': row.reason == 'Bonus flag', 'bg-ffound': row.reason == 'First blood', 'bg-wchoices': row.reason == 'Display choices', 'bg-success': row.reason == 'Validation', 'bg-info': row.reason == 'Hint', 'bg-secondary': row.reason.startsWith('Response '), 'bg-warning': row.reason == 'Tries'}">
<td> <td>
<a ng-repeat="exercice in exercices" ng-if="exercice.id == row.id_exercice" href="exercices/{{ row.id_exercice }}">{{ exercice.title }}</a> <a ng-repeat="exercice in exercices" ng-if="exercice.id == row.id_exercice" href="exercices/{{ row.id_exercice }}">{{ exercice.title }}</a>
</td> </td>
@ -23,9 +23,12 @@
<td> <td>
{{ row.points * row.coeff }} {{ row.points * row.coeff }}
</td> </td>
<td> <td ng-if="!row.reason.startsWith('Response ')">
{{ row.points }} * {{ row.coeff }} {{ row.points }} * {{ row.coeff }}
</td> </td>
<td ng-if="row.reason.startsWith('Response ')">
{{ row.points }} * {{ settings.questionGainRatio }} / {{ settings.questionGainRatio / row.coeff }}
</td>
<td> <td>
<nobr title="{{ row.time }}">{{ row.time | date:"mediumTime" }}</nobr> <nobr title="{{ row.time }}">{{ row.time | date:"mediumTime" }}</nobr>
</td> </td>

View file

@ -7,8 +7,21 @@
</div> </div>
</div> </div>
<div ng-if="diff">
<h3>Différences par rapport au dépôt</h3>
<div ng-repeat="diffline in diff" class="row">
<a ng-href="{{ diffline.link }}" class="col-3 d-flex align-items-center text-truncate text-monospace" title="{{ diffline.field }}">{{ diffline.field }}</a>
<div class="col">
<div class="text-danger"><span class="text-monospace">-</span>{{ diffline.be }}</div>
<div class="text-success"><span class="text-monospace">+</span>{{ diffline.af }}</div>
</div>
</div>
</div>
<hr ng-if="diff" class="my-3">
<div class="row"> <div class="row">
<form ng-submit="saveTheme()" class="col-4"> <form ng-submit="saveTheme()" class="col-4" ng-if="!(theme.id === 0 && theme.path)">
<div ng-class="{'form-group': field != 'locked', 'form-check': field == 'locked'}" ng-repeat="field in fields"> <div ng-class="{'form-group': field != 'locked', 'form-check': field == 'locked'}" ng-repeat="field in fields">
<input type="checkbox" class="form-check-input" id="{{ field }}" ng-model="theme[field]" ng-if="field == 'locked'"> <input type="checkbox" class="form-check-input" id="{{ field }}" ng-model="theme[field]" ng-if="field == 'locked'">
<label for="{{ field }}">{{ field | capitalize }}</label> <label for="{{ field }}">{{ field | capitalize }}</label>
@ -25,11 +38,14 @@
</div> </div>
</form> </form>
<div ng-if="theme.id" class="col-md-8" ng-controller="ExercicesListController"> <div ng-if="theme.id || theme.path" class="col-md-8" ng-class="{'offset-md-2': theme.id === 0 && theme.path}" ng-controller="ExercicesListController">
<h3> <h3>
Exercices ({{ exercices.length }}) Exercices ({{ exercices.length }})
<button type="button" ng-click="show('new')" class="float-right btn btn-sm btn-primary ml-2"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Ajouter un exercice</button> <button type="button" ng-click="show('new')" class="float-right btn btn-sm btn-primary ml-2"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Ajouter un exercice</button>
<button type="button" ng-click="syncExo()" ng-class="{'disabled': inSync}" class="float-right btn btn-sm btn-light ml-2"><span class="glyphicon glyphicon-refresh" aria-hidden="true"></span> Synchroniser</button> <div class="float-right btn-group ml-2" role="group">
<button type="button" ng-click="syncExo()" ng-class="{'disabled': inSync}" class="btn btn-sm btn-light ml-2"><span class="glyphicon glyphicon-refresh" aria-hidden="true"></span> Synchroniser</button>
<button type="button" ng-click="checkExoSync()" ng-class="{'disabled': inSync}" class="btn btn-sm btn-light" title="Exporter l'exercice actuel"><span class="glyphicon glyphicon-thumbs-up" aria-hidden="true"></span></button>
</div>
</h3> </h3>
<p><input type="search" class="form-control form-control-sm" placeholder="Search" ng-model="query" autofocus></p> <p><input type="search" class="form-control form-control-sm" placeholder="Search" ng-model="query" autofocus></p>

View file

@ -23,7 +23,7 @@ func NewThemeError(theme *fic.Theme, err error) *ThemeError {
if theme == nil { if theme == nil {
return &ThemeError{ return &ThemeError{
error: err, error: err,
ThemePath: StandaloneExercicesDirectory, ThemePath: fic.StandaloneExercicesDirectory,
} }
} }

View file

@ -420,36 +420,67 @@ func SyncExerciceFiles(i Importer, exercice *fic.Exercice, exceptions *CheckExce
return return
} }
// ApiGetRemoteExerciceFiles is an accessor to remote exercice files list. func GetRemoteExerciceFiles(thid, exid string) ([]*fic.EFile, error) {
func ApiGetRemoteExerciceFiles(c *gin.Context) { theme, exceptions, errs := BuildTheme(GlobalImporter, thid)
theme, exceptions, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid")) if theme == nil {
if theme != nil { return nil, errs
exercice, _, _, _, _, errs := BuildExercice(GlobalImporter, theme, path.Join(theme.Path, c.Params.ByName("exid")), nil, exceptions) }
if exercice != nil {
exercice, _, _, _, _, errs := BuildExercice(GlobalImporter, theme, path.Join(theme.Path, exid), nil, exceptions)
if exercice == nil {
return nil, errs
}
files, digests, errs := BuildFilesListInto(GlobalImporter, exercice, "files") files, digests, errs := BuildFilesListInto(GlobalImporter, exercice, "files")
if files != nil { if files == nil {
return nil, errs
}
var ret []*fic.EFile var ret []*fic.EFile
for _, fname := range files { for _, fname := range files {
fPath := path.Join(exercice.Path, "files", fname) fPath := path.Join(exercice.Path, "files", fname)
fSize, _ := GetFileSize(GlobalImporter, fPath) fSize, _ := GetFileSize(GlobalImporter, fPath)
ret = append(ret, &fic.EFile{
file := fic.EFile{
Path: fPath, Path: fPath,
Name: fname, Name: fname,
Checksum: digests[fname], Checksum: digests[fname],
Size: fSize, Size: fSize,
}) Published: true,
} }
c.JSON(http.StatusOK, ret)
} else { if d, exists := digests[strings.TrimSuffix(file.Name, ".gz")]; exists {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Errorf("%q", errs)}) file.Name = strings.TrimSuffix(file.Name, ".gz")
return file.Path = strings.TrimSuffix(file.Path, ".gz")
} file.ChecksumShown = d
} else { }
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Errorf("%q", errs)})
return ret = append(ret, &file)
} }
} else {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Errorf("%q", errs)}) // Complete with attributes
if paramsFiles, err := GetExerciceFilesParams(GlobalImporter, exercice); err == nil {
for _, file := range ret {
if f, ok := paramsFiles[file.Name]; ok {
file.Published = !f.Hidden
if disclaimer, err := ProcessMarkdown(GlobalImporter, fixnbsp(f.Disclaimer), exercice.Path); err == nil {
file.Disclaimer = disclaimer
}
}
}
}
return ret, nil
}
// ApiGetRemoteExerciceFiles is an accessor to remote exercice files list.
func ApiGetRemoteExerciceFiles(c *gin.Context) {
files, err := GetRemoteExerciceFiles(c.Params.ByName("thid"), c.Params.ByName("exid"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return return
} }
c.JSON(http.StatusOK, files)
} }

View file

@ -169,25 +169,32 @@ func SyncExerciceHints(i Importer, exercice *fic.Exercice, flagsBindings map[int
return return
} }
func GetRemoteExerciceHints(thid, exid string) ([]importHint, error) {
theme, exceptions, errs := BuildTheme(GlobalImporter, thid)
if theme == nil {
return nil, errs
}
exercice, _, _, eexceptions, _, errs := BuildExercice(GlobalImporter, theme, path.Join(theme.Path, exid), nil, exceptions)
if exercice == nil {
return nil, errs
}
hints, errs := CheckExerciceHints(GlobalImporter, exercice, eexceptions)
if hints == nil {
return nil, errs
}
return hints, nil
}
// ApiListRemoteExerciceHints is an accessor letting foreign packages to access remote exercice hints. // ApiListRemoteExerciceHints is an accessor letting foreign packages to access remote exercice hints.
func ApiGetRemoteExerciceHints(c *gin.Context) { func ApiGetRemoteExerciceHints(c *gin.Context) {
theme, exceptions, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid")) hints, errs := GetRemoteExerciceHints(c.Params.ByName("thid"), c.Params.ByName("exid"))
if theme != nil {
exercice, _, _, eexceptions, _, errs := BuildExercice(GlobalImporter, theme, path.Join(theme.Path, c.Params.ByName("exid")), nil, exceptions)
if exercice != nil {
hints, errs := CheckExerciceHints(GlobalImporter, exercice, eexceptions)
if hints != nil { if hints != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs))
return
}
c.JSON(http.StatusOK, hints) c.JSON(http.StatusOK, hints)
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs))
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs))
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs))
} }

View file

@ -699,26 +699,32 @@ func SyncExerciceFlags(i Importer, exercice *fic.Exercice, exceptions *CheckExce
return return
} }
func GetRemoteExerciceFlags(thid, exid string) ([]fic.Flag, error) {
theme, exceptions, errs := BuildTheme(GlobalImporter, thid)
if theme == nil {
return nil, errs
}
exercice, _, _, eexceptions, _, errs := BuildExercice(GlobalImporter, theme, path.Join(theme.Path, exid), nil, exceptions)
if exercice == nil {
return nil, errs
}
flags, errs := CheckExerciceFlags(GlobalImporter, exercice, []string{}, eexceptions)
if flags == nil {
return nil, errs
}
return flags, nil
}
// ApiListRemoteExerciceFlags is an accessor letting foreign packages to access remote exercice flags. // ApiListRemoteExerciceFlags is an accessor letting foreign packages to access remote exercice flags.
func ApiGetRemoteExerciceFlags(c *gin.Context) { func ApiGetRemoteExerciceFlags(c *gin.Context) {
theme, exceptions, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid")) flags, err := GetRemoteExerciceFlags(c.Params.ByName("thid"), c.Params.ByName("exid"))
if theme != nil { if err != nil {
exercice, _, _, eexceptions, _, errs := BuildExercice(GlobalImporter, theme, path.Join(theme.Path, c.Params.ByName("exid")), nil, exceptions) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
if exercice != nil { return
flags, errs := CheckExerciceFlags(GlobalImporter, exercice, []string{}, eexceptions) }
if flags != nil {
c.JSON(http.StatusOK, flags) c.JSON(http.StatusOK, flags)
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs))
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs))
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs))
return
} }

View file

@ -63,10 +63,17 @@ func buildDependancyMap(i Importer, theme *fic.Theme) (dmap map[int64]*fic.Exerc
continue continue
} }
// ename can be overrride by title.txt
if i.Exists(path.Join(theme.Path, edir, "title.txt")) {
if myTitle, err := GetFileContent(i, path.Join(theme.Path, edir, "title.txt")); err == nil {
ename = strings.TrimSpace(myTitle)
}
}
var e *fic.Exercice var e *fic.Exercice
e, err = theme.GetExerciceByTitle(ename) e, err = theme.GetExerciceByTitle(ename)
if err != nil { if err != nil {
return return dmap, fmt.Errorf("unable to GetExerciceByTitle(ename=%q, tid=%d): %w", ename, theme.Id, err)
} }
dmap[int64(eid)] = e dmap[int64(eid)] = e
@ -468,59 +475,57 @@ func SyncExercices(i Importer, theme *fic.Theme, exceptions *CheckExceptions) (e
return return
} }
func ListRemoteExercices(thid string) ([]string, error) {
if thid == "_" {
return GetExercices(GlobalImporter, &fic.StandaloneExercicesTheme)
}
theme, _, errs := BuildTheme(GlobalImporter, thid)
if theme == nil {
return nil, errs
}
return GetExercices(GlobalImporter, theme)
}
// ApiListRemoteExercices is an accessor letting foreign packages to access remote exercices list. // ApiListRemoteExercices is an accessor letting foreign packages to access remote exercices list.
func ApiListRemoteExercices(c *gin.Context) { func ApiListRemoteExercices(c *gin.Context) {
if c.Params.ByName("thid") == "_" { exercices, err := ListRemoteExercices(c.Params.ByName("thid"))
exercices, err := GetExercices(GlobalImporter, &fic.Theme{Path: StandaloneExercicesDirectory})
if err != nil { if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return return
} }
c.JSON(http.StatusOK, exercices) c.JSON(http.StatusOK, exercices)
return
} }
theme, _, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid")) func GetRemoteExercice(thid, exid string, inTheme *fic.Theme) (*fic.Exercice, error) {
if theme != nil { if thid == fic.StandaloneExercicesDirectory || thid == "_" {
exercices, err := GetExercices(GlobalImporter, theme) exercice, _, _, _, _, errs := BuildExercice(GlobalImporter, nil, path.Join(fic.StandaloneExercicesDirectory, exid), nil, nil)
if err != nil { return exercice, errs
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
} }
c.JSON(http.StatusOK, exercices) theme, exceptions, errs := BuildTheme(GlobalImporter, thid)
} else { if theme == nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs)) return nil, fmt.Errorf("Theme not found")
return
}
} }
// ApiListRemoteExercice is an accessor letting foreign packages to access remote exercice attributes. if inTheme == nil {
inTheme = theme
}
exercice, _, _, _, _, errs := BuildExercice(GlobalImporter, inTheme, path.Join(theme.Path, exid), nil, exceptions)
return exercice, errs
}
// ApiGetRemoteExercice is an accessor letting foreign packages to access remote exercice attributes.
func ApiGetRemoteExercice(c *gin.Context) { func ApiGetRemoteExercice(c *gin.Context) {
if c.Params.ByName("thid") == "_" { exercice, err := GetRemoteExercice(c.Params.ByName("thid"), c.Params.ByName("exid"), nil)
exercice, _, _, _, _, errs := BuildExercice(GlobalImporter, nil, path.Join(StandaloneExercicesDirectory, c.Params.ByName("exid")), nil, nil)
if exercice != nil { if exercice != nil {
c.JSON(http.StatusOK, exercice) c.JSON(http.StatusOK, exercice)
return return
} else { } else {
c.JSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Errorf("%q", errs)}) c.JSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
}
theme, exceptions, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid"))
if theme != nil {
exercice, _, _, _, _, errs := BuildExercice(GlobalImporter, theme, path.Join(theme.Path, c.Params.ByName("exid")), nil, exceptions)
if exercice != nil {
c.JSON(http.StatusOK, exercice)
return
} else {
c.JSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Errorf("%q", errs)})
return
}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Errorf("%q", errs)})
return return
} }
} }

View file

@ -75,8 +75,8 @@ func SpeedySyncDeep(i Importer) (errs SyncReport) {
if themes, err := fic.GetThemes(); err == nil { if themes, err := fic.GetThemes(); err == nil {
DeepSyncProgress = 2 DeepSyncProgress = 2
if i.Exists(StandaloneExercicesDirectory) { if i.Exists(fic.StandaloneExercicesDirectory) {
themes = append(themes, &fic.Theme{Path: StandaloneExercicesDirectory}) themes = append(themes, &fic.StandaloneExercicesTheme)
} }
var themeStep uint8 = uint8(250) / uint8(len(themes)) var themeStep uint8 = uint8(250) / uint8(len(themes))
@ -147,8 +147,8 @@ func SyncDeep(i Importer) (errs SyncReport) {
DeepSyncProgress = 2 DeepSyncProgress = 2
// Also synchronize standalone exercices // Also synchronize standalone exercices
if i.Exists(StandaloneExercicesDirectory) { if i.Exists(fic.StandaloneExercicesDirectory) {
themes = append(themes, &fic.Theme{Path: StandaloneExercicesDirectory}) themes = append(themes, &fic.StandaloneExercicesTheme)
} }
var themeStep uint8 = uint8(250) / uint8(len(themes)) var themeStep uint8 = uint8(250) / uint8(len(themes))

View file

@ -22,15 +22,13 @@ import (
"srs.epita.fr/fic-server/libfic" "srs.epita.fr/fic-server/libfic"
) )
const StandaloneExercicesDirectory = "exercices"
// GetThemes returns all theme directories in the base directory. // GetThemes returns all theme directories in the base directory.
func GetThemes(i Importer) (themes []string, err error) { func GetThemes(i Importer) (themes []string, err error) {
if dirs, err := i.ListDir("/"); err != nil { if dirs, err := i.ListDir("/"); err != nil {
return nil, err return nil, err
} else { } else {
for _, dir := range dirs { for _, dir := range dirs {
if !strings.HasPrefix(dir, ".") && !strings.HasPrefix(dir, "_") && dir != StandaloneExercicesDirectory { if !strings.HasPrefix(dir, ".") && !strings.HasPrefix(dir, "_") && dir != fic.StandaloneExercicesDirectory {
if _, err := i.ListDir(dir); err == nil { if _, err := i.ListDir(dir); err == nil {
themes = append(themes, dir) themes = append(themes, dir)
} }
@ -180,8 +178,10 @@ func BuildTheme(i Importer, tdir string) (th *fic.Theme, exceptions *CheckExcept
th.URLId = fic.ToURLid(th.Name) th.URLId = fic.ToURLid(th.Name)
if authors, err := getAuthors(i, tdir); err != nil { if authors, err := getAuthors(i, tdir); err != nil {
if tdir != fic.StandaloneExercicesDirectory {
errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("unable to get AUTHORS.txt: %w", err))) errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("unable to get AUTHORS.txt: %w", err)))
return nil, nil, errs return nil, nil, errs
}
} else { } else {
// Format authors // Format authors
th.Authors = strings.Join(authors, ", ") th.Authors = strings.Join(authors, ", ")
@ -355,18 +355,34 @@ func ApiListRemoteThemes(c *gin.Context) {
c.JSON(http.StatusOK, themes) c.JSON(http.StatusOK, themes)
} }
func GetRemoteTheme(thid string) (*fic.Theme, error) {
if thid == fic.StandaloneExercicesTheme.URLId || thid == fic.StandaloneExercicesDirectory {
return &fic.StandaloneExercicesTheme, nil
}
theme, _, errs := BuildTheme(GlobalImporter, thid)
if theme == nil {
return nil, errs
}
return theme, nil
}
// ApiListRemoteTheme is an accessor letting foreign packages to access remote main theme attributes. // ApiListRemoteTheme is an accessor letting foreign packages to access remote main theme attributes.
func ApiGetRemoteTheme(c *gin.Context) { func ApiGetRemoteTheme(c *gin.Context) {
if c.Params.ByName("thid") == "_" { var theme *fic.Theme
c.Status(http.StatusNoContent) var err error
if c.Params.ByName("thid") == fic.StandaloneExercicesTheme.URLId {
theme, err = GetRemoteTheme(fic.StandaloneExercicesDirectory)
} else {
theme, err = GetRemoteTheme(c.Params.ByName("thid"))
}
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return return
} }
r, _, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid")) c.JSON(http.StatusOK, theme)
if r == nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Errorf("%q", errs)})
return
}
c.JSON(http.StatusOK, r)
} }

View file

@ -90,6 +90,7 @@ func reloadSettings(config *settings.Settings) {
fic.GlobalScoreCoefficient = config.GlobalScoreCoefficient fic.GlobalScoreCoefficient = config.GlobalScoreCoefficient
fic.CountOnlyNotGoodTries = config.CountOnlyNotGoodTries fic.CountOnlyNotGoodTries = config.CountOnlyNotGoodTries
fic.DiscountedFactor = config.DiscountedFactor fic.DiscountedFactor = config.DiscountedFactor
fic.QuestionGainRatio = config.QuestionGainRatio
} }
func main() { func main() {

View file

@ -28,11 +28,13 @@
export let readonly = false; export let readonly = false;
export let forcesolved = false; export let forcesolved = false;
let last_submission = { };
let responses = { }; let responses = { };
async function submitFlags() { async function submitFlags() {
waitInProgress.set(true); waitInProgress.set(true);
sberr = ""; sberr = "";
message = ""; message = "";
last_submission = JSON.parse(JSON.stringify(responses));
if ($my && $my.team_id === 0) { if ($my && $my.team_id === 0) {
let allGoodResponse = true; let allGoodResponse = true;
@ -119,6 +121,11 @@
mcqs: { }, mcqs: { },
justifications: { }, justifications: { },
}; };
last_submission = {
flags: { },
mcqs: { },
justifications: { },
};
} }
let last_exercice = null; let last_exercice = null;
@ -152,10 +159,12 @@
{#if exercice.tries || exercice.solved_time || exercice.submitted || sberr || $timeouted} {#if exercice.tries || exercice.solved_time || exercice.submitted || sberr || $timeouted}
<ListGroup class="border-dark"> <ListGroup class="border-dark">
{#if exercice.solved_time || exercice.tries} {#if exercice.solved_time || exercice.tries}
<ListGroupItem class="text-warning rounded-0"> <div class="d-flex align-items-center">
<ListGroupItem class="rounded-0 {$waitInProgress?'text-secondary':'text-warning'}">
{#if exercice.tries > 0}{exercice.tries} {exercice.tries==1?"tentative effectuée":"tentatives effectuées"}.{/if} {#if exercice.tries > 0}{exercice.tries} {exercice.tries==1?"tentative effectuée":"tentatives effectuées"}.{/if}
Dernière solution envoyée à <DateFormat date={exercice.solved_time} />. Dernière solution envoyée le <span class:placeholder={$waitInProgress} class:placeholder-glow={$waitInProgress}><DateFormat date={exercice.solved_time} /></span>.
</ListGroupItem> </ListGroupItem>
</div>
{/if} {/if}
{#if exercice.solve_dist} {#if exercice.solve_dist}
<ListGroupItem class="rounded-0"> <ListGroupItem class="rounded-0">
@ -191,6 +200,7 @@
<FlagMCQ <FlagMCQ
exercice_id={exercice.id} exercice_id={exercice.id}
{flag} {flag}
previous_values={$waitInProgress ? { justifications: { }, mcqs: { } } : last_submission}
bind:values={responses.mcqs} bind:values={responses.mcqs}
bind:justifications={responses.justifications} bind:justifications={responses.justifications}
/> />
@ -199,6 +209,7 @@
class="mb-3" class="mb-3"
exercice_id={exercice.id} exercice_id={exercice.id}
{flag} {flag}
previous_value={$waitInProgress ? "" : last_submission.flags[flag.id]}
bind:value={responses.flags[flag.id]} bind:value={responses.flags[flag.id]}
/> />
{/if} {/if}

View file

@ -16,6 +16,7 @@
export let exercice_id = 0; export let exercice_id = 0;
export let flag = { }; export let flag = { };
export let no_label = false; export let no_label = false;
export let previous_value = "";
export let value = ""; export let value = "";
let values = [""]; let values = [""];
@ -141,6 +142,7 @@
<input <input
type="number" type="number"
class="form-control flag" class="form-control flag"
class:is-invalid={previous_value && previous_value == value}
id="sol_{flag.type}{flag.id}_{index}" id="sol_{flag.type}{flag.id}_{index}"
autocomplete="off" autocomplete="off"
bind:value={values[index]} bind:value={values[index]}
@ -154,6 +156,7 @@
<input <input
type="text" type="text"
class="form-control flag" class="form-control flag"
class:is-invalid={previous_value && previous_value == value}
id="sol_{flag.type}{flag.id}_{index}" id="sol_{flag.type}{flag.id}_{index}"
autocomplete="off" autocomplete="off"
bind:value={values[index]} bind:value={values[index]}
@ -164,6 +167,7 @@
{:else} {:else}
<textarea <textarea
class="form-control flag" class="form-control flag"
class:is-invalid={previous_value && previous_value == value}
id="sol_{flag.type}{flag.id}_{index}" id="sol_{flag.type}{flag.id}_{index}"
autocomplete="off" autocomplete="off"
bind:value={values[index]} bind:value={values[index]}
@ -208,6 +212,7 @@
value={l} value={l}
bind:group={values[index]} bind:group={values[index]}
class="form-check-input" class="form-check-input"
class:is-invalid={previous_value && previous_value == value}
> >
<label <label
class="form-check-label" class="form-check-label"
@ -220,6 +225,7 @@
{:else} {:else}
<select <select
class="form-select" class="form-select"
class:is-invalid={previous_value && previous_value == value}
id="sol_{flag.type}{flag.id}_{index}" id="sol_{flag.type}{flag.id}_{index}"
bind:value={values[index]} bind:value={values[index]}
> >

View file

@ -8,6 +8,7 @@
export let exercice_id = 0; export let exercice_id = 0;
export let flag = { }; export let flag = { };
export let previous_values = { justifications: { }, mcqs: { } };
export let values = { }; export let values = { };
export let justifications = { }; export let justifications = { };
</script> </script>
@ -24,7 +25,7 @@
{#each Object.keys(flag.choices) as cid, index} {#each Object.keys(flag.choices) as cid, index}
<div class="form-check ms-3"> <div class="form-check ms-3">
{#if typeof flag.choices[cid] != "object"} {#if typeof flag.choices[cid] != "object"}
<input class="form-check-input" type="checkbox" id="mcq_{flag.id}_{cid}" bind:checked={values[Number(cid)]} disabled={flag.found || flag.part_solved}> <input class="form-check-input" class:is-invalid={previous_values.mcqs && Object.keys(flag.choices).reduce((acc, cur) => acc + (previous_values.mcqs[Number(cur)] !== undefined ? 1 : 0), 0) > 0 && Object.keys(flag.choices).reduce((acc, cur) => acc && previous_values.mcqs[Number(cur)] == values[Number(cur)], true)} type="checkbox" id="mcq_{flag.id}_{cid}" bind:checked={values[Number(cid)]} disabled={flag.found || flag.part_solved}>
<label class="form-check-label" for="mcq_{flag.id}_{cid}"> <label class="form-check-label" for="mcq_{flag.id}_{cid}">
{flag.choices[cid]}{#if values[Number(cid)] && flag.justify}&nbsp;:{/if} {flag.choices[cid]}{#if values[Number(cid)] && flag.justify}&nbsp;:{/if}
</label> </label>
@ -34,6 +35,7 @@
{exercice_id} {exercice_id}
flag={{id: cid, placeholder: "Flag correspondant"}} flag={{id: cid, placeholder: "Flag correspondant"}}
no_label={true} no_label={true}
previous_value={previous_values.justifications && previous_values.justifications[cid]}
bind:value={justifications[cid]} bind:value={justifications[cid]}
/> />
{/if} {/if}
@ -43,6 +45,7 @@
class={flag.choices[cid].justification.found?"":"mb-3"} class={flag.choices[cid].justification.found?"":"mb-3"}
{exercice_id} {exercice_id}
flag={flag.choices[cid].justification} flag={flag.choices[cid].justification}
previous_value={previous_values.justifications[cid]}
bind:value={justifications[cid]} bind:value={justifications[cid]}
/> />
{/if} {/if}

View file

@ -10,6 +10,7 @@
import DateFormat from '$lib/components/DateFormat.svelte'; import DateFormat from '$lib/components/DateFormat.svelte';
import { my } from '$lib/stores/my.js'; import { my } from '$lib/stores/my.js';
import { settings } from '$lib/stores/settings.js';
import { themes, exercices_idx } from '$lib/stores/themes.js'; import { themes, exercices_idx } from '$lib/stores/themes.js';
let req = null; let req = null;
@ -55,6 +56,10 @@
{:else if row.reason == "Display choices"} {:else if row.reason == "Display choices"}
<Badge color="secondary"><Icon name="info-square" /></Badge> <Badge color="secondary"><Icon name="info-square" /></Badge>
Échange champ de texte contre liste de choix Échange champ de texte contre liste de choix
{:else if row.reason.startsWith("Response ")}
{@const fields = row.reason.split(" ")}
<Badge class="bg-success-subtle text-dark"><Icon name="clipboard2-check" /></Badge>
Validation {fields[1]} n<sup>o</sup>&nbsp;{fields[3]}
{:else} {:else}
<Badge color="primary"><Icon name="question" /></Badge> <Badge color="primary"><Icon name="question" /></Badge>
{row.reason} {row.reason}
@ -66,7 +71,7 @@
{/if} {/if}
</Column> </Column>
<Column header="Détail"> <Column header="Détail">
<span title="Valeur initiale (cette valeur est fixe)">{Math.trunc(10*row.points)/10}</span> &times; <span title="Coefficient multiplicateur (il varie selon les événements en cours sur la plateforme)">{row.coeff}</span> <span title="Valeur initiale (cette valeur est fixe)">{Math.trunc(10*row.points)/10}</span> &times; {#if row.reason.startsWith("Response ")}<span title="Pourcentage des points accordé pour avoir répondu aux questions d'un défi, sans avoir validé entièrement le défi">{Math.trunc($settings.questionGainRatio * 1000)/10}&nbsp;&percnt;</span> &divide; <span title="Nombre de questions du défi">{$settings.questionGainRatio / row.coeff}</span>{:else if row.reason == "Validation" && $settings.questionGainRatio != 0}(<span title="Coefficient multiplicateur (il varie selon les événements en cours sur la plateforme)">{row.coeff + $settings.questionGainRatio}</span> &minus; <span title="Pourcentage des points déjà obtenu au travers des réponses aux questions">{Math.trunc($settings.questionGainRatio * 1000)/10}&nbsp;&percnt;</span>){:else}<span title="Coefficient multiplicateur (il varie selon les événements en cours sur la plateforme)">{row.coeff}</span>{/if}
</Column> </Column>
<Column header="Points"> <Column header="Points">
{Math.trunc(10*row.points * row.coeff)/10} {Math.trunc(10*row.points * row.coeff)/10}

View file

@ -21,7 +21,7 @@
<div class="card-group text-justify mb-5"> <div class="card-group text-justify mb-5">
<div class="card niceborder"> <div class="card niceborder">
<div class="card-body text-indent"> <div class="card-body text-indent text-white">
<h2>Débloquage des challenges</h2> <h2>Débloquage des challenges</h2>
<p> <p>
Au début, seul le premier défi de chaque scénario est Au début, seul le premier défi de chaque scénario est
@ -31,7 +31,7 @@
{#if $settings.unlockedStandaloneExercicesByThemeStepValidation > 0 || $settings.unlockedStandaloneExercicesByStandaloneExerciceValidation > 0} {#if $settings.unlockedStandaloneExercicesByThemeStepValidation > 0 || $settings.unlockedStandaloneExercicesByStandaloneExerciceValidation > 0}
<p> <p>
Vous avez également accès à {$settings.unlockedStandaloneExercices} défis indépendants. Vous avez également accès à {$settings.unlockedStandaloneExercices} défis indépendants.
Ces défis sont débloqués D'autres défis sont débloqués
{#if $settings.unlockedStandaloneExercicesByThemeStepValidation > 0}{#if $settings.unlockedStandaloneExercicesByThemeStepValidation < 1} toutes les {1/$settings.unlockedStandaloneExercicesByThemeStepValidation} étape{#if 1/$settings.unlockedStandaloneExercicesByThemeStepValidation > 1}s{/if} de scénario que vous validez{:else}par {$settings.unlockedStandaloneExercicesByThemeStepValidation} défis pour chaque étape de scénario validée{/if}{/if} {#if $settings.unlockedStandaloneExercicesByThemeStepValidation > 0}{#if $settings.unlockedStandaloneExercicesByThemeStepValidation < 1} toutes les {1/$settings.unlockedStandaloneExercicesByThemeStepValidation} étape{#if 1/$settings.unlockedStandaloneExercicesByThemeStepValidation > 1}s{/if} de scénario que vous validez{:else}par {$settings.unlockedStandaloneExercicesByThemeStepValidation} défis pour chaque étape de scénario validée{/if}{/if}
{#if $settings.unlockedStandaloneExercicesByStandaloneExerciceValidation > 0}{#if $settings.unlockedStandaloneExercicesByStandaloneExerciceValidation < 1} tous les {1/$settings.unlockedStandaloneExercicesByStandaloneExerciceValidation} défi{#if 1/$settings.unlockedStandaloneExercicesByStandaloneExerciceValidation > 1}s{/if} indépendant que vous validez{:else}par {$settings.unlockedStandaloneExercicesByStandaloneExerciceValidation} exercice indépendant validé{/if}{/if} {#if $settings.unlockedStandaloneExercicesByStandaloneExerciceValidation > 0}{#if $settings.unlockedStandaloneExercicesByStandaloneExerciceValidation < 1} tous les {1/$settings.unlockedStandaloneExercicesByStandaloneExerciceValidation} défi{#if 1/$settings.unlockedStandaloneExercicesByStandaloneExerciceValidation > 1}s{/if} indépendant que vous validez{:else}par {$settings.unlockedStandaloneExercicesByStandaloneExerciceValidation} exercice indépendant validé{/if}{/if}
</p> </p>
@ -55,6 +55,21 @@
proposés. Plus le challenge est compliqué, plus il rapporte de points. proposés. Plus le challenge est compliqué, plus il rapporte de points.
</p> </p>
{#if $settings.questionGainRatio != 0}
<p>
Même si vous n'arrivez pas à valider un défi, toutes les questions validées augmentent votre score.
{Math.trunc($settings.questionGainRatio * 1000)/10}&nbsp;&percnt; des points du défi sont répartis à parts égales entre toutes les questions.
</p>
<p>
Par exemple, pour un défi de 5 questions valant 20 points, en ayant répondu à 3 questions sur les 5, votre score sera augmenté de&nbsp;:<br>
20 &times; {Math.trunc($settings.questionGainRatio * 1000)/10}&nbsp;&percnt; &divide; 5 &times; 3 &equals; {Math.trunc(20 * $settings.questionGainRatio / 5 * 3 * 100)/100}&nbsp;points.
</p>
<p>
Les {Math.trunc(1000 - $settings.questionGainRatio * 1000)/10}&nbsp;&percnt; restants sont obtenus à la validation complète du défi.
</p>
{/if}
{#if $settings.submissionCostBase != 0}
<h3>Coût des tentatives</h3> <h3>Coût des tentatives</h3>
<p> <p>
Vous disposez de 10&nbsp;tentatives pour trouver la/les solutions d'un Vous disposez de 10&nbsp;tentatives pour trouver la/les solutions d'un
@ -114,20 +129,21 @@
parmi ce nombre de tentatives. parmi ce nombre de tentatives.
</p> </p>
{/if} {/if}
{/if}
</div> </div>
</div> </div>
<div class="card niceborder"> <div class="card niceborder">
<div class="card-body text-indent"> <div class="card-body text-indent text-white">
{#if $settings.discountedFactor > 0} {#if $settings.discountedFactor > 0}
<h3>Décote des gains</h3> <h3>Décote des gains</h3>
<p> <p>
Une validation d'étape ne vous garanti pas un solde de points fixe. Une validation d'étape ne vous garantit pas un solde de points fixe.
</p> </p>
<p> <p>
Selon le nombre d'équipe qui valident un challenge donné, sa cote diminue et vous rapporte alors moins de points. Le gain est donc indépendemment du fait que vous ayez validé l'étape avant une autre équipe : le gain affiché est un gain maximum, entendu si aucune autre équipe ne le valide. Selon le nombre d'équipes qui valident un challenge donné, sa cote diminue et vous rapporte alors moins de points. Le gain final est donc indépendant du fait que vous ayez validé l'étape avant une autre équipe&nbsp;: le gain affiché est un gain maximum que vous obtiendriez si aucune autre équipe ne valide cette étape.
</p> </p>
<p> <p>
Chaque validation réduit de {$settings.discountedFactor*100}&nbsp;% la cote de l'exercice. Chaque validation réduit de {$settings.discountedFactor*100}&nbsp;&percnt; la cote de l'exercice.
</p> </p>
<p> <p>
Ainsi, pour un exercice d'une valeur initiale de {10*$settings.globalScoreCoefficient}&nbsp;points&nbsp;: Ainsi, pour un exercice d'une valeur initiale de {10*$settings.globalScoreCoefficient}&nbsp;points&nbsp;:
@ -192,10 +208,12 @@
défi. défi.
</p> </p>
{#if $settings.firstBlood}
<h4>Prem's</h4> <h4>Prem's</h4>
<p> <p>
Un bonus de +{$settings.firstBlood * 100}&nbsp;% est attribué à la première équipe qui résout un défi. Un bonus de +{$settings.firstBlood * 100}&nbsp;&percnt; est attribué à la première équipe qui résout un défi.
</p> </p>
{/if}
<h4>Bonus temporaires <small><Icon name="gift" aria-hidden="true" title="Des <h4>Bonus temporaires <small><Icon name="gift" aria-hidden="true" title="Des
bonus existent pour au moins un challenge de ce thème" /></small></h4> bonus existent pour au moins un challenge de ce thème" /></small></h4>

View file

@ -30,7 +30,7 @@ func reloadSettings(config *settings.Settings) {
fic.WChoiceCoefficient = config.WChoiceCurCoefficient fic.WChoiceCoefficient = config.WChoiceCurCoefficient
fic.ExerciceCurrentCoefficient = config.ExerciceCurCoefficient fic.ExerciceCurrentCoefficient = config.ExerciceCurCoefficient
ChStarted = config.Start.Unix() > 0 && time.Since(config.Start) >= 0 ChStarted = config.Start.Unix() > 0 && time.Since(config.Start) >= 0
if allowRegistration != config.AllowRegistration || fic.PartialValidation != config.PartialValidation || fic.UnlockedChallengeDepth != config.UnlockedChallengeDepth || fic.UnlockedStandaloneExercices != config.UnlockedStandaloneExercices || fic.UnlockedStandaloneExercicesByThemeStepValidation != config.UnlockedStandaloneExercicesByThemeStepValidation || fic.UnlockedStandaloneExercicesByStandaloneExerciceValidation != config.UnlockedStandaloneExercicesByStandaloneExerciceValidation || fic.UnlockedChallengeUpTo != config.UnlockedChallengeUpTo || fic.DisplayAllFlags != config.DisplayAllFlags || fic.FirstBlood != config.FirstBlood || fic.SubmissionCostBase != config.SubmissionCostBase || fic.SubmissionUniqueness != config.SubmissionUniqueness || fic.DiscountedFactor != config.DiscountedFactor || fic.HideCaseSensitivity != config.HideCaseSensitivity { if allowRegistration != config.AllowRegistration || fic.PartialValidation != config.PartialValidation || fic.UnlockedChallengeDepth != config.UnlockedChallengeDepth || fic.UnlockedStandaloneExercices != config.UnlockedStandaloneExercices || fic.UnlockedStandaloneExercicesByThemeStepValidation != config.UnlockedStandaloneExercicesByThemeStepValidation || fic.UnlockedStandaloneExercicesByStandaloneExerciceValidation != config.UnlockedStandaloneExercicesByStandaloneExerciceValidation || fic.UnlockedChallengeUpTo != config.UnlockedChallengeUpTo || fic.DisplayAllFlags != config.DisplayAllFlags || fic.FirstBlood != config.FirstBlood || fic.SubmissionCostBase != config.SubmissionCostBase || fic.SubmissionUniqueness != config.SubmissionUniqueness || fic.DiscountedFactor != config.DiscountedFactor || fic.QuestionGainRatio != config.QuestionGainRatio || fic.HideCaseSensitivity != config.HideCaseSensitivity {
allowRegistration = config.AllowRegistration allowRegistration = config.AllowRegistration
fic.PartialValidation = config.PartialValidation fic.PartialValidation = config.PartialValidation
@ -48,6 +48,7 @@ func reloadSettings(config *settings.Settings) {
fic.CountOnlyNotGoodTries = config.CountOnlyNotGoodTries fic.CountOnlyNotGoodTries = config.CountOnlyNotGoodTries
fic.HideCaseSensitivity = config.HideCaseSensitivity fic.HideCaseSensitivity = config.HideCaseSensitivity
fic.DiscountedFactor = config.DiscountedFactor fic.DiscountedFactor = config.DiscountedFactor
fic.QuestionGainRatio = config.QuestionGainRatio
if !skipInitialGeneration { if !skipInitialGeneration {
log.Println("Generating files...") log.Println("Generating files...")

View file

@ -645,3 +645,14 @@ func (e *Exercice) IsSolved() (int, *time.Time) {
return *nb, tm return *nb, tm
} }
} }
func HasStandaloneExercice() (bool, error) {
var nb int
err := DBQueryRow("SELECT COUNT(id_exercice) FROM exercices WHERE id_theme IS NULL").Scan(&nb)
if err != nil {
return false, err
} else {
return nb > 0, nil
}
}

View file

@ -46,6 +46,10 @@ func GetHint(id int64) (*EHint, error) {
return h, nil return h, nil
} }
func (h *EHint) TreatHintContent() {
treatHintContent(h)
}
// GetHint retrieves the hint with the given id. // GetHint retrieves the hint with the given id.
func (e *Exercice) GetHint(id int64) (*EHint, error) { func (e *Exercice) GetHint(id int64) (*EHint, error) {
h := &EHint{} h := &EHint{}

View file

@ -23,6 +23,9 @@ var CountOnlyNotGoodTries = false
// DiscountedFactor stores the percentage of the exercice's gain lost on each validation. // DiscountedFactor stores the percentage of the exercice's gain lost on each validation.
var DiscountedFactor = 0.0 var DiscountedFactor = 0.0
// QuestionGainRatio is the ratio given to a partially solved exercice in the final scoreboard.
var QuestionGainRatio = 0.0
func exoptsQuery(whereExo string) string { func exoptsQuery(whereExo string) string {
tries_table := "exercice_tries" tries_table := "exercice_tries"
if SubmissionUniqueness { if SubmissionUniqueness {
@ -38,9 +41,21 @@ func exoptsQuery(whereExo string) string {
exercices_table = "exercices_discounted" exercices_table = "exercices_discounted"
} }
questionGainQuery := ""
if QuestionGainRatio != 0.0 {
questionGainQuery = `SELECT id_team, F.id_exercice AS id_exercice, time, ` + fmt.Sprintf("%f", QuestionGainRatio) + ` / (COALESCE(T.total_flags, 0) + COALESCE(TMCQ.total_mcqs, 0)) AS coeff, CONCAT("Response flag ", F.id_flag, " ", F.ordre) AS reason FROM flag_found B INNER JOIN exercice_flags F ON F.id_flag = B.id_flag LEFT JOIN (SELECT id_exercice, COUNT(*) AS total_flags FROM exercice_flags GROUP BY id_exercice) T ON F.id_exercice = T.id_exercice LEFT JOIN (SELECT id_exercice, COUNT(*) AS total_mcqs FROM exercice_mcq GROUP BY id_exercice) TMCQ ON F.id_exercice = TMCQ.id_exercice WHERE F.bonus_gain = 0 UNION
SELECT id_team, F.id_exercice AS id_exercice, time, ` + fmt.Sprintf("%f", QuestionGainRatio) + ` / (COALESCE(T.total_flags, 0) + COALESCE(TMCQ.total_mcqs, 0)) AS coeff, CONCAT("Response MCQ ", F.id_mcq, " ", F.ordre) AS reason FROM mcq_found B INNER JOIN exercice_mcq F ON F.id_mcq = B.id_mcq LEFT JOIN (SELECT id_exercice, COUNT(*) AS total_flags FROM exercice_flags GROUP BY id_exercice) T ON F.id_exercice = T.id_exercice LEFT JOIN (SELECT id_exercice, COUNT(*) AS total_mcqs FROM exercice_mcq GROUP BY id_exercice) TMCQ ON F.id_exercice = TMCQ.id_exercice UNION`
}
firstBloodQuery := ""
if FirstBlood != 0.0 {
firstBloodQuery = `SELECT id_team, id_exercice, time, ` + fmt.Sprintf("%f", FirstBlood) + ` AS coeff, "First blood" AS reason FROM exercice_solved JOIN (SELECT id_exercice, MIN(time) time FROM exercice_solved GROUP BY id_exercice) d1 USING (id_exercice, time) UNION`
}
query := `SELECT S.id_team, S.time, E.gain AS points, coeff, S.reason, S.id_exercice FROM ( query := `SELECT S.id_team, S.time, E.gain AS points, coeff, S.reason, S.id_exercice FROM (
SELECT id_team, id_exercice, time, ` + fmt.Sprintf("%f", FirstBlood) + ` AS coeff, "First blood" AS reason FROM exercice_solved JOIN (SELECT id_exercice, MIN(time) time FROM exercice_solved GROUP BY id_exercice) d1 USING (id_exercice, time) UNION ` + questionGainQuery + `
SELECT id_team, id_exercice, time, coefficient AS coeff, "Validation" AS reason FROM exercice_solved ` + firstBloodQuery + `
SELECT id_team, id_exercice, time, coefficient - ` + fmt.Sprintf("%f", QuestionGainRatio) + ` AS coeff, "Validation" AS reason FROM exercice_solved
) S INNER JOIN ` + exercices_table + ` E ON S.id_exercice = E.id_exercice UNION ALL ) S INNER JOIN ` + exercices_table + ` E ON S.id_exercice = E.id_exercice UNION ALL
SELECT B.id_team, B.time, F.bonus_gain AS points, 1 AS coeff, "Bonus flag" AS reason, F.id_exercice FROM flag_found B INNER JOIN exercice_flags F ON F.id_flag = B.id_flag WHERE F.bonus_gain != 0 HAVING points != 0 UNION ALL SELECT B.id_team, B.time, F.bonus_gain AS points, 1 AS coeff, "Bonus flag" AS reason, F.id_exercice FROM flag_found B INNER JOIN exercice_flags F ON F.id_flag = B.id_flag WHERE F.bonus_gain != 0 HAVING points != 0 UNION ALL
SELECT id_team, MAX(time) AS time, (FLOOR(COUNT(*)/10 - 1) * (FLOOR(COUNT(*)/10)))/0.2 + (FLOOR(COUNT(*)/10) * (COUNT(*)%10)) AS points, ` + fmt.Sprintf("%f", SubmissionCostBase*-1) + ` AS coeff, "Tries" AS reason, id_exercice FROM ` + tries_table + ` S GROUP BY id_exercice, id_team` SELECT id_team, MAX(time) AS time, (FLOOR(COUNT(*)/10 - 1) * (FLOOR(COUNT(*)/10)))/0.2 + (FLOOR(COUNT(*)/10) * (COUNT(*)%10)) AS points, ` + fmt.Sprintf("%f", SubmissionCostBase*-1) + ` AS coeff, "Tries" AS reason, id_exercice FROM ` + tries_table + ` S GROUP BY id_exercice, id_team`

View file

@ -2,6 +2,14 @@ package fic
import () import ()
const StandaloneExercicesDirectory = "exercices"
var StandaloneExercicesTheme = Theme{
Name: "Défis indépendants",
URLId: "_",
Path: StandaloneExercicesDirectory,
}
// Theme represents a group of challenges, to display to players // Theme represents a group of challenges, to display to players
type Theme struct { type Theme struct {
Id int64 `json:"id"` Id int64 `json:"id"`
@ -62,11 +70,7 @@ func GetThemesExtended() ([]*Theme, error) {
return nil, err return nil, err
} else { } else {
// Append standalone exercices fake-themes // Append standalone exercices fake-themes
stdthm := &Theme{ stdthm := &StandaloneExercicesTheme
Name: "Défis indépendants",
URLId: "_",
Path: "exercices",
}
if exercices, err := stdthm.GetExercices(); err == nil && len(exercices) > 0 { if exercices, err := stdthm.GetExercices(); err == nil && len(exercices) > 0 {
themes = append(themes, stdthm) themes = append(themes, stdthm)

View file

@ -46,6 +46,8 @@ type Settings struct {
GlobalScoreCoefficient float64 `json:"globalScoreCoefficient"` GlobalScoreCoefficient float64 `json:"globalScoreCoefficient"`
// DiscountedFactor stores the percentage of the exercice's gain lost on each validation. // DiscountedFactor stores the percentage of the exercice's gain lost on each validation.
DiscountedFactor float64 `json:"discountedFactor,omitempty"` DiscountedFactor float64 `json:"discountedFactor,omitempty"`
// QuestionGainRatio is the ratio given to a partially solved exercice in the final scoreboard.
QuestionGainRatio float64 `json:"questionGainRatio,omitempty"`
// AllowRegistration permits unregistered Team to register themselves. // AllowRegistration permits unregistered Team to register themselves.
AllowRegistration bool `json:"allowRegistration,omitempty"` AllowRegistration bool `json:"allowRegistration,omitempty"`