From 5e262b75a37ae1a2a10b656b8063f22a067825e8 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 27 Mar 2025 16:25:48 +0100 Subject: [PATCH 1/9] admin: Can list independant exercices as theme --- admin/api/theme.go | 20 ++++++++++++++------ admin/static/views/theme.html | 4 ++-- libfic/exercice.go | 11 +++++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/admin/api/theme.go b/admin/api/theme.go index 37668ffd..95e3f7ca 100644 --- a/admin/api/theme.go +++ b/admin/api/theme.go @@ -70,13 +70,17 @@ func ThemeHandler(c *gin.Context) { return } - theme, err := fic.GetTheme(thid) - if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Theme not found"}) - return - } + if thid == 0 { + c.Set("theme", &fic.Theme{Name: "Exercices indépendants", Path: sync.StandaloneExercicesDirectory}) + } else { + theme, err := fic.GetTheme(thid) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Theme not found"}) + return + } - c.Set("theme", theme) + c.Set("theme", theme) + } c.Next() } @@ -127,6 +131,10 @@ func listThemes(c *gin.Context) { return } + if has, _ := fic.HasStandaloneExercice(); has { + themes = append([]*fic.Theme{&fic.Theme{Name: "Exercices indépendants", Path: sync.StandaloneExercicesDirectory}}, themes...) + } + c.JSON(http.StatusOK, themes) } diff --git a/admin/static/views/theme.html b/admin/static/views/theme.html index 831a54f8..c4495c92 100644 --- a/admin/static/views/theme.html +++ b/admin/static/views/theme.html @@ -8,7 +8,7 @@
-
+
@@ -25,7 +25,7 @@
-
+

