gogcli/internal/cmd/classroom_rosters.go
2026-05-05 08:55:21 +01:00

590 lines
16 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"strings"
"google.golang.org/api/classroom/v1"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
type ClassroomStudentsCmd struct {
List ClassroomStudentsListCmd `cmd:"" default:"withargs" aliases:"ls" help:"List students"`
Get ClassroomStudentsGetCmd `cmd:"" aliases:"info,show" help:"Get a student"`
Add ClassroomStudentsAddCmd `cmd:"" aliases:"create,new" help:"Add a student"`
Remove ClassroomStudentsRemoveCmd `cmd:"" aliases:"delete,rm,del,remove" help:"Remove a student"`
}
type ClassroomStudentsListCmd struct {
CourseID string `arg:"" name:"courseId" help:"Course ID or alias"`
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
Page string `name:"page" aliases:"cursor" help:"Page token"`
All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
}
func (c *ClassroomStudentsListCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
courseID := strings.TrimSpace(c.CourseID)
if courseID == "" {
return usage("empty courseId")
}
svc, err := newClassroomService(ctx, account)
if err != nil {
return wrapClassroomError(err)
}
fetch := func(pageToken string) ([]*classroom.Student, string, error) {
call := svc.Courses.Students.List(courseID).PageSize(c.Max).Context(ctx)
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapClassroomError(callErr)
}
return resp.Students, resp.NextPageToken, nil
}
students, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"students": students,
"nextPageToken": nextPageToken,
}); err != nil {
return err
}
if len(students) == 0 {
return failEmptyExit(c.FailEmpty)
}
return nil
}
if len(students) == 0 {
u.Err().Println("No students")
return failEmptyExit(c.FailEmpty)
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "USER_ID\tEMAIL\tNAME")
for _, student := range students {
if student == nil {
continue
}
fmt.Fprintf(w, "%s\t%s\t%s\n",
sanitizeTab(student.UserId),
sanitizeTab(profileEmail(student.Profile)),
sanitizeTab(profileName(student.Profile)),
)
}
printNextPageHint(u, nextPageToken)
return nil
}
type ClassroomStudentsGetCmd struct {
CourseID string `arg:"" name:"courseId" help:"Course ID or alias"`
UserID string `arg:"" name:"userId" help:"Student user ID or email"`
}
func (c *ClassroomStudentsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
courseID := strings.TrimSpace(c.CourseID)
userID := strings.TrimSpace(c.UserID)
if courseID == "" {
return usage("empty courseId")
}
if userID == "" {
return usage("empty userId")
}
svc, err := newClassroomService(ctx, account)
if err != nil {
return wrapClassroomError(err)
}
student, err := svc.Courses.Students.Get(courseID, userID).Context(ctx).Do()
if err != nil {
return wrapClassroomError(err)
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"student": student})
}
u.Out().Printf("user_id\t%s", student.UserId)
u.Out().Printf("email\t%s", profileEmail(student.Profile))
u.Out().Printf("name\t%s", profileName(student.Profile))
if student.StudentWorkFolder != nil {
u.Out().Printf("work_folder\t%s", student.StudentWorkFolder.Id)
}
return nil
}
type ClassroomStudentsAddCmd struct {
CourseID string `arg:"" name:"courseId" help:"Course ID or alias"`
UserID string `arg:"" name:"userId" help:"Student user ID or email"`
EnrollmentCode string `name:"enrollment-code" help:"Enrollment code"`
}
func (c *ClassroomStudentsAddCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
courseID := strings.TrimSpace(c.CourseID)
userID := strings.TrimSpace(c.UserID)
if courseID == "" {
return usage("empty courseId")
}
if userID == "" {
return usage("empty userId")
}
if err := dryRunExit(ctx, flags, "classroom.students.add", map[string]any{
"course_id": courseID,
"user_id": userID,
"enrollment_code": strings.TrimSpace(c.EnrollmentCode),
}); err != nil {
return err
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newClassroomService(ctx, account)
if err != nil {
return wrapClassroomError(err)
}
student := &classroom.Student{UserId: userID}
call := svc.Courses.Students.Create(courseID, student).Context(ctx)
if code := strings.TrimSpace(c.EnrollmentCode); code != "" {
call.EnrollmentCode(code)
}
created, err := call.Do()
if err != nil {
return wrapClassroomError(err)
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"student": created})
}
u.Out().Printf("user_id\t%s", created.UserId)
u.Out().Printf("email\t%s", profileEmail(created.Profile))
u.Out().Printf("name\t%s", profileName(created.Profile))
return nil
}
type ClassroomStudentsRemoveCmd struct {
CourseID string `arg:"" name:"courseId" help:"Course ID or alias"`
UserID string `arg:"" name:"userId" help:"Student user ID or email"`
}
func (c *ClassroomStudentsRemoveCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
courseID := strings.TrimSpace(c.CourseID)
userID := strings.TrimSpace(c.UserID)
if courseID == "" {
return usage("empty courseId")
}
if userID == "" {
return usage("empty userId")
}
if err := confirmDestructive(ctx, flags, fmt.Sprintf("remove student %s from %s", userID, courseID)); err != nil {
return err
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newClassroomService(ctx, account)
if err != nil {
return wrapClassroomError(err)
}
if _, err := svc.Courses.Students.Delete(courseID, userID).Context(ctx).Do(); err != nil {
return wrapClassroomError(err)
}
return writeResult(ctx, u,
kv("removed", true),
kv("courseId", courseID),
kv("userId", userID),
)
}
type ClassroomTeachersCmd struct {
List ClassroomTeachersListCmd `cmd:"" default:"withargs" aliases:"ls" help:"List teachers"`
Get ClassroomTeachersGetCmd `cmd:"" aliases:"info,show" help:"Get a teacher"`
Add ClassroomTeachersAddCmd `cmd:"" aliases:"create,new" help:"Add a teacher"`
Remove ClassroomTeachersRemoveCmd `cmd:"" aliases:"delete,rm,del,remove" help:"Remove a teacher"`
}
type ClassroomTeachersListCmd struct {
CourseID string `arg:"" name:"courseId" help:"Course ID or alias"`
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
Page string `name:"page" aliases:"cursor" help:"Page token"`
All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
}
func (c *ClassroomTeachersListCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
courseID := strings.TrimSpace(c.CourseID)
if courseID == "" {
return usage("empty courseId")
}
svc, err := newClassroomService(ctx, account)
if err != nil {
return wrapClassroomError(err)
}
fetch := func(pageToken string) ([]*classroom.Teacher, string, error) {
call := svc.Courses.Teachers.List(courseID).PageSize(c.Max).Context(ctx)
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapClassroomError(callErr)
}
return resp.Teachers, resp.NextPageToken, nil
}
teachers, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"teachers": teachers,
"nextPageToken": nextPageToken,
}); err != nil {
return err
}
if len(teachers) == 0 {
return failEmptyExit(c.FailEmpty)
}
return nil
}
if len(teachers) == 0 {
u.Err().Println("No teachers")
return failEmptyExit(c.FailEmpty)
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "USER_ID\tEMAIL\tNAME")
for _, teacher := range teachers {
if teacher == nil {
continue
}
fmt.Fprintf(w, "%s\t%s\t%s\n",
sanitizeTab(teacher.UserId),
sanitizeTab(profileEmail(teacher.Profile)),
sanitizeTab(profileName(teacher.Profile)),
)
}
printNextPageHint(u, nextPageToken)
return nil
}
type ClassroomTeachersGetCmd struct {
CourseID string `arg:"" name:"courseId" help:"Course ID or alias"`
UserID string `arg:"" name:"userId" help:"Teacher user ID or email"`
}
func (c *ClassroomTeachersGetCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
courseID := strings.TrimSpace(c.CourseID)
userID := strings.TrimSpace(c.UserID)
if courseID == "" {
return usage("empty courseId")
}
if userID == "" {
return usage("empty userId")
}
svc, err := newClassroomService(ctx, account)
if err != nil {
return wrapClassroomError(err)
}
teacher, err := svc.Courses.Teachers.Get(courseID, userID).Context(ctx).Do()
if err != nil {
return wrapClassroomError(err)
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"teacher": teacher})
}
u.Out().Printf("user_id\t%s", teacher.UserId)
u.Out().Printf("email\t%s", profileEmail(teacher.Profile))
u.Out().Printf("name\t%s", profileName(teacher.Profile))
return nil
}
type ClassroomTeachersAddCmd struct {
CourseID string `arg:"" name:"courseId" help:"Course ID or alias"`
UserID string `arg:"" name:"userId" help:"Teacher user ID or email"`
}
func (c *ClassroomTeachersAddCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
courseID := strings.TrimSpace(c.CourseID)
userID := strings.TrimSpace(c.UserID)
if courseID == "" {
return usage("empty courseId")
}
if userID == "" {
return usage("empty userId")
}
if err := dryRunExit(ctx, flags, "classroom.teachers.add", map[string]any{
"course_id": courseID,
"user_id": userID,
}); err != nil {
return err
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newClassroomService(ctx, account)
if err != nil {
return wrapClassroomError(err)
}
teacher := &classroom.Teacher{UserId: userID}
created, err := svc.Courses.Teachers.Create(courseID, teacher).Context(ctx).Do()
if err != nil {
return wrapClassroomError(err)
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"teacher": created})
}
u.Out().Printf("user_id\t%s", created.UserId)
u.Out().Printf("email\t%s", profileEmail(created.Profile))
u.Out().Printf("name\t%s", profileName(created.Profile))
return nil
}
type ClassroomTeachersRemoveCmd struct {
CourseID string `arg:"" name:"courseId" help:"Course ID or alias"`
UserID string `arg:"" name:"userId" help:"Teacher user ID or email"`
}
func (c *ClassroomTeachersRemoveCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
courseID := strings.TrimSpace(c.CourseID)
userID := strings.TrimSpace(c.UserID)
if courseID == "" {
return usage("empty courseId")
}
if userID == "" {
return usage("empty userId")
}
if err := confirmDestructive(ctx, flags, fmt.Sprintf("remove teacher %s from %s", userID, courseID)); err != nil {
return err
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newClassroomService(ctx, account)
if err != nil {
return wrapClassroomError(err)
}
if _, err := svc.Courses.Teachers.Delete(courseID, userID).Context(ctx).Do(); err != nil {
return wrapClassroomError(err)
}
return writeResult(ctx, u,
kv("removed", true),
kv("courseId", courseID),
kv("userId", userID),
)
}
type ClassroomRosterCmd struct {
CourseID string `arg:"" name:"courseId" help:"Course ID or alias"`
Students bool `name:"students" help:"Include students"`
Teachers bool `name:"teachers" help:"Include teachers"`
Max int64 `name:"max" aliases:"limit" help:"Max results (per role)" default:"100"`
Page string `name:"page" aliases:"cursor" help:"Page token (per role)"`
All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages (per role)"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
}
func (c *ClassroomRosterCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
courseID := strings.TrimSpace(c.CourseID)
if courseID == "" {
return usage("empty courseId")
}
includeStudents := c.Students || (!c.Students && !c.Teachers)
includeTeachers := c.Teachers || (!c.Students && !c.Teachers)
svc, err := newClassroomService(ctx, account)
if err != nil {
return wrapClassroomError(err)
}
var students []*classroom.Student
var teachers []*classroom.Teacher
studentsNextPageToken := ""
teachersNextPageToken := ""
if includeStudents {
fetch := func(pageToken string) ([]*classroom.Student, string, error) {
call := svc.Courses.Students.List(courseID).PageSize(c.Max).Context(ctx)
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapClassroomError(callErr)
}
return resp.Students, resp.NextPageToken, nil
}
students, studentsNextPageToken, err = loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
}
if includeTeachers {
fetch := func(pageToken string) ([]*classroom.Teacher, string, error) {
call := svc.Courses.Teachers.List(courseID).PageSize(c.Max).Context(ctx)
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapClassroomError(callErr)
}
return resp.Teachers, resp.NextPageToken, nil
}
teachers, teachersNextPageToken, err = loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
}
if outfmt.IsJSON(ctx) {
payload := map[string]any{"courseId": courseID}
if includeStudents {
payload["students"] = students
payload["studentsNextPageToken"] = studentsNextPageToken
}
if includeTeachers {
payload["teachers"] = teachers
payload["teachersNextPageToken"] = teachersNextPageToken
}
if err := outfmt.WriteJSON(ctx, os.Stdout, payload); err != nil {
return err
}
if includeStudents && includeTeachers && len(students) == 0 && len(teachers) == 0 {
return failEmptyExit(c.FailEmpty)
}
if includeStudents && !includeTeachers && len(students) == 0 {
return failEmptyExit(c.FailEmpty)
}
if includeTeachers && !includeStudents && len(teachers) == 0 {
return failEmptyExit(c.FailEmpty)
}
return nil
}
if includeStudents && includeTeachers && len(students) == 0 && len(teachers) == 0 {
u.Err().Println("No roster entries")
return failEmptyExit(c.FailEmpty)
}
if includeStudents && !includeTeachers && len(students) == 0 {
u.Err().Println("No students")
return failEmptyExit(c.FailEmpty)
}
if includeTeachers && !includeStudents && len(teachers) == 0 {
u.Err().Println("No teachers")
return failEmptyExit(c.FailEmpty)
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "ROLE\tUSER_ID\tEMAIL\tNAME")
if includeTeachers {
for _, teacher := range teachers {
if teacher == nil {
continue
}
fmt.Fprintf(w, "teacher\t%s\t%s\t%s\n",
sanitizeTab(teacher.UserId),
sanitizeTab(profileEmail(teacher.Profile)),
sanitizeTab(profileName(teacher.Profile)),
)
}
if teachersNextPageToken != "" {
u.Err().Printf("# Next teachers page: --page %s", teachersNextPageToken)
}
}
if includeStudents {
for _, student := range students {
if student == nil {
continue
}
fmt.Fprintf(w, "student\t%s\t%s\t%s\n",
sanitizeTab(student.UserId),
sanitizeTab(profileEmail(student.Profile)),
sanitizeTab(profileName(student.Profile)),
)
}
if studentsNextPageToken != "" {
u.Err().Printf("# Next students page: --page %s", studentsNextPageToken)
}
}
return nil
}