diff --git a/web/app/activity.go b/web/app/activity.go index 0d07c70..5ed93f1 100644 --- a/web/app/activity.go +++ b/web/app/activity.go @@ -28,6 +28,16 @@ func (a *App) CourseActivity(ctx context.Context, course string) ([]*db.Activity return a.db.CourseActivityFor(ctx, o, course) } +// ActivityLog returns the caller's complete activity log across all their courses, +// newest first — the audit-log dump behind the activity page and its JSON download. +func (a *App) ActivityLog(ctx context.Context) ([]*db.ActivityEntry, error) { + o, err := owner(ctx) + if err != nil { + return nil, err + } + return a.db.AllActivityFor(ctx, o) +} + // recordOp appends one terminal-state entry to the activity log. It is // best-effort: a logging failure must never fail the operation itself, so the // caller decides how to surface the returned error (RunOp streams it as a WARN). diff --git a/web/app/activity_test.go b/web/app/activity_test.go index 2df5a16..2aefaa4 100644 --- a/web/app/activity_test.go +++ b/web/app/activity_test.go @@ -31,7 +31,14 @@ func TestActivityRecordAndRead(t *testing.T) { t.Errorf("assignment activity wrong: %+v", got) } - // Course-wide: both entries. + // A third entry in a DIFFERENT course, to prove the dump is cross-course. + if err := a.recordOp(context.Background(), owner, + &opToken{Owner: owner, Op: "generate", Course: "other", Assignment: "ex1"}, + "done", "5 repositories"); err != nil { + t.Fatalf("recordOp: %v", err) + } + + // Course-wide: both tc entries, not the "other" one. all, err := a.CourseActivity(ctx, "tc") if err != nil { t.Fatalf("CourseActivity: %v", err) @@ -40,6 +47,15 @@ func TestActivityRecordAndRead(t *testing.T) { t.Errorf("course activity = %d entries, want 2", len(all)) } + // Owner-wide dump: every entry across all courses. + dump, err := a.ActivityLog(ctx) + if err != nil { + t.Fatalf("ActivityLog: %v", err) + } + if len(dump) != 3 { + t.Errorf("activity dump = %d entries, want 3 (across all courses)", len(dump)) + } + // Every store call scoped to the principal's owner. for _, seen := range fs.seenOwners { if seen != owner { @@ -57,4 +73,7 @@ func TestActivityRequiresAuthentication(t *testing.T) { if _, err := a.CourseActivity(context.Background(), "tc"); err == nil { t.Error("CourseActivity without a principal succeeded, want an error") } + if _, err := a.ActivityLog(context.Background()); err == nil { + t.Error("ActivityLog without a principal succeeded, want an error") + } } diff --git a/web/app/app.go b/web/app/app.go index 4e3aa7c..003d279 100644 --- a/web/app/app.go +++ b/web/app/app.go @@ -32,6 +32,7 @@ type store interface { RecordActivity(ctx context.Context, e *db.ActivityEntry) error ActivityFor(ctx context.Context, owner, course, assignment string) ([]*db.ActivityEntry, error) CourseActivityFor(ctx context.Context, owner, course string) ([]*db.ActivityEntry, error) + AllActivityFor(ctx context.Context, owner string) ([]*db.ActivityEntry, error) SaveJob(ctx context.Context, job *db.ScheduledJob) error CancelJob(ctx context.Context, owner, id string) (*db.ScheduledJob, error) JobsOf(ctx context.Context, owner string, statuses []string) ([]*db.ScheduledJob, error) diff --git a/web/app/courses_test.go b/web/app/courses_test.go index b7dfdf9..55750b3 100644 --- a/web/app/courses_test.go +++ b/web/app/courses_test.go @@ -195,6 +195,17 @@ func (f *fakeStore) CourseActivityFor(_ context.Context, owner, course string) ( return out, nil } +func (f *fakeStore) AllActivityFor(_ context.Context, owner string) ([]*db.ActivityEntry, error) { + f.seenOwners = append(f.seenOwners, owner) + var out []*db.ActivityEntry + for _, e := range f.activity { + if e.Owner == owner { + out = append(out, e) + } + } + return out, nil +} + func (f *fakeStore) GetUserSecret(_ context.Context, owner string) (*db.UserSecret, error) { f.seenOwners = append(f.seenOwners, owner) return f.userSecret[owner], nil diff --git a/web/db/activity.go b/web/db/activity.go index 654a127..6d74b6d 100644 --- a/web/db/activity.go +++ b/web/db/activity.go @@ -40,6 +40,8 @@ func (db *DB) EnsureActivityIndexes(ctx context.Context) error { _, err := db.collection(collectionActivity).Indexes().CreateMany(ctx, []mongo.IndexModel{ {Keys: bson.D{{Key: "owner", Value: 1}, {Key: "course", Value: 1}, {Key: "assignment", Value: 1}, {Key: "at", Value: -1}}}, {Keys: bson.D{{Key: "owner", Value: 1}, {Key: "course", Value: 1}, {Key: "at", Value: -1}}}, + // The owner-wide dump (AllActivityFor): every entry of one user, newest first. + {Keys: bson.D{{Key: "owner", Value: 1}, {Key: "at", Value: -1}}}, }) if err != nil { return fmt.Errorf("cannot create activity indexes: %w", err) @@ -58,20 +60,31 @@ func (db *DB) RecordActivity(ctx context.Context, e *ActivityEntry) error { // ActivityFor returns the log entries of one assignment, newest first. func (db *DB) ActivityFor(ctx context.Context, owner, course, assignment string) ([]*ActivityEntry, error) { - return db.findActivity(ctx, bson.M{"owner": owner, "course": course, "assignment": assignment}) + return db.findActivity(ctx, bson.M{"owner": owner, "course": course, "assignment": assignment}, activityLimit) } // CourseActivityFor returns the log entries across a whole course, newest first — // the course page groups them by assignment to show each one's latest status. func (db *DB) CourseActivityFor(ctx context.Context, owner, course string) ([]*ActivityEntry, error) { - return db.findActivity(ctx, bson.M{"owner": owner, "course": course}) + return db.findActivity(ctx, bson.M{"owner": owner, "course": course}, activityLimit) +} + +// AllActivityFor returns the owner's complete log across all their courses, newest +// first — the audit-log dump. Unlike the GUI's per-assignment/per-course reads it is +// UNCAPPED (limit 0): a dump must be complete, and one user's audit trail is bounded +// in practice. +func (db *DB) AllActivityFor(ctx context.Context, owner string) ([]*ActivityEntry, error) { + return db.findActivity(ctx, bson.M{"owner": owner}, 0) } // findActivity is the single owner-scoped read: like courses, there is no method -// that reads the log without an owner filter. -func (db *DB) findActivity(ctx context.Context, filter bson.M) ([]*ActivityEntry, error) { - cur, err := db.collection(collectionActivity).Find(ctx, filter, - options.Find().SetSort(bson.D{{Key: "at", Value: -1}}).SetLimit(activityLimit)) +// that reads the log without an owner filter. A limit <= 0 reads without a cap. +func (db *DB) findActivity(ctx context.Context, filter bson.M, limit int64) ([]*ActivityEntry, error) { + opts := options.Find().SetSort(bson.D{{Key: "at", Value: -1}}) + if limit > 0 { + opts.SetLimit(limit) + } + cur, err := db.collection(collectionActivity).Find(ctx, filter, opts) if err != nil { return nil, fmt.Errorf("cannot read activity: %w", err) } diff --git a/web/graph/activity.graphqls b/web/graph/activity.graphqls index 538f51f..0c6a25f 100644 --- a/web/graph/activity.graphqls +++ b/web/graph/activity.graphqls @@ -5,6 +5,8 @@ it to show, per assignment, what has already happened (setaccess, protect, archive, delete; later generate). """ type ActivityEntry { + "The course this entry is about (its name)." + course: String! "The assignment this entry is about (its name within the course)." assignment: String! "The operation that ran (setaccess, protect, archive, delete, …)." @@ -22,4 +24,6 @@ extend type Query { assignmentActivity(course: String!, name: String!): [ActivityEntry!]! "The activity log across a whole course of the caller's, newest first (the GUI groups it by assignment for a per-assignment status)." courseActivity(course: String!): [ActivityEntry!]! + "The caller's complete activity log across ALL their courses, newest first — the audit-log dump (uncapped, for the activity page and its JSON download)." + activityLog: [ActivityEntry!]! } diff --git a/web/graph/activity.resolvers.go b/web/graph/activity.resolvers.go index e9b0eb6..90a071e 100644 --- a/web/graph/activity.resolvers.go +++ b/web/graph/activity.resolvers.go @@ -28,3 +28,12 @@ func (r *queryResolver) CourseActivity(ctx context.Context, course string) ([]*m } return toGraphActivity(entries), nil } + +// ActivityLog is the resolver for the activityLog field. +func (r *queryResolver) ActivityLog(ctx context.Context) ([]*model.ActivityEntry, error) { + entries, err := r.app.ActivityLog(ctx) + if err != nil { + return nil, err + } + return toGraphActivity(entries), nil +} diff --git a/web/graph/generated/generated.go b/web/graph/generated/generated.go index bd02ea8..dc39af6 100644 --- a/web/graph/generated/generated.go +++ b/web/graph/generated/generated.go @@ -41,6 +41,7 @@ type ComplexityRoot struct { ActivityEntry struct { Assignment func(childComplexity int) int At func(childComplexity int) int + Course func(childComplexity int) int Detail func(childComplexity int) int Op func(childComplexity int) int Status func(childComplexity int) int @@ -261,6 +262,7 @@ type ComplexityRoot struct { } Query struct { + ActivityLog func(childComplexity int) int ApprovalRuleSchema func(childComplexity int) int ApprovalSettingsSchema func(childComplexity int) int Assignment func(childComplexity int, course string, name string) int @@ -398,6 +400,7 @@ type QueryResolver interface { ServerInfo(ctx context.Context) (*model.ServerInfo, error) AssignmentActivity(ctx context.Context, course string, name string) ([]*model.ActivityEntry, error) CourseActivity(ctx context.Context, course string) ([]*model.ActivityEntry, error) + ActivityLog(ctx context.Context) ([]*model.ActivityEntry, error) AssignmentSchema(ctx context.Context) ([]*model.FieldMeta, error) BranchRuleSchema(ctx context.Context) ([]*model.FieldMeta, error) ApprovalSettingsSchema(ctx context.Context) ([]*model.FieldMeta, error) @@ -454,6 +457,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.ActivityEntry.At(childComplexity), true + case "ActivityEntry.course": + if e.ComplexityRoot.ActivityEntry.Course == nil { + break + } + + return e.ComplexityRoot.ActivityEntry.Course(childComplexity), true case "ActivityEntry.detail": if e.ComplexityRoot.ActivityEntry.Detail == nil { break @@ -1395,6 +1404,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.ProjectReport.WebURL(childComplexity), true + case "Query.activityLog": + if e.ComplexityRoot.Query.ActivityLog == nil { + break + } + + return e.ComplexityRoot.Query.ActivityLog(childComplexity), true case "Query.approvalRuleSchema": if e.ComplexityRoot.Query.ApprovalRuleSchema == nil { break @@ -2029,6 +2044,8 @@ it to show, per assignment, what has already happened (setaccess, protect, archive, delete; later generate). """ type ActivityEntry { + "The course this entry is about (its name)." + course: String! "The assignment this entry is about (its name within the course)." assignment: String! "The operation that ran (setaccess, protect, archive, delete, …)." @@ -2046,6 +2063,8 @@ extend type Query { assignmentActivity(course: String!, name: String!): [ActivityEntry!]! "The activity log across a whole course of the caller's, newest first (the GUI groups it by assignment for a per-assignment status)." courseActivity(course: String!): [ActivityEntry!]! + "The caller's complete activity log across ALL their courses, newest first — the audit-log dump (uncapped, for the activity page and its JSON download)." + activityLog: [ActivityEntry!]! } `, BuiltIn: false}, {Name: "../assignments.graphqls", Input: `""" @@ -2768,6 +2787,8 @@ var parsedSchema = gqlparser.MustLoadSchema(sources...) func (ec *executionContext) childFields_ActivityEntry(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { + case "course": + return ec.fieldContext_ActivityEntry_course(ctx, field) case "assignment": return ec.fieldContext_ActivityEntry_assignment(ctx, field) case "op": @@ -4262,6 +4283,29 @@ func (ec *executionContext) field___Type_fields_args(ctx context.Context, rawArg // region **************************** field.gotpl ***************************** +func (ec *executionContext) _ActivityEntry_course(ctx context.Context, field graphql.CollectedField, obj *model.ActivityEntry) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_ActivityEntry_course(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Course, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_ActivityEntry_course(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("ActivityEntry", field, false, false, errors.New("field of type String does not have child fields")) +} + func (ec *executionContext) _ActivityEntry_assignment(ctx context.Context, field graphql.CollectedField, obj *model.ActivityEntry) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -8146,6 +8190,38 @@ func (ec *executionContext) fieldContext_Query_courseActivity(ctx context.Contex return fc, nil } +func (ec *executionContext) _Query_activityLog(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_Query_activityLog(ctx, field) + }, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().ActivityLog(ctx) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []*model.ActivityEntry) graphql.Marshaler { + return ec.marshalNActivityEntry2ᚕᚖgithubᚗcomᚋobcodeᚋglabsᚋv3ᚋwebᚋgraphᚋmodelᚐActivityEntryᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Query_activityLog(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_ActivityEntry(ctx, field) + }, + } + return fc, nil +} + func (ec *executionContext) _Query_assignmentSchema(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -11379,6 +11455,11 @@ func (ec *executionContext) _ActivityEntry(ctx context.Context, sel ast.Selectio switch field.Name { case "__typename": out.Values[i] = graphql.MarshalString("ActivityEntry") + case "course": + out.Values[i] = ec._ActivityEntry_course(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "assignment": out.Values[i] = ec._ActivityEntry_assignment(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -13112,6 +13193,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "activityLog": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_activityLog(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "assignmentSchema": field := field diff --git a/web/graph/mapping.go b/web/graph/mapping.go index 7c68aa4..40e512a 100644 --- a/web/graph/mapping.go +++ b/web/graph/mapping.go @@ -149,6 +149,7 @@ func toGraphActivity(entries []*db.ActivityEntry) []*model.ActivityEntry { out := make([]*model.ActivityEntry, 0, len(entries)) for _, e := range entries { out = append(out, &model.ActivityEntry{ + Course: e.Course, Assignment: e.Assignment, Op: e.Op, Status: e.Status, diff --git a/web/graph/model/models_gen.go b/web/graph/model/models_gen.go index d0457bc..02e01a5 100644 --- a/web/graph/model/models_gen.go +++ b/web/graph/model/models_gen.go @@ -15,6 +15,8 @@ import ( // it to show, per assignment, what has already happened (setaccess, protect, // archive, delete; later generate). type ActivityEntry struct { + // The course this entry is about (its name). + Course string `json:"course"` // The assignment this entry is about (its name within the course). Assignment string `json:"assignment"` // The operation that ran (setaccess, protect, archive, delete, …).