Exercices ({{ exercices.length }}) diff --git a/libfic/exercice.go b/libfic/exercice.go index d6b38121..3401f0ae 100644 --- a/libfic/exercice.go +++ b/libfic/exercice.go @@ -645,3 +645,14 @@ func (e *Exercice) IsSolved() (int, *time.Time) { 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 + } +} From 74f388a2b981df67d02956b64d6aaad56fa6a4a1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 28 Mar 2025 13:09:13 +0100 Subject: [PATCH 2/9] admin: Check all theme/exercice attribute are in sync with repo --- admin/api/exercice.go | 348 ++++++++++++++++++++++++++++++- admin/api/sync.go | 33 ++- admin/api/theme.go | 115 +++++++++- admin/main.go | 12 +- admin/static.go | 2 - admin/static/js/app.js | 45 ++++ admin/static/views/exercice.html | 18 +- admin/static/views/sync.html | 38 ++++ admin/static/views/theme.html | 18 +- admin/sync/errors.go | 2 +- admin/sync/exercice_files.go | 87 +++++--- admin/sync/exercice_hints.go | 37 ++-- admin/sync/exercice_keys.go | 40 ++-- admin/sync/exercices.go | 95 +++++---- admin/sync/full.go | 8 +- admin/sync/themes.go | 44 ++-- libfic/hint.go | 4 + libfic/theme.go | 14 +- 18 files changed, 818 insertions(+), 142 deletions(-) diff --git a/admin/api/exercice.go b/admin/api/exercice.go index afe6b8d7..024fd8b1 100644 --- a/admin/api/exercice.go +++ b/admin/api/exercice.go @@ -1,9 +1,11 @@ package api import ( + "bytes" "fmt" "log" "net/http" + "path" "reflect" "strconv" "strings" @@ -32,6 +34,8 @@ func declareExercicesRoutes(router *gin.RouterGroup) { apiExercicesRoutes.PATCH("", partUpdateExercice) apiExercicesRoutes.DELETE("", deleteExercice) + apiExercicesRoutes.POST("/diff-sync", APIDiffExerciceWithRemote) + apiExercicesRoutes.GET("/stats.json", getExerciceStats) apiExercicesRoutes.GET("/history.json", getExerciceHistory) @@ -91,8 +95,8 @@ func declareExercicesRoutes(router *gin.RouterGroup) { // Remote 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/hints", sync.ApiGetRemoteExerciceHints) } type Exercice struct { @@ -130,7 +134,7 @@ func ExerciceHandler(c *gin.Context) { c.Set("theme", theme) } 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() 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) +} diff --git a/admin/api/sync.go b/admin/api/sync.go index 0bcba713..225b6e96 100644 --- a/admin/api/sync.go +++ b/admin/api/sync.go @@ -79,12 +79,14 @@ func declareSyncRoutes(router *gin.RouterGroup) { c.JSON(http.StatusOK, r) }) + apiSyncRoutes.POST("/local-diff", APIDiffDBWithRemote) + apiSyncDeepRoutes := apiSyncRoutes.Group("/deep/:thid") apiSyncDeepRoutes.Use(ThemeHandler) // Special route to handle standalone exercices apiSyncRoutes.POST("/deep/0", func(c *gin.Context) { 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()) } sync.EditDeepReport(&sync.SyncReport{Exercices: st}, false) @@ -378,3 +380,32 @@ func autoSync(c *gin.Context) { 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) +} diff --git a/admin/api/theme.go b/admin/api/theme.go index 95e3f7ca..c1057397 100644 --- a/admin/api/theme.go +++ b/admin/api/theme.go @@ -5,7 +5,9 @@ import ( "log" "net/http" "path" + "reflect" "strconv" + "strings" "srs.epita.fr/fic-server/admin/sync" "srs.epita.fr/fic-server/libfic" @@ -48,6 +50,8 @@ func declareThemesRoutes(router *gin.RouterGroup) { apiThemesRoutes.PUT("", updateTheme) apiThemesRoutes.DELETE("", deleteTheme) + apiThemesRoutes.POST("/diff-sync", APIDiffThemeWithRemote) + apiThemesRoutes.GET("/exercices_stats.json", getThemedExercicesStats) declareExercicesRoutes(apiThemesRoutes) @@ -71,7 +75,7 @@ func ThemeHandler(c *gin.Context) { } if thid == 0 { - c.Set("theme", &fic.Theme{Name: "Exercices indépendants", Path: sync.StandaloneExercicesDirectory}) + c.Set("theme", &fic.StandaloneExercicesTheme) } else { theme, err := fic.GetTheme(thid) if err != nil { @@ -132,7 +136,7 @@ func listThemes(c *gin.Context) { } if has, _ := fic.HasStandaloneExercice(); has { - themes = append([]*fic.Theme{&fic.Theme{Name: "Exercices indépendants", Path: sync.StandaloneExercicesDirectory}}, themes...) + themes = append([]*fic.Theme{&fic.StandaloneExercicesTheme}, themes...) } c.JSON(http.StatusOK, themes) @@ -263,3 +267,110 @@ func getThemedExercicesStats(c *gin.Context) { } 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) +} diff --git a/admin/main.go b/admin/main.go index 47d31c72..06470166 100644 --- a/admin/main.go +++ b/admin/main.go @@ -199,10 +199,10 @@ func main() { } 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) - if err == nil { + challengeinfo, err := sync.GetFileContent(sync.GlobalImporter, settings.ChallengeFile) + 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 { log.Fatal("Unable to open SETTINGS/challenge.json:", err) } else { @@ -213,6 +213,10 @@ func main() { } } } + + if ci, err := settings.ReadChallengeInfo(challengeinfo); err == nil { + fic.StandaloneExercicesTheme.Authors = ci.Authors + } } } diff --git a/admin/static.go b/admin/static.go index 3a2bd877..82772993 100644 --- a/admin/static.go +++ b/admin/static.go @@ -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 + ".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{ "Content-Encoding": "gzip", }) @@ -136,7 +135,6 @@ func declareStaticRoutes(router *gin.RouterGroup, cfg *settings.Settings, baseUR } } - log.Println(filepath) c.File(filepath) }) router.GET("/submissions/*_", func(c *gin.Context) { diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 315c9a10..19a3f518 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -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) { @@ -1819,6 +1834,21 @@ angular.module("FICApp") $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) { @@ -1998,6 +2028,21 @@ angular.module("FICApp") $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 () { var tid = $scope.exercice.id_theme; diff --git a/admin/static/views/exercice.html b/admin/static/views/exercice.html index 1236261d..2077395e 100644 --- a/admin/static/views/exercice.html +++ b/admin/static/views/exercice.html @@ -10,11 +10,27 @@
Vidéo Flags - +
+ + +
Voir sur la forge

+
+

Différences par rapport au dépôt

+
+ {{ diffline.field }} +
+
-{{ diffline.be }}
+
+{{ diffline.af }}
+
+
+
+ +
+
diff --git a/admin/static/views/sync.html b/admin/static/views/sync.html index 106cc6c3..886a28cd 100644 --- a/admin/static/views/sync.html +++ b/admin/static/views/sync.html @@ -120,3 +120,41 @@
+ +
+
+ +

+ Différences avec le dépôts +

+
+
+
Lancez la génération du rapport pour lister les différences.
+
+
+
+

+ {{ th }} +

+
+ + + + +
+
+ +
+
diff --git a/admin/static/views/theme.html b/admin/static/views/theme.html index c4495c92..5c17fbb6 100644 --- a/admin/static/views/theme.html +++ b/admin/static/views/theme.html @@ -7,6 +7,19 @@
+
+

Différences par rapport au dépôt

+
+ {{ diffline.field }} +
+
-{{ diffline.be }}
+
+{{ diffline.af }}
+
+
+
+ +
+
@@ -29,7 +42,10 @@

Exercices ({{ exercices.length }}) - +
+ + +

diff --git a/admin/sync/errors.go b/admin/sync/errors.go index a422ffe1..b0e2a015 100644 --- a/admin/sync/errors.go +++ b/admin/sync/errors.go @@ -23,7 +23,7 @@ func NewThemeError(theme *fic.Theme, err error) *ThemeError { if theme == nil { return &ThemeError{ error: err, - ThemePath: StandaloneExercicesDirectory, + ThemePath: fic.StandaloneExercicesDirectory, } } diff --git a/admin/sync/exercice_files.go b/admin/sync/exercice_files.go index fe37eba1..276a72e4 100644 --- a/admin/sync/exercice_files.go +++ b/admin/sync/exercice_files.go @@ -420,36 +420,67 @@ func SyncExerciceFiles(i Importer, exercice *fic.Exercice, exceptions *CheckExce return } +func GetRemoteExerciceFiles(thid, exid string) ([]*fic.EFile, error) { + theme, exceptions, errs := BuildTheme(GlobalImporter, thid) + if theme == nil { + return nil, errs + } + + 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") + if files == nil { + return nil, errs + } + + var ret []*fic.EFile + for _, fname := range files { + fPath := path.Join(exercice.Path, "files", fname) + fSize, _ := GetFileSize(GlobalImporter, fPath) + + file := fic.EFile{ + Path: fPath, + Name: fname, + Checksum: digests[fname], + Size: fSize, + Published: true, + } + + if d, exists := digests[strings.TrimSuffix(file.Name, ".gz")]; exists { + file.Name = strings.TrimSuffix(file.Name, ".gz") + file.Path = strings.TrimSuffix(file.Path, ".gz") + file.ChecksumShown = d + } + + ret = append(ret, &file) + } + + // 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) { - 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 { - files, digests, errs := BuildFilesListInto(GlobalImporter, exercice, "files") - if files != nil { - var ret []*fic.EFile - for _, fname := range files { - fPath := path.Join(exercice.Path, "files", fname) - fSize, _ := GetFileSize(GlobalImporter, fPath) - ret = append(ret, &fic.EFile{ - Path: fPath, - Name: fname, - Checksum: digests[fname], - Size: fSize, - }) - } - c.JSON(http.StatusOK, ret) - } else { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Errorf("%q", errs)}) - return - } - } else { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Errorf("%q", errs)}) - return - } - } else { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Errorf("%q", errs)}) + files, err := GetRemoteExerciceFiles(c.Params.ByName("thid"), c.Params.ByName("exid")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) return } + + c.JSON(http.StatusOK, files) } diff --git a/admin/sync/exercice_hints.go b/admin/sync/exercice_hints.go index ccb8fc9d..acb40bd6 100644 --- a/admin/sync/exercice_hints.go +++ b/admin/sync/exercice_hints.go @@ -169,25 +169,32 @@ func SyncExerciceHints(i Importer, exercice *fic.Exercice, flagsBindings map[int 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. func ApiGetRemoteExerciceHints(c *gin.Context) { - theme, exceptions, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid")) - 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 { - c.JSON(http.StatusOK, hints) - return - } - - c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs)) - return - } - + hints, errs := GetRemoteExerciceHints(c.Params.ByName("thid"), c.Params.ByName("exid")) + if hints != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs)) return } - c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs)) + c.JSON(http.StatusOK, hints) } diff --git a/admin/sync/exercice_keys.go b/admin/sync/exercice_keys.go index d4a88f3c..e62dcf34 100644 --- a/admin/sync/exercice_keys.go +++ b/admin/sync/exercice_keys.go @@ -699,26 +699,32 @@ func SyncExerciceFlags(i Importer, exercice *fic.Exercice, exceptions *CheckExce 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. func ApiGetRemoteExerciceFlags(c *gin.Context) { - theme, exceptions, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid")) - if theme != nil { - exercice, _, _, eexceptions, _, errs := BuildExercice(GlobalImporter, theme, path.Join(theme.Path, c.Params.ByName("exid")), nil, exceptions) - if exercice != nil { - flags, errs := CheckExerciceFlags(GlobalImporter, exercice, []string{}, eexceptions) - if flags != nil { - c.JSON(http.StatusOK, flags) - return - } - - c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs)) - return - } - - c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs)) + flags, err := GetRemoteExerciceFlags(c.Params.ByName("thid"), c.Params.ByName("exid")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) return } - c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs)) - return + c.JSON(http.StatusOK, flags) } diff --git a/admin/sync/exercices.go b/admin/sync/exercices.go index 158e96ba..49e1eef9 100644 --- a/admin/sync/exercices.go +++ b/admin/sync/exercices.go @@ -63,10 +63,17 @@ func buildDependancyMap(i Importer, theme *fic.Theme) (dmap map[int64]*fic.Exerc 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 e, err = theme.GetExerciceByTitle(ename) if err != nil { - return + return dmap, fmt.Errorf("unable to GetExerciceByTitle(ename=%q, tid=%d): %w", ename, theme.Id, err) } dmap[int64(eid)] = e @@ -468,59 +475,57 @@ func SyncExercices(i Importer, theme *fic.Theme, exceptions *CheckExceptions) (e 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. func ApiListRemoteExercices(c *gin.Context) { - if c.Params.ByName("thid") == "_" { - exercices, err := GetExercices(GlobalImporter, &fic.Theme{Path: StandaloneExercicesDirectory}) - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) - return - } - - c.JSON(http.StatusOK, exercices) + exercices, err := ListRemoteExercices(c.Params.ByName("thid")) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) return } - theme, _, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid")) - if theme != nil { - exercices, err := GetExercices(GlobalImporter, theme) - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) - return - } - - c.JSON(http.StatusOK, exercices) - } else { - c.AbortWithStatusJSON(http.StatusInternalServerError, fmt.Errorf("%q", errs)) - return - } + c.JSON(http.StatusOK, exercices) } -// ApiListRemoteExercice is an accessor letting foreign packages to access remote exercice attributes. +func GetRemoteExercice(thid, exid string, inTheme *fic.Theme) (*fic.Exercice, error) { + if thid == fic.StandaloneExercicesDirectory || thid == "_" { + exercice, _, _, _, _, errs := BuildExercice(GlobalImporter, nil, path.Join(fic.StandaloneExercicesDirectory, exid), nil, nil) + return exercice, errs + } + + theme, exceptions, errs := BuildTheme(GlobalImporter, thid) + if theme == nil { + return nil, fmt.Errorf("Theme not found") + } + + 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) { - if c.Params.ByName("thid") == "_" { - exercice, _, _, _, _, errs := BuildExercice(GlobalImporter, nil, path.Join(StandaloneExercicesDirectory, c.Params.ByName("exid")), nil, nil) - if exercice != nil { - c.JSON(http.StatusOK, exercice) - return - } else { - c.JSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Errorf("%q", errs)}) - 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 - } + exercice, err := GetRemoteExercice(c.Params.ByName("thid"), c.Params.ByName("exid"), nil) + if exercice != nil { + c.JSON(http.StatusOK, exercice) + return } else { - c.JSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Errorf("%q", errs)}) + c.JSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) return } } diff --git a/admin/sync/full.go b/admin/sync/full.go index b4b9e661..275a1e83 100644 --- a/admin/sync/full.go +++ b/admin/sync/full.go @@ -75,8 +75,8 @@ func SpeedySyncDeep(i Importer) (errs SyncReport) { if themes, err := fic.GetThemes(); err == nil { DeepSyncProgress = 2 - if i.Exists(StandaloneExercicesDirectory) { - themes = append(themes, &fic.Theme{Path: StandaloneExercicesDirectory}) + if i.Exists(fic.StandaloneExercicesDirectory) { + themes = append(themes, &fic.StandaloneExercicesTheme) } var themeStep uint8 = uint8(250) / uint8(len(themes)) @@ -147,8 +147,8 @@ func SyncDeep(i Importer) (errs SyncReport) { DeepSyncProgress = 2 // Also synchronize standalone exercices - if i.Exists(StandaloneExercicesDirectory) { - themes = append(themes, &fic.Theme{Path: StandaloneExercicesDirectory}) + if i.Exists(fic.StandaloneExercicesDirectory) { + themes = append(themes, &fic.StandaloneExercicesTheme) } var themeStep uint8 = uint8(250) / uint8(len(themes)) diff --git a/admin/sync/themes.go b/admin/sync/themes.go index 1605233d..20b3c114 100644 --- a/admin/sync/themes.go +++ b/admin/sync/themes.go @@ -22,15 +22,13 @@ import ( "srs.epita.fr/fic-server/libfic" ) -const StandaloneExercicesDirectory = "exercices" - // GetThemes returns all theme directories in the base directory. func GetThemes(i Importer) (themes []string, err error) { if dirs, err := i.ListDir("/"); err != nil { return nil, err } else { 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 { 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) if authors, err := getAuthors(i, tdir); err != nil { - errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("unable to get AUTHORS.txt: %w", err))) - return nil, nil, errs + if tdir != fic.StandaloneExercicesDirectory { + errs = multierr.Append(errs, NewThemeError(th, fmt.Errorf("unable to get AUTHORS.txt: %w", err))) + return nil, nil, errs + } } else { // Format authors th.Authors = strings.Join(authors, ", ") @@ -355,18 +355,34 @@ func ApiListRemoteThemes(c *gin.Context) { 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. func ApiGetRemoteTheme(c *gin.Context) { - if c.Params.ByName("thid") == "_" { - c.Status(http.StatusNoContent) + var theme *fic.Theme + 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 } - r, _, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid")) - if r == nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Errorf("%q", errs)}) - return - } - - c.JSON(http.StatusOK, r) + c.JSON(http.StatusOK, theme) } diff --git a/libfic/hint.go b/libfic/hint.go index db754e07..f6297817 100644 --- a/libfic/hint.go +++ b/libfic/hint.go @@ -46,6 +46,10 @@ func GetHint(id int64) (*EHint, error) { return h, nil } +func (h *EHint) TreatHintContent() { + treatHintContent(h) +} + // GetHint retrieves the hint with the given id. func (e *Exercice) GetHint(id int64) (*EHint, error) { h := &EHint{} diff --git a/libfic/theme.go b/libfic/theme.go index 1b7b808f..fa4cc909 100644 --- a/libfic/theme.go +++ b/libfic/theme.go @@ -2,6 +2,14 @@ package fic 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 type Theme struct { Id int64 `json:"id"` @@ -62,11 +70,7 @@ func GetThemesExtended() ([]*Theme, error) { return nil, err } else { // Append standalone exercices fake-themes - stdthm := &Theme{ - Name: "Défis indépendants", - URLId: "_", - Path: "exercices", - } + stdthm := &StandaloneExercicesTheme if exercices, err := stdthm.GetExercices(); err == nil && len(exercices) > 0 { themes = append(themes, stdthm) From 4ca2bc106af65749b83bf5e890db68643b8b0b4d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 28 Mar 2025 13:32:59 +0100 Subject: [PATCH 3/9] admin: Add doc around settings --- admin/static/views/settings.html | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/admin/static/views/settings.html b/admin/static/views/settings.html index 354f5b84..eac4bcf4 100644 --- a/admin/static/views/settings.html +++ b/admin/static/views/settings.html @@ -85,21 +85,21 @@
-
+
- +
-
- +
+
-
- +
+
From 8e196136c3052df860db9d61b5b176933502749e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 28 Mar 2025 13:33:09 +0100 Subject: [PATCH 4/9] admin: Can gain points for each question answered // partial exercice solved --- admin/api/settings.go | 2 ++ admin/static/views/settings.html | 6 ++++++ admin/static/views/team-score.html | 7 +++++-- checker/main.go | 1 + frontend/fic/src/lib/components/ScoreGrid.svelte | 7 ++++++- generator/main.go | 3 ++- libfic/stats.go | 7 ++++++- settings/settings.go | 2 ++ 8 files changed, 30 insertions(+), 5 deletions(-) diff --git a/admin/api/settings.go b/admin/api/settings.go index 0ea7f0ea..c16d698d 100644 --- a/admin/api/settings.go +++ b/admin/api/settings.go @@ -310,6 +310,7 @@ func ApplySettings(config *settings.Settings) { fic.SubmissionCostBase = config.SubmissionCostBase fic.SubmissionUniqueness = config.SubmissionUniqueness fic.CountOnlyNotGoodTries = config.CountOnlyNotGoodTries + fic.QuestionGainRatio = config.QuestionGainRatio if config.DiscountedFactor != fic.DiscountedFactor { fic.DiscountedFactor = config.DiscountedFactor @@ -329,6 +330,7 @@ func ResetSettings() error { WChoiceCurCoefficient: 1, GlobalScoreCoefficient: 1, DiscountedFactor: 0, + QuestionGainRatio: 0, UnlockedStandaloneExercices: 10, UnlockedStandaloneExercicesByThemeStepValidation: 1, UnlockedStandaloneExercicesByStandaloneExerciceValidation: 0, diff --git a/admin/static/views/settings.html b/admin/static/views/settings.html index eac4bcf4..95b5a612 100644 --- a/admin/static/views/settings.html +++ b/admin/static/views/settings.html @@ -104,6 +104,12 @@
+
+ +
+ +
+

