Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions web/app/import_assignment.go
Original file line number Diff line number Diff line change
@@ -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)
}
60 changes: 60 additions & 0 deletions web/app/import_assignment_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions web/graph/assignments.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -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!
}
9 changes: 9 additions & 0 deletions web/graph/assignments.resolvers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

108 changes: 98 additions & 10 deletions web/graph/generated/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading