Merge branch 'main' into 150-fix-error-response

# Conflicts:
#	backend/service/addSchedule.go
#	backend/service/fetch/v2/fetcher.go
#	frontend/package-lock.json
This commit is contained in:
Elmar Kresse
2024-01-21 17:59:08 +01:00
58 changed files with 6719 additions and 3348 deletions

View File

@@ -5,9 +5,13 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/cron"
"htwkalender/service/course"
"htwkalender/service/events"
"htwkalender/service/feed"
"htwkalender/service/fetch/sport"
v2 "htwkalender/service/fetch/v2"
"htwkalender/service/functions/time"
"log"
"strconv"
"log/slog"
)
@@ -24,19 +28,37 @@ func AddSchedules(app *pocketbase.PocketBase) {
course.UpdateCourse(app)
})
// Every sunday at 3am clean all courses (5 segments - minute, hour, day, month, weekday) "0 3 * * 0"
scheduler.MustAdd("cleanFeeds", "0 3 * * 0", func() {
// Every sunday at 1am clean all courses (5 segments - minute, hour, day, month, weekday) "0 3 * * 0"
scheduler.MustAdd("cleanFeeds", "0 1 * * 0", func() {
// clean feeds older than 6 months
slog.Info("Started cleaning feeds schedule")
feed.ClearFeeds(app.Dao(), 6, time.RealClock{})
})
// Every sunday at 3am fetch all sport events (5 segments - minute, hour, day, month, weekday) "0 2 * * 0"
scheduler.MustAdd("fetchSportEvents", "0 3 * * 0", func() {
// Every sunday at 2am fetch all sport events (5 segments - minute, hour, day, month, weekday) "0 2 * * 0"
scheduler.MustAdd("fetchEvents", "0 2 * * 0", func() {
slog.Info("Started fetching sport events schedule")
sport.FetchAndUpdateSportEvents(app)
})
//delete all events and then fetch all events from remote this should be done every sunday at 2am
scheduler.MustAdd("fetchEvents", "0 2 * * 0", func() {
err := events.DeleteAllEvents(app)
if err != nil {
log.Println(err)
}
err, savedEvents := v2.FetchAllEventsAndSave(app)
if err != nil {
log.Println(err)
} else {
log.Println("Successfully saved: " + strconv.FormatInt(int64(len(savedEvents)), 10) + " events")
}
})
scheduler.Start()
return nil
})

View File

@@ -7,6 +7,8 @@ import (
)
func Test_getDateFromWeekNumber(t *testing.T) {
europeTime, _ := time.LoadLocation("Europe/Berlin")
type args struct {
year int
weekNumber int
@@ -25,7 +27,7 @@ func Test_getDateFromWeekNumber(t *testing.T) {
weekNumber: 1,
dayName: "Montag",
},
want: time.Date(2021, 1, 4, 0, 0, 0, 0, time.UTC),
want: time.Date(2021, 1, 4, 0, 0, 0, 0, europeTime),
wantErr: false,
},
{
@@ -35,7 +37,7 @@ func Test_getDateFromWeekNumber(t *testing.T) {
weekNumber: 57,
dayName: "Montag",
},
want: time.Date(2024, 1, 29, 0, 0, 0, 0, time.UTC),
want: time.Date(2024, 1, 29, 0, 0, 0, 0, europeTime),
wantErr: false,
},
{
@@ -45,7 +47,7 @@ func Test_getDateFromWeekNumber(t *testing.T) {
weekNumber: 1,
dayName: "Montag",
},
want: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC),
want: time.Date(2023, 1, 2, 0, 0, 0, 0, europeTime),
wantErr: false,
},
}

View File

@@ -127,20 +127,14 @@ func buildIcalQueryForModules(modules []model.FeedCollection) dbx.Expression {
//second check if modules has only one element
if len(modules) == 1 {
return dbx.And(
dbx.HashExp{"Name": modules[0].Name},
dbx.HashExp{"course": modules[0].Course},
)
return dbx.HashExp{"uuid": modules[0].UUID}
}
//third check if modules has more than one element
var wheres []dbx.Expression
for _, module := range modules {
where := dbx.And(
dbx.HashExp{"Name": module.Name},
dbx.HashExp{"course": module.Course},
)
where := dbx.HashExp{"uuid": module.UUID}
wheres = append(wheres, where)
}
@@ -196,16 +190,16 @@ func GetAllModulesForCourse(app *pocketbase.PocketBase, course string, semester
return events, nil
}
func GetAllModulesDistinctByNameAndCourse(app *pocketbase.PocketBase) (model.Events, error) {
var events model.Events
func GetAllModulesDistinctByNameAndCourse(app *pocketbase.PocketBase) ([]model.ModuleDTO, error) {
var modules []model.ModuleDTO
err := app.Dao().DB().Select("*").From("events").GroupBy("Name").Distinct(true).All(&events)
err := app.Dao().DB().Select("Name", "EventType", "Prof", "course", "semester", "uuid").From("events").GroupBy("Name", "Course").Distinct(true).All(&modules)
if err != nil {
slog.Error("Error while getting events from database: ", err)
return nil, fmt.Errorf("error while getting events distinct by name and course from data")
}
return events, nil
return modules, nil
}
func DeleteAllEventsForCourse(app *pocketbase.PocketBase, course string, semester string) error {

View File

@@ -23,13 +23,13 @@ func Test_buildIcalQueryForModules(t *testing.T) {
},
{
name: "one module",
args: args{modules: []model.FeedCollection{{Name: "test", Course: "test"}}},
want: dbx.And(dbx.HashExp{"Name": "test"}, dbx.HashExp{"course": "test"}),
args: args{modules: []model.FeedCollection{{Name: "test", Course: "test", UUID: "test"}}},
want: dbx.HashExp{"uuid": "test"},
},
{
name: "two modules",
args: args{modules: []model.FeedCollection{{Name: "test", Course: "test"}, {Name: "test2", Course: "test2"}}},
want: dbx.Or(dbx.And(dbx.HashExp{"Name": "test"}, dbx.HashExp{"course": "test"}), dbx.And(dbx.HashExp{"Name": "test2"}, dbx.HashExp{"course": "test2"})),
args: args{modules: []model.FeedCollection{{Name: "test", Course: "test", UUID: "test"}, {Name: "test2", Course: "test2", UUID: "test2"}}},
want: dbx.Or(dbx.HashExp{"uuid": "test"}, dbx.HashExp{"uuid": "test2"}),
},
}
for _, tt := range tests {

View File

@@ -13,25 +13,47 @@ import (
func GetRooms(app *pocketbase.PocketBase) ([]string, error) {
var events []struct {
Rooms string `db:"Rooms" json:"Rooms"`
Rooms string `db:"Rooms" json:"Rooms"`
Course string `db:"course" json:"Course"`
}
// get all rooms from event records in the events collection
err := app.Dao().DB().Select("Rooms").From("events").All(&events)
err := app.Dao().DB().Select("Rooms", "course").From("events").Distinct(true).All(&events)
if err != nil {
return nil, err
}
var roomArray []string
roomArray := clearAndSeparateRooms([]struct {
Rooms string
Course string
}(events))
return roomArray
}
func clearAndSeparateRooms(events []struct {
Rooms string
Course string
}) []string {
var roomArray []string
for _, event := range events {
var room = strings.FieldsFunc(event.Rooms, functions.IsSeparator(
[]rune{',', ' ', '\t', '\n', '\r', '\u00A0'},
))
var room []string
// sport rooms don't have to be separated
if event.Course != "Sport" {
//split rooms by comma, tab, newline, carriage return, semicolon, space and non-breaking space
room = strings.FieldsFunc(event.Rooms, functions.IsSeparator(
[]rune{',', '\t', '\n', '\r', ';', ' ', '\u00A0'}),
)
} else {
room = append(room, event.Rooms)
}
//split functions room by space and add each room to array if it is not already in there
for _, r := range room {
var text = strings.TrimSpace(r)
if !functions.Contains(roomArray, text) && !strings.Contains(text, " ") && len(text) >= 1 {
if !functions.Contains(roomArray, text) && len(text) >= 1 {
roomArray = append(roomArray, text)
}
}

View File

@@ -12,17 +12,28 @@ import (
func GetModulesForCourseDistinct(app *pocketbase.PocketBase, course string, semester string) (model.Events, error) {
modules, err := db.GetAllModulesForCourse(app, course, semester)
replaceEmptyEntry(modules, "Sonderveranstaltungen")
// Convert the []model.Module to []Named
var namedEvents []Named
for _, module := range modules {
namedEvents = append(namedEvents, &module)
}
replaceEmptyEntry(namedEvents, "Sonderveranstaltungen")
return modules, err
}
type Named interface {
GetName() string
SetName(name string)
}
// replaceEmptyEntry replaces an empty entry in a module with a replacement string
// If the module is not empty, nothing happens
func replaceEmptyEntry(modules model.Events, replacement string) {
for i, module := range modules {
if functions.OnlyWhitespace(module.Name) {
modules[i].Name = replacement
func replaceEmptyEntry(namedList []Named, replacement string) {
for i, namedItem := range namedList {
if functions.OnlyWhitespace(namedItem.GetName()) {
namedList[i].SetName(replacement)
}
}
}
@@ -32,7 +43,12 @@ func replaceEmptyEntry(modules model.Events, replacement string) {
func GetAllModulesDistinct(app *pocketbase.PocketBase, c echo.Context) error {
modules, err := db.GetAllModulesDistinctByNameAndCourse(app)
replaceEmptyEntry(modules, "Sonderveranstaltungen")
var namedModules []Named
for _, module := range modules {
namedModules = append(namedModules, &module)
}
replaceEmptyEntry(namedModules, "Sonderveranstaltungen")
if err != nil {
return c.JSON(400, err)

View File

@@ -18,8 +18,9 @@ import (
"github.com/PuerkitoBio/goquery"
)
// @TODO: add tests
// @TODO: make it like a cron job to fetch the sport courses once a week
// FetchAndUpdateSportEvents fetches all sport events from the HTWK sport website
// it deletes them first and then saves them to the database
// It returns all saved events
func FetchAndUpdateSportEvents(app *pocketbase.PocketBase) []model.Event {
var sportCourseLinks = fetchAllAvailableSportCourses()
@@ -56,6 +57,7 @@ func FetchAndUpdateSportEvents(app *pocketbase.PocketBase) []model.Event {
}
}
// @TODO: delete and save events in one transaction and it only should delete events that are not in the new events list and save events that are not in the database
err = db.DeleteAllEventsForCourse(app, "Sport", functions.GetCurrentSemesterString())
if err != nil {
return nil
@@ -89,7 +91,7 @@ func formatEntriesToEvents(entries []model.SportEntry) []model.Event {
Week: strconv.Itoa(23),
Start: start,
End: end,
Name: entry.Title + " " + entry.Details.Type + " (" + entry.ID + ")",
Name: entry.Title + " (" + entry.ID + ")",
EventType: entry.Details.Type,
Prof: entry.Details.CourseLead.Name,
Rooms: entry.Details.Location.Name,

View File

@@ -267,7 +267,6 @@ func Test_generateUUIDs(t *testing.T) {
}
func Test_createTimeFromHourAndMinuteString(t *testing.T) {
europeTime, _ := time.LoadLocation("Europe/Berlin")
type args struct {
tableTime string
}
@@ -281,21 +280,21 @@ func Test_createTimeFromHourAndMinuteString(t *testing.T) {
args: args{
tableTime: "08:00",
},
want: time.Date(0, 0, 0, 8, 0, 0, 0, europeTime),
want: time.Date(0, 0, 0, 8, 0, 0, 0, time.UTC),
},
{
name: "Test 2",
args: args{
tableTime: "08:15",
},
want: time.Date(0, 0, 0, 8, 15, 0, 0, europeTime),
want: time.Date(0, 0, 0, 8, 15, 0, 0, time.UTC),
},
{
name: "Test 3",
args: args{
tableTime: "08:30",
},
want: time.Date(0, 0, 0, 8, 30, 0, 0, europeTime),
want: time.Date(0, 0, 0, 8, 30, 0, 0, time.UTC),
},
}
for _, tt := range tests {

View File

@@ -35,7 +35,7 @@ func toEvents(tables [][]*html.Node, days []string) []model.Event {
Prof: getTextContent(tableData[6]),
Rooms: getTextContent(tableData[8]),
BookedAt: getTextContent(tableData[10]),
Course: course,
Course: strings.TrimSpace(course),
})
}
}

View File

@@ -23,8 +23,33 @@ func ParseEventsFromRemote(app *pocketbase.PocketBase) (model.Events, error) {
func FetchAllEventsAndSave(app *pocketbase.PocketBase, clock localTime.Clock) ([]model.Event, error) {
var savedRecords []model.Event
var stubUrl = [2]string{
"https://stundenplan.htwk-leipzig.de/",
"/Berichte/Text-Listen;Veranstaltungsarten;name;" +
"Vp%0A" +
"Vw%0A" +
"V%0A" +
"Sp%0A" +
"Sw%0A" +
"S%0A" +
"Pp%0A" +
"Pw%0A" +
"P%0A" +
"ZV%0A" +
"Tut%0A" +
"Sperr%0A" +
"pf%0A" +
"wpf%0A" +
"fak%0A" +
"Pruefung%0A" +
"Vertretung%0A" +
"Fremdveranst.%0A" +
"Buchen%0A" +
"%0A?&template=sws_modul&weeks=1-65&combined=yes",
}
if (clock.Now().Month() >= 3) && (clock.Now().Month() <= 10) {
url := "https://stundenplan.htwk-leipzig.de/ss/Berichte/Text-Listen;Veranstaltungsarten;name;Vp%0AVw%0AV%0ASp%0ASw%0AS%0APp%0APw%0AP%0AZV%0ATut%0ASperr%0Apf%0Awpf%0Afak%0A%0A?&template=sws_modul&weeks=1-65&combined=yes"
url := stubUrl[0] + "ss" + stubUrl[1]
events, err := parseEventForOneSemester(url)
if err != nil {
return nil, fmt.Errorf("failed to parse events for summmer semester: %w", err)
@@ -37,7 +62,7 @@ func FetchAllEventsAndSave(app *pocketbase.PocketBase, clock localTime.Clock) ([
}
if (clock.Now().Month() >= 9) || (clock.Now().Month() <= 4) {
url := "https://stundenplan.htwk-leipzig.de/ws/Berichte/Text-Listen;Veranstaltungsarten;name;Vp%0AVw%0AV%0ASp%0ASw%0AS%0APp%0APw%0AP%0AZV%0ATut%0ASperr%0Apf%0Awpf%0Afak%0A%0A?&template=sws_modul&weeks=1-65&combined=yes"
url := stubUrl[0] + "ws" + stubUrl[1]
events, err := parseEventForOneSemester(url)
if err != nil {
return nil, fmt.Errorf("failed to parse events for winter semester: %w", err)
@@ -46,7 +71,7 @@ func FetchAllEventsAndSave(app *pocketbase.PocketBase, clock localTime.Clock) ([
if dbError != nil {
return nil, fmt.Errorf("failed to save events: %w", dbError)
}
savedRecords = append(savedEvents, events...)
savedRecords = append(savedRecords, savedEvents...)
}
return savedRecords, nil
}
@@ -87,21 +112,26 @@ func parseEventForOneSemester(url string) ([]model.Event, error) {
semesterString := findFirstSpanWithClass(table, "header-0-2-0").FirstChild.Data
semester, year := extractSemesterAndYear(semesterString)
events = convertWeeksToDates(events, semester, year)
events = generateUUIDs(events)
events = splitEventType(events)
var seminarGroup = model.SeminarGroup{
University: findFirstSpanWithClass(table, "header-1-0-0").FirstChild.Data,
Events: events,
}
if seminarGroup.Events == nil && seminarGroup.University == "" {
return nil, err
}
events = switchNameAndNotesForExam(events)
events = generateUUIDs(events)
return events, nil
}
// switch name and notes for Pruefung events when Note is not empty and Name starts with "Prüfungen" and contains email
func switchNameAndNotesForExam(events []model.Event) []model.Event {
for i, event := range events {
if event.EventType == "Pruefung" {
if event.Notes != "" && strings.HasPrefix(event.Name, "Prüfungen") && strings.Contains(event.Name, "@") {
events[i].Name = event.Notes
events[i].Notes = event.Name
}
}
}
return events
}
func parseHTML(webpage string, err error) (*html.Node, error) {
doc, err := html.Parse(strings.NewReader(webpage))
if err != nil {

View File

@@ -0,0 +1,83 @@
package v2
import (
"htwkalender/model"
"reflect"
"testing"
)
func Test_switchNameAndNotesForExam(t *testing.T) {
type args struct {
events []model.Event
}
tests := []struct {
name string
args args
want []model.Event
}{
{
name: "switch name and notes for exam",
args: args{
events: []model.Event{
{
EventType: "Pruefung",
Name: "Prüfungen FING/EIT WiSe (pruefungsamt.fing-eit@htwk-leipzig.de)",
Notes: "Computer Vision II - Räume/Zeit unter Vorbehalt- (Raum W111.1)",
},
},
},
want: []model.Event{
{
EventType: "Pruefung",
Name: "Computer Vision II - Räume/Zeit unter Vorbehalt- (Raum W111.1)",
Notes: "Prüfungen FING/EIT WiSe (pruefungsamt.fing-eit@htwk-leipzig.de)",
},
},
},
{
name: "dont switch name and notes for exam",
args: args{
events: []model.Event{
{
EventType: "Pruefung",
Name: "i054 Umweltschutz und Recycling DPB & VNB 7.FS (wpf)",
Notes: "Prüfung",
},
},
},
want: []model.Event{
{
EventType: "Pruefung",
Notes: "Prüfung",
Name: "i054 Umweltschutz und Recycling DPB & VNB 7.FS (wpf)",
},
},
},
{
name: "dont switch name and notes for exam",
args: args{
events: []model.Event{
{
EventType: "Pruefung",
Name: "Prüfungen FING/ME WiSe (pruefungsamt.fing-me@htwk-leipzig.de)",
Notes: "",
},
},
},
want: []model.Event{
{
EventType: "Pruefung",
Notes: "",
Name: "Prüfungen FING/ME WiSe (pruefungsamt.fing-me@htwk-leipzig.de)",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := switchNameAndNotesForExam(tt.args.events); !reflect.DeepEqual(got, tt.want) {
t.Errorf("switchNameAndNotesForExam() = %v, want %v", got, tt.want)
}
})
}
}