Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat:Implemented Base Grade Notification Hook Service #181

Merged
merged 20 commits into from
Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0ae6ce1
feat: implemented base grade notification hook service
Antonwy Jun 18, 2023
2ed0600
Merge branch 'main' into feature/new-grade-published-hook
Antonwy Jun 18, 2023
dc5d937
feat: added api key option
Antonwy Jul 2, 2023
5a6188d
Merge branch 'main' into feature/new-grade-published-hook
joschahenningsen Jul 6, 2023
2f96449
fix: campusAPI Url change & new error calls
Antonwy Sep 17, 2023
23291d3
Merge remote-tracking branch 'origin/feature/new-grade-published-hook…
Antonwy Sep 17, 2023
2ada07c
fix: exam scheduling comments
Antonwy Sep 17, 2023
346d799
fix: directly added primary key to callback url
Antonwy Sep 17, 2023
654921c
Merge branch 'main' into feature/new-grade-published-hook
Antonwy Sep 17, 2023
f391cb5
fix: formatting
Antonwy Sep 17, 2023
e3bae45
Merge branch 'main' into feature/new-grade-published-hook
CommanderStorm Sep 19, 2023
45f9fac
Apply suggestions from code review
CommanderStorm Sep 19, 2023
76e6ab2
ran the pre-commit hook
CommanderStorm Sep 19, 2023
8070d61
renamed `ExamResultPublished` -> `PublishedExamResult`
CommanderStorm Sep 19, 2023
fe3cfca
inlined the models in the migration
CommanderStorm Sep 19, 2023
5bcb005
decoupled cron and the migration
CommanderStorm Sep 19, 2023
95c40ba
made shure the CAMPUS_API_TOKEN is configured in the deployment and d…
CommanderStorm Sep 19, 2023
d874fa0
inlined a few variables
CommanderStorm Sep 19, 2023
21e0891
increased the amount of time between newExamResultsHook's
CommanderStorm Sep 19, 2023
7c4b2d1
Merge branch 'main' into feature/new-grade-published-hook
CommanderStorm Sep 19, 2023
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
49 changes: 31 additions & 18 deletions server/backend/campus_api/campusApi.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,51 @@ import (
)

const (
CampusApiUrl = "https://campus.tum.de/tumonline"
CampusQueryToken = "pToken"
CampusGradesPath = "/wbservicesbasic.noten"
CampusApiUrl = "https://campus.tum.de/tumonline"
CampusQueryToken = "pToken"
CampusGradesPath = "/wbservicesbasic.noten"
CampusExamResultsPublished = "/wbservicesbasic.pruefungenErgebnisse"
)

var (
ErrCannotCreateRequest = errors.New("cannot create http request")
ErrWhileFetchingGrades = errors.New("error while fetching grades")
ErrorWhileUnmarshalling = errors.New("error while unmarshalling")
)

func FetchExamResultsPublished(token string) (*model.TUMAPIExamResultsPublished, error) {
var examResultsPublished model.TUMAPIExamResultsPublished
err := RequestCampusApi(CampusExamResultsPublished, token, &examResultsPublished)
if err != nil {
return nil, err
}

return &examResultsPublished, nil
}

func FetchGrades(token string) (*model.IOSGrades, error) {
var grades model.IOSGrades
err := RequestCampusApi(CampusGradesPath, token, &grades)
if err != nil {
return nil, err
}

requestUrl := CampusApiUrl + CampusGradesPath
return &grades, nil
}

