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/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/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 37668ffd..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) @@ -70,13 +74,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.StandaloneExercicesTheme) + } 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 +135,10 @@ func listThemes(c *gin.Context) { return } + if has, _ := fic.HasStandaloneExercice(); has { + themes = append([]*fic.Theme{&fic.StandaloneExercicesTheme}, themes...) + } + c.JSON(http.StatusOK, themes) } @@ -255,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/settings.html b/admin/static/views/settings.html index 354f5b84..203f2b2f 100644 --- a/admin/static/views/settings.html +++ b/admin/static/views/settings.html @@ -64,46 +64,60 @@
- -
- +
+ +
+ +
- -
- +
+ +
+ +
- -
- +
+ +
+ +
- -
- +
+ +
+ +
-
+
- +
-
- +
+
-
- +
+
+
+ +
+ +
+

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/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/admin/static/views/theme.html b/admin/static/views/theme.html index 831a54f8..5c17fbb6 100644 --- a/admin/static/views/theme.html +++ b/admin/static/views/theme.html @@ -7,8 +7,21 @@
+
+

Différences par rapport au dépôt

+
+ {{ diffline.field }} +
+
-{{ diffline.be }}
+
+{{ diffline.af }}
+
+
+
+ +
+
- +
@@ -25,11 +38,14 @@
-
+

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