diff --git a/admin/static/views/team-score.html b/admin/static/views/team-score.html index 96ca8321..2b4617ff 100644 --- a/admin/static/views/team-score.html +++ b/admin/static/views/team-score.html @@ -13,7 +13,7 @@ Date - + {{ exercice.title }} @@ -23,9 +23,12 @@ {{ row.points * row.coeff }} - + {{ row.points }} * {{ row.coeff }} + + {{ row.points }} * {{ settings.questionGainRatio }} / {{ settings.questionGainRatio / row.coeff }} + {{ row.time | date:"mediumTime" }} diff --git a/checker/main.go b/checker/main.go index 03befb8d..1e16b1a7 100644 --- a/checker/main.go +++ b/checker/main.go @@ -90,6 +90,7 @@ func reloadSettings(config *settings.Settings) { fic.GlobalScoreCoefficient = config.GlobalScoreCoefficient fic.CountOnlyNotGoodTries = config.CountOnlyNotGoodTries fic.DiscountedFactor = config.DiscountedFactor + fic.QuestionGainRatio = config.QuestionGainRatio } func main() { diff --git a/frontend/fic/src/lib/components/ScoreGrid.svelte b/frontend/fic/src/lib/components/ScoreGrid.svelte index 1f9e6eba..8fb927cf 100644 --- a/frontend/fic/src/lib/components/ScoreGrid.svelte +++ b/frontend/fic/src/lib/components/ScoreGrid.svelte @@ -10,6 +10,7 @@ import DateFormat from '$lib/components/DateFormat.svelte'; import { my } from '$lib/stores/my.js'; + import { settings } from '$lib/stores/settings.js'; import { themes, exercices_idx } from '$lib/stores/themes.js'; let req = null; @@ -55,6 +56,10 @@ {:else if row.reason == "Display choices"} Échange champ de texte contre liste de choix + {:else if row.reason.startsWith("Response ")} + {@const fields = row.reason.split(" ")} + + Validation {fields[1]} {:else} {row.reason} @@ -66,7 +71,7 @@ {/if} - {Math.trunc(10*row.points)/10} × {row.coeff} + {Math.trunc(10*row.points)/10} × {#if row.reason.startsWith("Response ")}{$settings.questionGainRatio} ÷ {$settings.questionGainRatio / row.coeff}{:else}{row.coeff}{/if} {Math.trunc(10*row.points * row.coeff)/10} diff --git a/generator/main.go b/generator/main.go index 050bfbc9..de5ddba5 100644 --- a/generator/main.go +++ b/generator/main.go @@ -30,7 +30,7 @@ func reloadSettings(config *settings.Settings) { fic.WChoiceCoefficient = config.WChoiceCurCoefficient fic.ExerciceCurrentCoefficient = config.ExerciceCurCoefficient 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 fic.PartialValidation = config.PartialValidation @@ -48,6 +48,7 @@ func reloadSettings(config *settings.Settings) { fic.CountOnlyNotGoodTries = config.CountOnlyNotGoodTries fic.HideCaseSensitivity = config.HideCaseSensitivity fic.DiscountedFactor = config.DiscountedFactor + fic.QuestionGainRatio = config.QuestionGainRatio if !skipInitialGeneration { log.Println("Generating files...") diff --git a/libfic/stats.go b/libfic/stats.go index e1ce1f24..0fabac49 100644 --- a/libfic/stats.go +++ b/libfic/stats.go @@ -23,6 +23,9 @@ var CountOnlyNotGoodTries = false // DiscountedFactor stores the percentage of the exercice's gain lost on each validation. 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 { tries_table := "exercice_tries" if SubmissionUniqueness { @@ -39,8 +42,10 @@ func exoptsQuery(whereExo string) string { } query := `SELECT S.id_team, S.time, E.gain AS points, coeff, S.reason, S.id_exercice FROM ( + 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) 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) 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 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 - SELECT id_team, id_exercice, time, coefficient AS coeff, "Validation" AS reason FROM exercice_solved + 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 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` diff --git a/settings/settings.go b/settings/settings.go index ecec2ce9..0fe36d92 100644 --- a/settings/settings.go +++ b/settings/settings.go @@ -46,6 +46,8 @@ type Settings struct { GlobalScoreCoefficient float64 `json:"globalScoreCoefficient"` // DiscountedFactor stores the percentage of the exercice's gain lost on each validation. 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 bool `json:"allowRegistration,omitempty"` From 5ba86d0c5f85ef404989f72d65678e38e335485e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 28 Mar 2025 15:54:13 +0100 Subject: [PATCH 5/9] admin: Refactor rank query by extracting optional query parts --- libfic/stats.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/libfic/stats.go b/libfic/stats.go index 0fabac49..609f583a 100644 --- a/libfic/stats.go +++ b/libfic/stats.go @@ -41,10 +41,20 @@ func exoptsQuery(whereExo string) string { 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) 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) 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 ( - 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) 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) 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 - 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 + ` + ` + 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 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 From 71120c1c891d349b36bd2fb6076420797012644d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 28 Mar 2025 16:49:10 +0100 Subject: [PATCH 6/9] frontend: Improve rules --- frontend/fic/src/routes/rules/+page.svelte | 38 ++++++++++++++++------ 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/frontend/fic/src/routes/rules/+page.svelte b/frontend/fic/src/routes/rules/+page.svelte index 166880a2..a10814bb 100644 --- a/frontend/fic/src/routes/rules/+page.svelte +++ b/frontend/fic/src/routes/rules/+page.svelte @@ -21,7 +21,7 @@
-
+

Débloquage des challenges

Au début, seul le premier défi de chaque scénario est @@ -31,7 +31,7 @@ {#if $settings.unlockedStandaloneExercicesByThemeStepValidation > 0 || $settings.unlockedStandaloneExercicesByStandaloneExerciceValidation > 0}

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

@@ -55,6 +55,21 @@ proposés. Plus le challenge est compliqué, plus il rapporte de points.

+ {#if $settings.questionGainRatio != 0} +

+ 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} % des points du défi sont répartis à parts égales entre toutes les questions. +

+

+ 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 :
+ 20 × {Math.trunc($settings.questionGainRatio * 1000)/10} % ÷ 5 × 3 = {Math.trunc(20 * $settings.questionGainRatio / 5 * 3 * 100)/100} points. +

+

+ Les {Math.trunc(1000 - $settings.questionGainRatio * 1000)/10} % restants sont obtenus à la validation complète du défi. +

+ {/if} + + {#if $settings.submissionCostBase != 0}

Coût des tentatives

Vous disposez de 10 tentatives pour trouver la/les solutions d'un @@ -114,20 +129,21 @@ parmi ce nombre de tentatives.

{/if} + {/if}
-
+
{#if $settings.discountedFactor > 0}

Décote des gains

- 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.

- 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 : le gain affiché est un gain maximum que vous obtiendriez si aucune autre équipe ne valide cette étape.

- Chaque validation réduit de {$settings.discountedFactor*100} % la cote de l'exercice. + Chaque validation réduit de {$settings.discountedFactor*100} % la cote de l'exercice.

Ainsi, pour un exercice d'une valeur initiale de {10*$settings.globalScoreCoefficient} points : @@ -192,10 +208,12 @@ défi.

-

Prem's

-

- Un bonus de +{$settings.firstBlood * 100} % est attribué à la première équipe qui résout un défi. -

+ {#if $settings.firstBlood} +

Prem's

+

+ Un bonus de +{$settings.firstBlood * 100} % est attribué à la première équipe qui résout un défi. +

+ {/if}

Bonus temporaires

From bf2be00f15a1c3bed51d553f52ddbdf14ece429c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 28 Mar 2025 16:49:22 +0100 Subject: [PATCH 7/9] Indicate flag order in grid-score --- frontend/fic/src/lib/components/ScoreGrid.svelte | 6 +++--- libfic/stats.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/fic/src/lib/components/ScoreGrid.svelte b/frontend/fic/src/lib/components/ScoreGrid.svelte index 8fb927cf..1ed3909b 100644 --- a/frontend/fic/src/lib/components/ScoreGrid.svelte +++ b/frontend/fic/src/lib/components/ScoreGrid.svelte @@ -58,8 +58,8 @@ Échange champ de texte contre liste de choix {:else if row.reason.startsWith("Response ")} {@const fields = row.reason.split(" ")} - - Validation {fields[1]} + + Validation {fields[1]} no {fields[3]} {:else} {row.reason} @@ -71,7 +71,7 @@ {/if} - {Math.trunc(10*row.points)/10} × {#if row.reason.startsWith("Response ")}{$settings.questionGainRatio} ÷ {$settings.questionGainRatio / row.coeff}{:else}{row.coeff}{/if} + {Math.trunc(10*row.points)/10} × {#if row.reason.startsWith("Response ")}{Math.trunc($settings.questionGainRatio * 1000)/10} % ÷ {$settings.questionGainRatio / row.coeff}{:else if row.reason == "Validation" && $settings.questionGainRatio != 0}({row.coeff + $settings.questionGainRatio}{Math.trunc($settings.questionGainRatio * 1000)/10} %){:else}{row.coeff}{/if} {Math.trunc(10*row.points * row.coeff)/10} diff --git a/libfic/stats.go b/libfic/stats.go index 609f583a..cf37f407 100644 --- a/libfic/stats.go +++ b/libfic/stats.go @@ -43,8 +43,8 @@ func exoptsQuery(whereExo string) string { 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) 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) 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` + 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 := "" From f841d9c11c637cb2fa3c7392a7460fc2b7189430 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 28 Mar 2025 17:55:10 +0100 Subject: [PATCH 8/9] frontend: Mark bad submissions as invalid --- .../src/lib/components/ExerciceFlags.svelte | 19 +++++++++++++++---- .../fic/src/lib/components/FlagKey.svelte | 6 ++++++ .../fic/src/lib/components/FlagMCQ.svelte | 5 ++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/frontend/fic/src/lib/components/ExerciceFlags.svelte b/frontend/fic/src/lib/components/ExerciceFlags.svelte index a5da7862..a84360e4 100644 --- a/frontend/fic/src/lib/components/ExerciceFlags.svelte +++ b/frontend/fic/src/lib/components/ExerciceFlags.svelte @@ -28,11 +28,13 @@ export let readonly = false; export let forcesolved = false; + let last_submission = { }; let responses = { }; async function submitFlags() { waitInProgress.set(true); sberr = ""; message = ""; + last_submission = JSON.parse(JSON.stringify(responses)); if ($my && $my.team_id === 0) { let allGoodResponse = true; @@ -119,6 +121,11 @@ mcqs: { }, justifications: { }, }; + last_submission = { + flags: { }, + mcqs: { }, + justifications: { }, + }; } let last_exercice = null; @@ -152,10 +159,12 @@ {#if exercice.tries || exercice.solved_time || exercice.submitted || sberr || $timeouted} {#if exercice.solved_time || exercice.tries} - - {#if exercice.tries > 0}{exercice.tries} {exercice.tries==1?"tentative effectuée":"tentatives effectuées"}.{/if} - Dernière solution envoyée à . - +
+ + {#if exercice.tries > 0}{exercice.tries} {exercice.tries==1?"tentative effectuée":"tentatives effectuées"}.{/if} + Dernière solution envoyée le . + +
{/if} {#if exercice.solve_dist} @@ -191,6 +200,7 @@ @@ -199,6 +209,7 @@ class="mb-3" exercice_id={exercice.id} {flag} + previous_value={$waitInProgress ? "" : last_submission.flags[flag.id]} bind:value={responses.flags[flag.id]} /> {/if} diff --git a/frontend/fic/src/lib/components/FlagKey.svelte b/frontend/fic/src/lib/components/FlagKey.svelte index 7a0d8201..a1084965 100644 --- a/frontend/fic/src/lib/components/FlagKey.svelte +++ b/frontend/fic/src/lib/components/FlagKey.svelte @@ -16,6 +16,7 @@ export let exercice_id = 0; export let flag = { }; export let no_label = false; + export let previous_value = ""; export let value = ""; let values = [""]; @@ -141,6 +142,7 @@