admin: Check all theme/exercice attribute are in sync with repo

This commit is contained in:
nemunaire 2025-03-28 13:09:13 +01:00
parent 5e262b75a3
commit 74f388a2b9
18 changed files with 818 additions and 142 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -127,7 +127,6 @@ func declareStaticRoutes(router *gin.RouterGroup, cfg *settings.Settings, baseUR
if st, err := os.Stat(filepath); os.IsNotExist(err) || st.Size() == 0 {
if st, err := os.Stat(filepath + ".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) {

View file

@ -923,6 +923,21 @@ angular.module("FICApp")
});
});
};
$scope.diffWithRepo = function () {
$scope.diff = null;
$http({
url: "api/sync/local-diff",
method: "POST"
}).then(function (response) {
$scope.diff = response.data;
if (response.data === null) {
$scope.addToast('success', 'Changements par rapport au dépôt', "Tout est pareil !");
}
}, function (response) {
$scope.diff = null;
$scope.addToast('danger', 'An error occurs when synchronizing exercice:', response.data.errmsg);
});
};
})
.controller("AuthController", function ($scope, $http) {
@ -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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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