func RequestCampusApi(path string, token string, response any) error {
requestUrl := "https://exams.free.beeceptor.com/"
Antonwy marked this conversation as resolved.
Show resolved Hide resolved
req, err := http.NewRequest(http.MethodGet, requestUrl, nil)

if err != nil {
log.Errorf("Error while creating request: %s", err)
return nil, ErrCannotCreateRequest
return ErrCannotCreateRequest
}

q := req.URL.Query()
q.Add(CampusQueryToken, token)

req.URL.RawQuery = q.Encode()

resp, err := http.DefaultClient.Do(req)

if err != nil {
log.Errorf("Error while fetching grades: %s", err)
return nil, ErrWhileFetchingGrades
log.Errorf("Error while fetching %s: %s", path, err)
Antonwy marked this conversation as resolved.
Show resolved Hide resolved
return errors.New("error while fetching " + path)
Antonwy marked this conversation as resolved.
Show resolved Hide resolved
}

defer func(Body io.ReadCloser) {
Expand All @@ -51,13 +65,12 @@ func FetchGrades(token string) (*model.IOSGrades, error) {
}
}(resp.Body)

var grades model.IOSGrades
err = xml.NewDecoder(resp.Body).Decode(&grades)
err = xml.NewDecoder(resp.Body).Decode(&response)

if err != nil {
log.Errorf("Error while unmarshalling grades: %s", err)
return nil, ErrorWhileUnmarshalling
log.Errorf("Error while unmarshalling %s: %s", path, err)
Antonwy marked this conversation as resolved.
Show resolved Hide resolved
return ErrorWhileUnmarshalling
}

return &grades, nil
return nil
}
6 changes: 5 additions & 1 deletion server/backend/cron/cronjobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const (
StorageDir = "/Storage/" // target location of files
IOSNotifications = "iosNotifications"
IOSActivityReset = "iosActivityReset"
NewExamResultsHook = "newExamResultsHook"

/* MensaType = "mensa"
ChatType = "chat"
Expand Down Expand Up @@ -58,7 +59,7 @@ func (c *CronService) Run() error {
var res []model.Crontab

c.db.Model(&model.Crontab{}).
Where("`interval` > 0 AND (lastRun+`interval`) < ? AND type IN (?, ?, ?, ?, ?, ?, ?)",
Where("`interval` > 0 AND (lastRun+`interval`) < ? AND type IN (?, ?, ?, ?, ?, ?, ?, ?)",
time.Now().Unix(),
NewsType,
FileDownloadType,
Expand All @@ -67,6 +68,7 @@ func (c *CronService) Run() error {
CanteenHeadcount,
IOSNotifications,
IOSActivityReset,
NewExamResultsHook,
).
Scan(&res)

Expand Down Expand Up @@ -98,6 +100,8 @@ func (c *CronService) Run() error {
if c.useMensa {
g.Go(c.averageRatingComputation)
}
case NewExamResultsHook:
g.Go(func() error { return c.newExamResultsHookCron() })
/*
TODO: Implement handlers for other cronjobs
case MensaType:
Expand Down
15 changes: 15 additions & 0 deletions server/backend/cron/newExamResultsHook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package cron

import (
"github.com/TUM-Dev/Campus-Backend/server/backend/ios_notifications/ios_device"
"github.com/TUM-Dev/Campus-Backend/server/backend/new_exam_results_hook/new_exam_results_scheduling"
)

func (c *CronService) newExamResultsHookCron() error {
repo := new_exam_results_scheduling.NewRepository(c.db)
devicesRepo := ios_device.NewRepository(c.db)

service := new_exam_results_scheduling.NewService(repo, devicesRepo, c.APNs)

return service.HandleScheduledCron()
}
51 changes: 51 additions & 0 deletions server/backend/migration/20230530000000.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package migration

import (
"database/sql"
_ "embed"
"github.com/TUM-Dev/Campus-Backend/server/backend/cron"
"github.com/TUM-Dev/Campus-Backend/server/model"
"github.com/go-gormigrate/gormigrate/v2"
"github.com/guregu/null"
"gorm.io/gorm"
)

func (m TumDBMigrator) migrate20230530000000() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20230530000000",
Migrate: func(tx *gorm.DB) error {

if err := tx.AutoMigrate(
&model.ExamResultPublished{},
&model.NewExamResultsSubscriber{},
CommanderStorm marked this conversation as resolved.
Show resolved Hide resolved
); err != nil {
return err
}

err := SafeEnumMigrate(tx, model.Crontab{}, "type", cron.NewExamResultsHook)
if err != nil {
return err
}

return tx.Create(&model.Crontab{
Interval: 60, // Every 5 minutes
Type: null.String{NullString: sql.NullString{String: cron.NewExamResultsHook, Valid: true}},
CommanderStorm marked this conversation as resolved.
Show resolved Hide resolved
}).Error
},
Rollback: func(tx *gorm.DB) error {
if err := tx.Migrator().DropTable(&model.ExamResultPublished{}); err != nil {
return err
}
if err := tx.Migrator().DropTable(&model.NewExamResultsSubscriber{}); err != nil {
return err
}

err := SafeEnumRollback(tx, model.Crontab{}, "type", cron.NewExamResultsHook)
if err != nil {
return err
}

return tx.Delete(&model.Crontab{}, "type = ?", cron.NewExamResultsHook).Error
},
}
}
1 change: 1 addition & 0 deletions server/backend/migration/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func (m TumDBMigrator) Migrate() error {
m.migrate20220713000000(),
m.migrate20221119131300(),
m.migrate20221210000000(),
m.migrate20230530000000(),
})
err := mig.Migrate()
return err
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package new_exam_results_scheduling

import (
"github.com/TUM-Dev/Campus-Backend/server/model"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)

type Repository struct {
DB *gorm.DB
}

func (repository *Repository) StoreExamResultsPublished(examResultsPublished []model.ExamResultPublished) error {
db := repository.DB

db.Where("1 = 1").Delete(&model.ExamResultPublished{})
Antonwy marked this conversation as resolved.
Show resolved Hide resolved

return db.
Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).
Antonwy marked this conversation as resolved.
Show resolved Hide resolved
Create(examResultsPublished).Error
}

func (repository *Repository) FindAllExamResultsPublished() (*[]model.ExamResultPublished, error) {
db := repository.DB

var results []model.ExamResultPublished

err := db.Find(&results).Error
Antonwy marked this conversation as resolved.
Show resolved Hide resolved

return &results, err
}

func NewRepository(db *gorm.DB) *Repository {
return &Repository{
DB: db,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package new_exam_results_scheduling

import (
"github.com/TUM-Dev/Campus-Backend/server/backend/campus_api"
"github.com/TUM-Dev/Campus-Backend/server/backend/ios_notifications/ios_apns"
"github.com/TUM-Dev/Campus-Backend/server/backend/ios_notifications/ios_device"
"github.com/TUM-Dev/Campus-Backend/server/backend/new_exam_results_hook/new_exam_results_subscriber"
"github.com/TUM-Dev/Campus-Backend/server/model"
log "github.com/sirupsen/logrus"
)

const (
MaxRoutineCount = 10
MockAPIToken = "DDF9A212B2F80A01C6D0307B8455EEAA"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How "mock" is this api token?
does this need to be set via environment variables?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes we should set it in an env var. Right now its my api token that I can just deactivate. I think the idea was that we create an account independant token...

)

type Service struct {
Repository *Repository
DevicesRepository *ios_device.Repository
Priority *model.IOSSchedulingPriority
APNs *ios_apns.Service
}

func (service *Service) HandleScheduledCron() error {
log.Info("Fetching published exam results")

apiResult, err := campus_api.FetchExamResultsPublished(MockAPIToken)
if err != nil {
return err
}

var apiExamResults []model.ExamResultPublished
for _, apiExamResult := range apiResult.ExamResults {
apiExamResults = append(apiExamResults, *apiExamResult.ToDBExamResult())
}

storedExamResults, err := service.Repository.FindAllExamResultsPublished()
if err != nil {
return err
}

newPublishedExamResults := service.findNewPublishedExamResults(&apiExamResults, storedExamResults)

if len(*newPublishedExamResults) > 0 {
service.notifySubscribers(newPublishedExamResults)
} else {
log.Info("No new published exam results")
}

service.Repository.StoreExamResultsPublished(apiExamResults)

return nil
Antonwy marked this conversation as resolved.
Show resolved Hide resolved
}

func (service *Service) findNewPublishedExamResults(apiExamResults, storedExamResults *[]model.ExamResultPublished) *[]model.ExamResultPublished {
var apiExamResultsMap = make(map[string]model.ExamResultPublished)
for _, apiExamResult := range *apiExamResults {
apiExamResultsMap[apiExamResult.ExamID] = apiExamResult
}

var storedExamResultsMap = make(map[string]model.ExamResultPublished)
for _, storedExamResult := range *storedExamResults {
storedExamResultsMap[storedExamResult.ExamID] = storedExamResult
}

var newPublishedExamResults []model.ExamResultPublished

for id, result := range apiExamResultsMap {
if storedResult, ok := storedExamResultsMap[id]; ok && !storedResult.Published && result.Published {
newPublishedExamResults = append(newPublishedExamResults, result)
}
}

return &newPublishedExamResults
}

func (service *Service) notifySubscribers(newPublishedExamResults *[]model.ExamResultPublished) {
log.Infof("Notifying subscribers about %d published exam results", len(*newPublishedExamResults))

subscribersRepo := new_exam_results_subscriber.NewRepository(service.Repository.DB)
subscribersService := new_exam_results_subscriber.NewService(subscribersRepo)

err := subscribersService.NotifySubscribers(newPublishedExamResults)
if err != nil {
log.WithError(err).Error("Failed to notify subscribers")
return
}
}

func NewService(repository *Repository,
devicesRepository *ios_device.Repository,
apnsService *ios_apns.Service,
) *Service {
return &Service{
Repository: repository,
DevicesRepository: devicesRepository,
Priority: model.DefaultIOSSchedulingPriority(),
APNs: apnsService,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package new_exam_results_subscriber

import (
"bytes"
"encoding/json"
"github.com/TUM-Dev/Campus-Backend/server/model"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"net/http"
)

type Repository struct {
DB *gorm.DB
}

func (repository *Repository) FindAllSubscribers() (*[]model.NewExamResultsSubscriber, error) {
db := repository.DB

var subscribers []model.NewExamResultsSubscriber

err := db.Find(&subscribers).Error
Antonwy marked this conversation as resolved.
Show resolved Hide resolved

return &subscribers, err
}

func (repository *Repository) NotifySubscriber(subscriber *model.NewExamResultsSubscriber, newGrades *[]model.ExamResultPublished) error {
url := subscriber.CallbackUrl

body, err := json.Marshal(newGrades)
if err != nil {
log.WithError(err).Errorf("Error while marshalling newGrades")
CommanderStorm marked this conversation as resolved.
Show resolved Hide resolved
return err
}

req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(body))
Antonwy marked this conversation as resolved.
Show resolved Hide resolved

req.Header.Set("Content-Type", "application/json")

if subscriber.ApiKey.Valid {
req.Header.Set("Authorization", subscriber.ApiKey.String)
}

if err != nil {
log.WithError(err).Errorf("Error while creating request")
CommanderStorm marked this conversation as resolved.
Show resolved Hide resolved
return err
}

_, err = http.DefaultClient.Do(req)
if err != nil {
log.WithError(err).Errorf("Error while fetching %s", url)
CommanderStorm marked this conversation as resolved.
Show resolved Hide resolved
return err
}

return nil
}

func NewRepository(db *gorm.DB) *Repository {
return &Repository{
DB: db,
}
}
Loading