From a6efe540f9776d6218e9d4d8fee469770bdd1216 Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Sat, 18 Jul 2026 17:41:00 +0200 Subject: [PATCH] feat(web): import a single assignment from YAML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add importAssignmentYAML(course, yaml): upsert one assignment into an existing course from a YAML snippet in the same keyed form it has in a course file (the single top-level key is the assignment name), so a block can be copy-pasted straight out of a course YAML instead of re-importing the whole course. - app.ImportAssignmentYAML merges the block into the stored course body, decodes the whole course via config.DecodeCourseBody, rejects inline signKeys, validates the assignment against the real resolver (a concrete one that does not resolve is rejected; an abstract base is fine), then persists — re-marshalling the course YAML, exactly like SetAssignment. - graph: importAssignmentYAML mutation + resolver. - tests: upsert keeps existing assignments; rejects multiple top-level keys and invalid names. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/app/import_assignment.go | 96 +++++++++++++++++++++++++ web/app/import_assignment_test.go | 60 ++++++++++++++++ web/graph/assignments.graphqls | 2 + web/graph/assignments.resolvers.go | 9 +++ web/graph/generated/generated.go | 108 ++++++++++++++++++++++++++--- 5 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 web/app/import_assignment.go create mode 100644 web/app/import_assignment_test.go diff --git a/web/app/import_assignment.go b/web/app/import_assignment.go new file mode 100644 index 0000000..2d12586 --- /dev/null +++ b/web/app/import_assignment.go @@ -0,0 +1,96 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/obcode/glabs/v3/config" + "gopkg.in/yaml.v3" +) + +// ImportAssignmentYAML upserts a single assignment into one of the caller's +// courses from a YAML snippet in the same keyed form it has in a course file: +// +// blatt3: +// per: student +// accesslevel: developer +// startercode: +// url: git@gitlab.lrz.de:... +// +// The snippet's single top-level key is the assignment name. The block is merged +// into the stored course, decoded and validated through the same resolver the +// editor uses (a concrete assignment that does not resolve is rejected), then +// persisted — re-marshalling the course YAML, exactly like SetAssignment. +func (a *App) ImportAssignmentYAML(ctx context.Context, course, assignmentYAML string) (*AssignmentView, error) { + stored, err := a.Course(ctx, course) + if err != nil { + return nil, err + } + + // The snippet must have exactly one top-level key — the assignment name. + var snippet map[string]any + if err := yaml.Unmarshal([]byte(assignmentYAML), &snippet); err != nil { + return nil, fmt.Errorf("cannot parse assignment YAML: %w", err) + } + switch len(snippet) { + case 1: + case 0: + return nil, fmt.Errorf("assignment YAML is empty: expected a single top-level key naming the assignment") + default: + return nil, fmt.Errorf("assignment YAML has %d top-level keys: expected exactly one, naming the assignment", len(snippet)) + } + var name string + var body any + for k, v := range snippet { + name, body = k, v + } + name = strings.TrimSpace(name) + if !nameRe.MatchString(name) { + return nil, fmt.Errorf("invalid assignment name %q: use only letters, digits, '.', '-' and '_'", name) + } + + // Merge the block into the stored course's body, then decode the whole course + // through the same path a course import uses. + bytes := stored.RawYAML + if len(bytes) == 0 { + if encoded, err := config.EncodeCourse(stored.Source); err == nil { + bytes = encoded + } + } + var top map[string]any + if err := yaml.Unmarshal(bytes, &top); err != nil { + return nil, fmt.Errorf("cannot parse stored course: %w", err) + } + courseBody, ok := top[course].(map[string]any) + if !ok { + return nil, fmt.Errorf("stored course %q has an unexpected shape", course) + } + courseBody[name] = body + + source, _, err := config.DecodeCourseBody(course, courseBody) + if err != nil { + return nil, fmt.Errorf("invalid assignment: %w", err) + } + if err := rejectInlineSignKeys(source); err != nil { + return nil, err + } + + // Reject a concrete assignment that does not resolve (an abstract base is fine). + if vr := a.validateDrafted(source, course, name, source.Assignments[name]); !vr.OK { + return nil, fmt.Errorf("assignment %q is not valid: %s", name, strings.Join(vr.Errors, "; ")) + } + + raw, err := config.EncodeCourse(source) + if err != nil { + return nil, err + } + stored.Source = source + stored.RawYAML = raw + stored.UpdatedAt = time.Now() + if err := a.db.SaveCourse(ctx, stored); err != nil { + return nil, err + } + return a.Assignment(ctx, course, name) +} diff --git a/web/app/import_assignment_test.go b/web/app/import_assignment_test.go new file mode 100644 index 0000000..cc3da69 --- /dev/null +++ b/web/app/import_assignment_test.go @@ -0,0 +1,60 @@ +package app + +import ( + "strings" + "testing" +) + +func TestImportAssignmentYAML_upsertsAndKeepsOthers(t *testing.T) { + const owner = "prof@hm.edu" + fs := newFakeStore() + fs.courses[owner+"/uc"] = storedCourse(t, owner, urlsCourse) + a := &App{db: fs, gitlabHost: "https://gl"} + ctx := ctxAs(owner) + + snippet := "blatt2:\n per: student\n accesslevel: developer\n students:\n - c@hm.edu\n" + view, err := a.ImportAssignmentYAML(ctx, "uc", snippet) + if err != nil { + t.Fatalf("ImportAssignmentYAML: %v", err) + } + if view == nil || view.Name != "blatt2" { + t.Fatalf("view = %+v, want name blatt2", view) + } + + // The new assignment is stored and resolvable... + got, err := a.Assignment(ctx, "uc", "blatt2") + if err != nil { + t.Fatalf("Assignment(blatt2): %v", err) + } + if got == nil || got.ResolveError != "" { + t.Fatalf("blatt2 not stored/resolvable: %+v", got) + } + // ...and the pre-existing assignment is untouched. + if b1, _ := a.Assignment(ctx, "uc", "blatt1"); b1 == nil { + t.Error("blatt1 was lost by the import") + } +} + +func TestImportAssignmentYAML_rejectsMultipleTopKeys(t *testing.T) { + const owner = "prof@hm.edu" + fs := newFakeStore() + fs.courses[owner+"/uc"] = storedCourse(t, owner, urlsCourse) + a := &App{db: fs, gitlabHost: "https://gl"} + + _, err := a.ImportAssignmentYAML(ctxAs(owner), "uc", "a:\n per: student\nb:\n per: group\n") + if err == nil || !strings.Contains(err.Error(), "exactly one") { + t.Fatalf("error = %v, want a single-top-level-key error", err) + } +} + +func TestImportAssignmentYAML_rejectsBadName(t *testing.T) { + const owner = "prof@hm.edu" + fs := newFakeStore() + fs.courses[owner+"/uc"] = storedCourse(t, owner, urlsCourse) + a := &App{db: fs, gitlabHost: "https://gl"} + + _, err := a.ImportAssignmentYAML(ctxAs(owner), "uc", "bad name:\n per: student\n") + if err == nil || !strings.Contains(err.Error(), "invalid assignment name") { + t.Fatalf("error = %v, want an invalid-name error", err) + } +} diff --git a/web/graph/assignments.graphqls b/web/graph/assignments.graphqls index f6bfad2..42e3322 100644 --- a/web/graph/assignments.graphqls +++ b/web/graph/assignments.graphqls @@ -132,6 +132,8 @@ type RepoUrl { extend type Mutation { "Apply a draft to one of the caller's assignments, creating it if it does not exist: validate, then persist (re-marshals the course YAML). Rejects a concrete draft that does not resolve." setAssignment(course: String!, name: String!, draft: [FieldValueInput!]!): AssignmentView! + "Import a single assignment into one of the caller's courses from a YAML snippet keyed by the assignment name (as it appears in a course file), upserting it: validate against the real resolver, then persist. Rejects a concrete assignment that does not resolve." + importAssignmentYAML(course: String!, yaml: String!): AssignmentView! "Delete one assignment from one of the caller's courses. Returns false if there was no such assignment." deleteAssignment(course: String!, name: String!): Boolean! } diff --git a/web/graph/assignments.resolvers.go b/web/graph/assignments.resolvers.go index b085add..9b18192 100644 --- a/web/graph/assignments.resolvers.go +++ b/web/graph/assignments.resolvers.go @@ -23,6 +23,15 @@ func (r *mutationResolver) SetAssignment(ctx context.Context, course string, nam return toGraphAssignmentView(view), nil } +// ImportAssignmentYaml is the resolver for the importAssignmentYAML field. +func (r *mutationResolver) ImportAssignmentYaml(ctx context.Context, course string, yaml string) (*model.AssignmentView, error) { + view, err := r.app.ImportAssignmentYAML(ctx, course, yaml) + if err != nil { + return nil, err + } + return toGraphAssignmentView(view), nil +} + // DeleteAssignment is the resolver for the deleteAssignment field. func (r *mutationResolver) DeleteAssignment(ctx context.Context, course string, name string) (bool, error) { return r.app.DeleteAssignment(ctx, course, name) diff --git a/web/graph/generated/generated.go b/web/graph/generated/generated.go index 4e123d7..d3c1c8c 100644 --- a/web/graph/generated/generated.go +++ b/web/graph/generated/generated.go @@ -136,16 +136,17 @@ type ComplexityRoot struct { } Mutation struct { - CreateCourse func(childComplexity int, name string, coursePath string, semesterPath string, useCoursenameAsPrefix bool, useEmailDomainAsSuffix bool) int - DeleteAssignment func(childComplexity int, course string, name string) int - DeleteCourse func(childComplexity int, name string) int - ImportCourseYaml func(childComplexity int, yaml string) int - RemoveGitlabToken func(childComplexity int) int - SetAssignment func(childComplexity int, course string, name string, draft []*model.FieldValueInput) int - SetCourse func(childComplexity int, name string, coursePath string, semesterPath string, useCoursenameAsPrefix bool, useEmailDomainAsSuffix bool) int - SetCourseGroups func(childComplexity int, name string, groups []*model.GroupInput) int - SetCourseStudents func(childComplexity int, name string, students []string) int - SetGitlabToken func(childComplexity int, token string) int + CreateCourse func(childComplexity int, name string, coursePath string, semesterPath string, useCoursenameAsPrefix bool, useEmailDomainAsSuffix bool) int + DeleteAssignment func(childComplexity int, course string, name string) int + DeleteCourse func(childComplexity int, name string) int + ImportAssignmentYaml func(childComplexity int, course string, yaml string) int + ImportCourseYaml func(childComplexity int, yaml string) int + RemoveGitlabToken func(childComplexity int) int + SetAssignment func(childComplexity int, course string, name string, draft []*model.FieldValueInput) int + SetCourse func(childComplexity int, name string, coursePath string, semesterPath string, useCoursenameAsPrefix bool, useEmailDomainAsSuffix bool) int + SetCourseGroups func(childComplexity int, name string, groups []*model.GroupInput) int + SetCourseStudents func(childComplexity int, name string, students []string) int + SetGitlabToken func(childComplexity int, token string) int } ProjectMemberReport struct { @@ -234,6 +235,7 @@ type MutationResolver interface { SetCourseGroups(ctx context.Context, name string, groups []*model.GroupInput) (*model.Course, error) DeleteCourse(ctx context.Context, name string) (bool, error) SetAssignment(ctx context.Context, course string, name string, draft []*model.FieldValueInput) (*model.AssignmentView, error) + ImportAssignmentYaml(ctx context.Context, course string, yaml string) (*model.AssignmentView, error) DeleteAssignment(ctx context.Context, course string, name string) (bool, error) SetGitlabToken(ctx context.Context, token string) (*model.GitLabTokenStatus, error) RemoveGitlabToken(ctx context.Context) (*model.GitLabTokenStatus, error) @@ -674,6 +676,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Mutation.DeleteCourse(childComplexity, args["name"].(string)), true + case "Mutation.importAssignmentYAML": + if e.ComplexityRoot.Mutation.ImportAssignmentYaml == nil { + break + } + + args, err := ec.field_Mutation_importAssignmentYAML_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.ImportAssignmentYaml(childComplexity, args["course"].(string), args["yaml"].(string)), true case "Mutation.importCourseYAML": if e.ComplexityRoot.Mutation.ImportCourseYaml == nil { break @@ -1287,6 +1300,8 @@ type RepoUrl { extend type Mutation { "Apply a draft to one of the caller's assignments, creating it if it does not exist: validate, then persist (re-marshals the course YAML). Rejects a concrete draft that does not resolve." setAssignment(course: String!, name: String!, draft: [FieldValueInput!]!): AssignmentView! + "Import a single assignment into one of the caller's courses from a YAML snippet keyed by the assignment name (as it appears in a course file), upserting it: validate against the real resolver, then persist. Rejects a concrete assignment that does not resolve." + importAssignmentYAML(course: String!, yaml: String!): AssignmentView! "Delete one assignment from one of the caller's courses. Returns false if there was no such assignment." deleteAssignment(course: String!, name: String!): Boolean! } @@ -2028,6 +2043,28 @@ func (ec *executionContext) field_Mutation_deleteCourse_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation_importAssignmentYAML_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "course", + func(ctx context.Context, v any) (string, error) { + return ec.unmarshalNString2string(ctx, v) + }) + if err != nil { + return nil, err + } + args["course"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "yaml", + func(ctx context.Context, v any) (string, error) { + return ec.unmarshalNString2string(ctx, v) + }) + if err != nil { + return nil, err + } + args["yaml"] = arg1 + return args, nil +} + func (ec *executionContext) field_Mutation_importCourseYAML_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -4107,6 +4144,50 @@ func (ec *executionContext) fieldContext_Mutation_setAssignment(ctx context.Cont return fc, nil } +func (ec *executionContext) _Mutation_importAssignmentYAML(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_importAssignmentYAML(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().ImportAssignmentYaml(ctx, fc.Args["course"].(string), fc.Args["yaml"].(string)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *model.AssignmentView) graphql.Marshaler { + return ec.marshalNAssignmentView2ᚖgithubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐAssignmentView(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_importAssignmentYAML(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_AssignmentView(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_importAssignmentYAML_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_deleteAssignment(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -7559,6 +7640,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "importAssignmentYAML": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_importAssignmentYAML(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "deleteAssignment": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_deleteAssignment(ctx, field)