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
10 changes: 10 additions & 0 deletions web/app/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
21 changes: 20 additions & 1 deletion web/app/activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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")
}
}
1 change: 1 addition & 0 deletions web/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions web/app/courses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 19 additions & 6 deletions web/db/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions web/graph/activity.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -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, …)."
Expand All @@ -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!]!
}
9 changes: 9 additions & 0 deletions web/graph/activity.resolvers.go

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

103 changes: 103 additions & 0 deletions web/graph/generated/generated.go

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

1 change: 1 addition & 0 deletions web/graph/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions web/graph/model/models_gen.go

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

Loading