yoake/internal/canvaslms/handler.go
2022-11-11 17:16:53 -05:00

318 lines
8.8 KiB
Go

package canvaslms
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/eternal-flame-AD/yoake/config"
"github.com/eternal-flame-AD/yoake/internal/auth"
"github.com/eternal-flame-AD/yoake/internal/comm"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/labstack/echo/v4"
)
type Handler struct {
conf config.CanvasLMS
maxn int
respCache *responseCache
respCacheMutex sync.RWMutex
refreshPeriod time.Duration
comm *comm.CommProvider
}
type GetGradesResponse struct {
LastRefresh string `json:"last_refresh"`
Grades []SubmissionScoreResponse `json:"grades"`
}
type SubmissionScoreResponse struct {
Name string
Due string
AssignmentID string
AssignmentLegacyID string
AssignmentURL string
SubmissionID string
SubmissionLegacyID string
CourseID string
CourseLegacyID string
CourseName string
CourseCode string
Graded string
Posted string
State string
Score float64
EnteredScore float64
PossiblePoints float64
Grade string
EnteredGrade string
GradeHidden bool
GradedAt string
PostedAt string
SubmissionUserLegacyID string
SubmissionUserID string
SubmissionUserName string
SubmissionUserSISID string
SubmissionUserEmail string
}
func submissionScoreResponseFromQL(courselid, courseid, coursename, coursecode string, coursesubmission GraphSubmissionResponse) SubmissionScoreResponse {
res := SubmissionScoreResponse{
Name: coursesubmission.Assignment.Name,
Due: "-",
AssignmentID: coursesubmission.Assignment.ID,
AssignmentLegacyID: coursesubmission.Assignment.IDLegacy,
AssignmentURL: coursesubmission.Assignment.HTMLUrl,
SubmissionID: coursesubmission.ID,
SubmissionLegacyID: coursesubmission.IDLegacy,
CourseID: courseid,
CourseLegacyID: courselid,
CourseName: coursename,
CourseCode: coursecode,
Graded: coursesubmission.GradingStatus,
Posted: strconv.FormatBool(coursesubmission.Posted),
State: coursesubmission.State,
Score: -1,
EnteredScore: -1,
PossiblePoints: coursesubmission.Assignment.PointsPossible,
Grade: "-",
EnteredGrade: "-",
GradeHidden: coursesubmission.GradeHidden,
GradedAt: "-",
PostedAt: "-",
SubmissionUserID: coursesubmission.User.ID,
SubmissionUserLegacyID: coursesubmission.User.IDLegacy,
SubmissionUserName: coursesubmission.User.Name,
}
if coursesubmission.Score != nil {
res.Score = *coursesubmission.Score
}
if coursesubmission.EnteredScore != nil {
res.EnteredScore = *coursesubmission.EnteredScore
}
if coursesubmission.Assignment.DueAt != nil {
res.Due = *coursesubmission.Assignment.DueAt
}
if coursesubmission.Grade != nil {
res.Grade = *coursesubmission.Grade
}
if coursesubmission.GradedAt != nil {
res.GradedAt = *coursesubmission.GradedAt
}
if coursesubmission.PostedAt != nil {
res.PostedAt = *coursesubmission.PostedAt
}
if coursesubmission.User.SISID != nil {
res.SubmissionUserSISID = *coursesubmission.User.SISID
}
if coursesubmission.User.Email != nil {
res.SubmissionUserEmail = *coursesubmission.User.Email
}
return res
}
func (h *Handler) sortResponse(compare GraphSubmissionCompareFunc) (resp []SubmissionScoreResponse, err error) {
h.respCacheMutex.RLock()
defer h.respCacheMutex.RUnlock()
res := make([]GraphSubmissionResponse, h.maxn)
resF := make([]SubmissionScoreResponse, h.maxn)
curL := 0
push := func(pos int, resp GraphSubmissionResponse, respF SubmissionScoreResponse) {
for i := curL - 1; i > pos; i-- {
res[i] = res[i-1]
resF[i] = resF[i-1]
}
res[pos] = resp
resF[pos] = respF
}
for _, course := range h.respCache.rawResponse.Data.AllCourses {
for _, submission := range course.SubmissionsConnection.Nodes {
pos := curL
for i := curL - 1; i >= 0; i-- {
if !compare(submission, res[i]) {
break
}
pos = i
}
if pos < curL || curL < h.maxn {
push(pos, submission, submissionScoreResponseFromQL(course.IDLegacy, course.ID, course.Name, course.CourseCode, submission))
if curL < h.maxn {
curL++
}
}
}
}
return resF, nil
}
func (h *Handler) refresh() (hasUpdate bool, err error) {
h.respCacheMutex.Lock()
defer h.respCacheMutex.Unlock()
client := http.Client{
Timeout: h.refreshPeriod / 2,
}
buf := bytes.NewBufferString("")
e := json.NewEncoder(buf)
e.Encode(struct {
Query string `json:"query"`
OperationName string `json:"operationName"`
Variables *struct{} `json:"variables"`
}{strings.ReplaceAll(GraphQuery, "$maxn$", h.conf.MaxN), "gradeQuery", nil})
now := time.Now()
req, err := http.NewRequest("POST", h.conf.Endpoint, buf)
if err != nil {
return false, err
}
req.Header.Set("content-type", "application/json")
req.Header.Set("Authorization", "Bearer "+h.conf.Token)
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false, fmt.Errorf("remote returned with %d", resp.StatusCode)
}
dec := json.NewDecoder(resp.Body)
respStruct := new(GraphResponse)
if err := dec.Decode(respStruct); err != nil {
return false, err
}
hasUpdate = false
lastUpdateTime := make(map[string]time.Time)
for _, course := range respStruct.Data.AllCourses {
for _, submission := range course.SubmissionsConnection.Nodes {
lastUpdForSubmission := laterTime(submission.PostedAt, submission.GradedAt)
if lastUpdForSubmission != nil {
newUpdTime := parseJSONTime(*lastUpdForSubmission)
lastUpdateTime[submission.ID] = newUpdTime
if h.respCache != nil && (h.conf.SubmissionName == "" || submission.User.Name == h.conf.SubmissionName) {
if lastUpdateTime, ok := h.respCache.submissionLastUpdate[submission.ID]; !ok || lastUpdateTime.UnixNano() != newUpdTime.UnixNano() {
hasUpdate = true
}
}
}
}
}
h.respCache = &responseCache{
rawResponse: *respStruct,
requestTime: now,
submissionLastUpdate: lastUpdateTime,
}
if hasUpdate {
go h.SendGradeMessage(h.conf.Message.OnUpdate)
}
return hasUpdate, nil
}
func (h *Handler) GetInformation(key string) (data interface{}, err error) {
switch key {
case "recent-graded":
return h.sortResponse(GraphSubmissionCompareByGradeTime)
case "recent-due":
return h.sortResponse(GraphSubmissionCompareByDue)
}
return nil, errors.New("unknown info request type")
}
type responseCache struct {
rawResponse GraphResponse
requestTime time.Time
submissionLastUpdate map[string]time.Time
}
func Register(g *echo.Group, comm *comm.CommProvider) (h *Handler, err error) {
h = &Handler{conf: config.Config().CanvasLMS, comm: comm}
if h.conf.Token == "" {
return nil, errors.New("canvas token not set")
}
maxn, err := strconv.Atoi(h.conf.MaxN)
if err != nil {
return nil, err
}
h.maxn = maxn
refreshperiod, err := time.ParseDuration(h.conf.Frequency)
if err != nil {
return nil, err
}
h.refreshPeriod = refreshperiod
checkForUpdates := make(chan bool)
go func() {
if _, err := h.refresh(); err != nil {
log.Panicf("cannot access graphql endpoint: %v", err)
} else {
go h.SendGradeMessage(h.conf.Message.OnStartup)
}
for forced := range checkForUpdates {
_ = forced
h.refresh()
// TODO: notify if there is an update
}
}()
go func() {
for range time.NewTicker(h.refreshPeriod).C {
checkForUpdates <- false
}
}()
gradesG := g.Group("/grades", auth.RequireMiddleware(auth.RoleAdmin), func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if h.respCache == nil {
return echoerror.NewHttp(http.StatusServiceUnavailable, errors.New("not yet initialized"))
}
return next(c)
}
})
{
gradesG.GET("", func(c echo.Context) error {
if c.QueryParam("refresh") == "1" {
if _, err := h.refresh(); err != nil {
return fmt.Errorf("cannot access graphql endpoint: %v", err)
}
}
sortQuery := c.QueryParam("sort")
if sortQuery == "" {
sortQuery = "recent-graded"
}
var res GetGradesResponse
res.LastRefresh = h.respCache.requestTime.Format(time.RFC3339)
switch sortQuery {
case "recent-graded":
if grades, err := h.sortResponse(GraphSubmissionCompareByGradeTime); err != nil {
return err
} else {
res.Grades = grades
}
return c.JSON(http.StatusOK, res)
case "recent-due":
if grades, err := h.sortResponse(GraphSubmissionCompareByDue); err != nil {
return err
} else {
res.Grades = grades
}
return c.JSON(http.StatusOK, res)
}
return errors.New("unknown info request type")
})
}
return h, nil
}