Merge branch 'main' into 104-exams-in-calendar

# Conflicts:
#	backend/service/addSchedule.go
This commit is contained in:
masterElmar
2023-12-19 20:56:07 +01:00
21 changed files with 976 additions and 41 deletions

View File

@@ -3,6 +3,7 @@ module htwkalender
go 1.21
require (
github.com/PuerkitoBio/goquery v1.8.1
github.com/google/uuid v1.3.1
github.com/jordic/goics v0.0.0-20210404174824-5a0337b716a0
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
@@ -13,6 +14,7 @@ require (
require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go v1.46.3 // indirect
github.com/aws/aws-sdk-go-v2 v1.21.2 // indirect

View File

@@ -14,6 +14,10 @@ github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1r
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
@@ -239,8 +243,10 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -258,25 +264,30 @@ golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=

View File

@@ -2,6 +2,7 @@ package model
import (
"slices"
"strings"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
@@ -13,21 +14,30 @@ func (m Events) Contains(event Event) bool {
return slices.Contains(m, event)
}
type AnonymizedEventDTO struct {
Day string `db:"Day" json:"day"`
Week string `db:"Week" json:"week"`
Start types.DateTime `db:"start" json:"start"`
End types.DateTime `db:"end" json:"end"`
Rooms string `db:"Rooms" json:"rooms"`
Free bool `json:"free"`
}
type Event struct {
UUID string `db:"uuid" json:"uuid"`
Day string `db:"Day" json:"day"`
Week string `db:"Week" json:"week"`
Start types.DateTime `db:"start" json:"start"`
End types.DateTime `db:"end" json:"end"`
Name string `db:"Name" json:"name"`
EventType string `db:"EventType" json:"eventType"`
Compulsory string `db:"Compulsory" json:"compulsory"`
Prof string `db:"Prof" json:"prof"`
Rooms string `db:"Rooms" json:"rooms"`
Notes string `db:"Notes" json:"notes"`
BookedAt string `db:"BookedAt" json:"bookedAt"`
Course string `db:"course" json:"course"`
Semester string `db:"semester" json:"semester"`
UUID string `db:"uuid" json:"uuid"`
Day string `db:"Day" json:"day"`
Week string `db:"Week" json:"week"`
Start types.DateTime `db:"start" json:"start"`
End types.DateTime `db:"end" json:"end"`
Name string `db:"Name" json:"name"`
EventType string `db:"EventType" json:"eventType"`
Compulsory string `db:"Compulsory" json:"compulsory"`
Prof string `db:"Prof" json:"prof"`
Rooms string `db:"Rooms" json:"rooms"`
Notes string `db:"Notes" json:"notes"`
BookedAt string `db:"BookedAt" json:"bookedAt"`
Course string `db:"course" json:"course"`
Semester string `db:"semester" json:"semester"`
models.BaseModel
}
@@ -52,3 +62,15 @@ func (m *Event) SetCourse(course string) Event {
m.Course = course
return *m
}
// Creates an AnonymizedEventDTO from an Event hiding all sensitive data
func (m *Event) AnonymizeEvent() AnonymizedEventDTO {
return AnonymizedEventDTO{
Day: m.Day,
Week: m.Week,
Start: m.Start,
End: m.End,
Rooms: m.Rooms,
Free: strings.Contains(strings.ToLower(m.Name), "zur freien verfügung"),
}
}

View File

@@ -1,6 +1,7 @@
package model
import (
"reflect"
"testing"
"github.com/pocketbase/pocketbase/models"
@@ -126,3 +127,73 @@ func TestEvent_Equals(t *testing.T) {
})
}
}
func TestEvent_AnonymizeEvent(t *testing.T) {
type fields struct {
UUID string
Day string
Week string
Start types.DateTime
End types.DateTime
Name string
EventType string
Compulsory string
Prof string
Rooms string
Notes string
BookedAt string
Course string
Semester string
BaseModel models.BaseModel
}
tests := []struct {
name string
fields fields
want AnonymizedEventDTO
}{
{
name: "empty event",
fields: fields{},
want: AnonymizedEventDTO{Day: "", Week: "", Start: types.DateTime{}, End: types.DateTime{}, Rooms: "", Free: false},
},
{
name: "one event",
fields: fields{Name: "Event", Day: "test", Week: "test", Rooms: "test"},
want: AnonymizedEventDTO{Day: "test", Week: "test", Start: types.DateTime{}, End: types.DateTime{}, Rooms: "test", Free: false},
},
{
name: "one event with free",
fields: fields{Name: "Räume zur freien Verfügung", Day: "test", Week: "test", Rooms: "test", Course: "test"},
want: AnonymizedEventDTO{Day: "test", Week: "test", Start: types.DateTime{}, End: types.DateTime{}, Rooms: "test", Free: true},
},
{
name: "another free event",
fields: fields{Name: "Zur freien Verfügung", Day: "Montag", Week: "5", Start: types.DateTime{}, End: types.DateTime{}, Rooms: "TR_A1.28-S", Course: "42INM-3"},
want: AnonymizedEventDTO{Day: "Montag", Week: "5", Start: types.DateTime{}, End: types.DateTime{}, Rooms: "TR_A1.28-S", Free: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Event{
UUID: tt.fields.UUID,
Day: tt.fields.Day,
Week: tt.fields.Week,
Start: tt.fields.Start,
End: tt.fields.End,
Name: tt.fields.Name,
EventType: tt.fields.EventType,
Compulsory: tt.fields.Compulsory,
Prof: tt.fields.Prof,
Rooms: tt.fields.Rooms,
Notes: tt.fields.Notes,
BookedAt: tt.fields.BookedAt,
Course: tt.fields.Course,
Semester: tt.fields.Semester,
BaseModel: tt.fields.BaseModel,
}
if got := m.AnonymizeEvent(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Event.AnonymizeEvent() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,56 @@
package model
import "time"
// MODELS
// SportEntry represents the overall event details.
type SportEntry struct {
Title string
Details EventDetails
AdditionalNote string
ID string
}
// EventDetails represents detailed information about the event.
type EventDetails struct {
DateRange DateRange
Cycle string
Gender string
CourseLead CourseLead
Location Location
Participants Participants
Cost string
Type string
}
// DateRange represents a start and end date.
type DateRange struct {
Start time.Time
End time.Time
}
// CourseLead represents a person with a name and a contact link.
type CourseLead struct {
Name string
Link string
}
// Location represents the location of the event.
type Location struct {
Name string
Address string
}
// Participants represents the participants' details.
type Participants struct {
Bookings int
TotalPlaces int
WaitList int
}
type SportDayStartEnd struct {
Start time.Time
End time.Time
Day time.Weekday
}

View File

@@ -8,7 +8,7 @@ servers:
- url: http://localhost:8090
description: Local server
paths:
/api/fetchPlans:
/api/fetch/events:
get:
summary: Fetch Seminar Plans
security:
@@ -16,7 +16,7 @@ paths:
responses:
'200':
description: Successful response
/api/fetchGroups:
/api/fetch/groups:
get:
summary: Fetch Seminar Groups
security:
@@ -24,6 +24,14 @@ paths:
responses:
'200':
description: Successful response
/api/fetch/sports:
get:
summary: Fetch Sport Events from HTWK Leipzig
security:
- ApiKeyAuth: [ ]
responses:
'200':
description: Successful response
/api/modules:
delete:
summary: Delete Module

View File

@@ -3,6 +3,7 @@ package service
import (
"htwkalender/service/db"
"htwkalender/service/events"
"htwkalender/service/fetch/sport"
v1 "htwkalender/service/fetch/v1"
v2 "htwkalender/service/fetch/v2"
"htwkalender/service/ical"
@@ -54,6 +55,26 @@ func AddRoutes(app *pocketbase.PocketBase) {
return nil
})
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/fetch/sports",
Handler: func(c echo.Context) error {
sportEvents := sport.FetchAndUpdateSportEvents(app)
return c.JSON(200, sportEvents)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app),
apis.RequireAdminAuth(),
},
})
if err != nil {
return err
}
return nil
})
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodDelete,

View File

@@ -7,6 +7,7 @@ import (
"htwkalender/service/course"
"htwkalender/service/events"
"htwkalender/service/feed"
"htwkalender/service/fetch/sport"
v2 "htwkalender/service/fetch/v2"
"htwkalender/service/functions/time"
"log"
@@ -31,6 +32,12 @@ func AddSchedules(app *pocketbase.PocketBase) {
feed.ClearFeeds(app.Dao(), 6, time.RealClock{})
})
// 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() {
sport.FetchAndUpdateSportEvents(app)
})
//delete all events and then fetch all events from remote this should be done every day at 4am
scheduler.MustAdd("fetchEvents", "0 4 * * *", func() {
err := events.DeleteAllEvents(app)

View File

@@ -2,6 +2,7 @@ package db
import (
"htwkalender/model"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
@@ -251,3 +252,15 @@ func FindAllEventsByModule(app *pocketbase.PocketBase, module model.Module) (mod
return events, nil
}
func GetAllModulesByNameAndDateRange(app *pocketbase.PocketBase, name string, startDate time.Time, endDate time.Time) (model.Events, error) {
var events model.Events
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Name = {:name} AND Start >= {:startDate} AND End <= {:endDate}", dbx.Params{"name": name, "startDate": startDate, "endDate": endDate})).All(&events)
if err != nil {
print("Error while getting events from database: ", err)
return nil, err
}
return events, nil
}

View File

@@ -0,0 +1,495 @@
package sport
import (
"errors"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/tools/types"
"htwkalender/model"
"htwkalender/service/db"
"htwkalender/service/functions"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
)
// @TODO: add tests
// @TODO: make it like a cron job to fetch the sport courses once a week
func FetchAndUpdateSportEvents(app *pocketbase.PocketBase) []model.Event {
var sportCourseLinks = fetchAllAvailableSportCourses()
sportEntries := fetchHTWKSportCourses(sportCourseLinks)
events := formatEntriesToEvents(sportEntries)
var earliestDate time.Time
var latestDate time.Time
// find earliest and latest date in events
for _, event := range events {
if event.Start.Time().Before(earliestDate) {
earliestDate = event.Start.Time()
}
if event.End.Time().After(latestDate) {
latestDate = event.End.Time()
}
}
// get all events from database where name = Feiertage und lehrveranstaltungsfreie Tage
holidays, err := db.GetAllModulesByNameAndDateRange(app, "Feiertage und lehrveranstaltungsfreie Tage", earliestDate, latestDate)
if err != nil {
return nil
}
// remove all events that have same year, month and day as items in holidays
for _, holiday := range holidays {
for i, event := range events {
if event.Start.Time().Year() == holiday.Start.Time().Year() &&
event.Start.Time().Month() == holiday.Start.Time().Month() &&
event.Start.Time().Day() == holiday.Start.Time().Day() {
events = append(events[:i], events[i+1:]...)
}
}
}
err = db.DeleteAllEventsForCourse(app, "Sport", functions.GetCurrentSemesterString())
if err != nil {
return nil
}
// save events to database
savedEvents, err := db.SaveEvents(events, app)
if err != nil {
return nil
}
return savedEvents
}
func formatEntriesToEvents(entries []model.SportEntry) []model.Event {
var events []model.Event
for _, entry := range entries {
eventStarts, eventEnds := getWeekEvents(entry.Details.DateRange.Start, entry.Details.DateRange.End, entry.Details.Cycle)
for j := range eventStarts {
start, _ := types.ParseDateTime(eventStarts[j].In(time.UTC))
end, _ := types.ParseDateTime(eventEnds[j].In(time.UTC))
var event = model.Event{
UUID: uuid.NewSHA1(uuid.NameSpaceDNS, []byte(entry.Title+entry.ID+entry.Details.Type)).String(),
Day: toGermanWeekdayString(entry.Details.DateRange.Start.Weekday()),
Week: strconv.Itoa(23),
Start: start,
End: end,
Name: entry.Title + " " + entry.Details.Type + " (" + entry.ID + ")",
EventType: entry.Details.Type,
Prof: entry.Details.CourseLead.Name,
Rooms: entry.Details.Location.Name,
Notes: entry.AdditionalNote,
BookedAt: "",
Course: "Sport",
Semester: checkSemester(entry.Details.DateRange.Start),
}
events = append(events, event)
}
}
return events
}
func getDayInt(weekDay string) int {
var weekDayInt int
switch weekDay {
case "Mo":
weekDayInt = 1
case "Di":
weekDayInt = 2
case "Mi":
weekDayInt = 3
case "Do":
weekDayInt = 4
case "Fr":
weekDayInt = 5
case "Sa":
weekDayInt = 6
case "So":
weekDayInt = 0
}
return weekDayInt
}
func toGermanWeekdayString(weekday time.Weekday) string {
switch weekday {
case time.Monday:
return "Montag"
case time.Tuesday:
return "Dienstag"
case time.Wednesday:
return "Mittwoch"
case time.Thursday:
return "Donnerstag"
case time.Friday:
return "Freitag"
case time.Saturday:
return "Samstag"
case time.Sunday:
return "Sonntag"
default:
return ""
}
}
func extractStartAndEndTime(cycle string) (int, int, int, int) {
timeRegExp, _ := regexp.Compile("[0-9]{2}:[0-9]{2}")
times := timeRegExp.FindAllString(cycle, 2)
startHour, _ := strconv.Atoi(times[0][0:2])
startMinute, _ := strconv.Atoi(times[0][3:5])
endHour, _ := strconv.Atoi(times[1][0:2])
endMinute, _ := strconv.Atoi(times[1][3:5])
return startHour, startMinute, endHour, endMinute
}
func getWeekEvents(start time.Time, end time.Time, cycle string) ([]time.Time, []time.Time) {
var weekEvents []model.SportDayStartEnd
// split by regexp to get the cycle parts
var cycleParts []string
cycleParts = splitByCommaWithTime(cycle)
for _, cyclePart := range cycleParts {
//cut string at the first integer/number
cyclePartWithDaysOnly := cyclePart[0:strings.IndexFunc(cyclePart, func(r rune) bool { return r >= '0' && r <= '9' })]
// check if cycle has multiple days by checking if it has a plus sign
if strings.Contains(cyclePartWithDaysOnly, "+") {
// find all days in cycle part by regexp
dayRegExp, _ := regexp.Compile("[A-Z][a-z]")
days := dayRegExp.FindAllString(cyclePart, -1)
startHour, startMinute, endHour, endMinute := extractStartAndEndTime(cyclePart)
// creating a SportDayStartEnd for each day in the cycle
for _, day := range days {
weekEvents = append(weekEvents, model.SportDayStartEnd{
Start: time.Date(start.Year(), start.Month(), start.Day(), startHour, startMinute, 0, 0, start.Location()),
End: time.Date(end.Year(), end.Month(), end.Day(), endHour, endMinute, 0, 0, end.Location()),
Day: time.Weekday(getDayInt(day)),
})
}
}
// check if cycle has multiple days by checking if it has a minus sign
if strings.Contains(cyclePartWithDaysOnly, "-") {
// find all days in cycle part by regexp
dayRegExp, _ := regexp.Compile("[A-Z][a-z]")
days := dayRegExp.FindAllString(cyclePart, 2)
startHour, startMinute, endHour, endMinute := extractStartAndEndTime(cyclePart)
//create a int array with all days from start to end day
var daysBetween []int
for i := getDayInt(days[0]); i <= getDayInt(days[1]); i++ {
daysBetween = append(daysBetween, i)
}
// creating a SportDayStartEnd for each day in the cycle
for _, day := range daysBetween {
weekEvents = append(weekEvents, model.SportDayStartEnd{
Start: time.Date(start.Year(), start.Month(), start.Day(), startHour, startMinute, 0, 0, start.Location()),
End: time.Date(end.Year(), end.Month(), end.Day(), endHour, endMinute, 0, 0, end.Location()),
Day: time.Weekday(day),
})
}
}
// check if cycle has only one day
if !strings.Contains(cyclePartWithDaysOnly, "-") && !strings.Contains(cyclePartWithDaysOnly, "+") {
// find all days in cycle part by regexp
dayRegExp, _ := regexp.Compile("[A-Z][a-z]")
days := dayRegExp.FindAllString(cyclePart, -1)
startHour, startMinute, endHour, endMinute := extractStartAndEndTime(cyclePart)
// creating a SportDayStartEnd for each day in the cycle
for _, day := range days {
weekEvents = append(weekEvents, model.SportDayStartEnd{
Start: time.Date(start.Year(), start.Month(), start.Day(), startHour, startMinute, 0, 0, start.Location()),
End: time.Date(end.Year(), end.Month(), end.Day(), endHour, endMinute, 0, 0, end.Location()),
Day: time.Weekday(getDayInt(day)),
})
}
}
}
var startDatesList []time.Time
var endDatesList []time.Time
for _, weekEvent := range weekEvents {
startDates, endDates := createEventListFromStartToEndMatchingDay(weekEvent)
startDatesList = append(startDatesList, startDates...)
endDatesList = append(endDatesList, endDates...)
}
return startDatesList, endDatesList
}
func createEventListFromStartToEndMatchingDay(weekEvent model.SportDayStartEnd) ([]time.Time, []time.Time) {
var startDates []time.Time
var endDates []time.Time
for d := weekEvent.Start; d.Before(weekEvent.End); d = d.AddDate(0, 0, 1) {
if d.Weekday() == weekEvent.Day {
startDates = append(startDates, time.Date(d.Year(), d.Month(), d.Day(), weekEvent.Start.Hour(), weekEvent.Start.Minute(), 0, 0, d.Location()))
endDates = append(endDates, time.Date(d.Year(), d.Month(), d.Day(), weekEvent.End.Hour(), weekEvent.End.Minute(), 0, 0, d.Location()))
}
}
return startDates, endDates
}
func splitByCommaWithTime(input string) []string {
var result []string
// Split by comma
parts := strings.Split(input, ", ")
// Regular expression to match a day with time
regex := regexp.MustCompile(`([A-Za-z]{2,}(\+[A-Za-z]{2,})* \d{2}:\d{2}-\d{2}:\d{2})`)
// Iterate over parts and combine when necessary
var currentPart string
for _, part := range parts {
if regex.MatchString(part) {
if currentPart != "" {
currentPart += ", " + part
result = append(result, currentPart)
currentPart = ""
} else {
result = append(result, part)
}
// If the part contains a day with time, start a new currentPart
} else {
// If there's no currentPart, start a new one
if currentPart != "" {
currentPart += ", " + part
} else {
currentPart = part
}
}
}
// Add the last currentPart to the result
if currentPart != "" {
result = append(result, currentPart)
}
return result
}
// check if ws or ss
func checkSemester(date time.Time) string {
if date.Month() >= 4 && date.Month() <= 9 {
return "ss"
} else {
return "ws"
}
}
// fetch the main page where all sport courses are listed and extract all links to the sport courses
func fetchAllAvailableSportCourses() []string {
var url = "https://sport.htwk-leipzig.de/sportangebote"
var doc, err = htmlRequest(url)
if err != nil {
return nil
}
// link list of all sport courses
var links []string
// find all links to sport courses with regex https://sport.htwk-leipzig.de/sportangebote/detail/sport/ + [0-9]{1,4}
doc.Find("a[href]").Each(func(i int, s *goquery.Selection) {
link, _ := s.Attr("href")
if strings.HasPrefix(link, "/sportangebote/detail/sport/") {
links = append(links, link)
}
})
return links
}
// fetchAllHTWKSportCourses fetches all sport courses from the given links.
// to speed up the process, it uses multithreading.
func fetchHTWKSportCourses(links []string) []model.SportEntry {
//multithreaded webpage requests to speed up the process
var maxThreads = 10
var htmlPageArray = make([]*goquery.Document, len(links))
var hostUrl = "https://sport.htwk-leipzig.de"
var wg sync.WaitGroup
wg.Add(maxThreads)
for i := 0; i < maxThreads; i++ {
go func(i int) {
for j := i; j < len(links); j += maxThreads {
doc, err := htmlRequest(hostUrl + links[j])
if err == nil {
htmlPageArray[j] = doc
}
}
wg.Done()
}(i)
}
wg.Wait()
var events []model.SportEntry
for _, doc := range htmlPageArray {
if doc != nil {
event, err := fetchHtwkSportCourse(doc)
if err == nil {
events = append(events, event...)
}
}
}
return events
}
func htmlRequest(url string) (*goquery.Document, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, err
}
return doc, nil
}
// fetchHtwkSportCourse fetches the sport course from the given url and id.
// If the sport course does not exist, it will return an error.
// If the sport course exists, it will return the sport course.
// goquery is used to parse the html. The html structure is not very consistent, so it is hard to parse.
// May be improved in the future.
func fetchHtwkSportCourse(doc *goquery.Document) ([]model.SportEntry, error) {
var events []model.SportEntry
germanTime, _ := time.LoadLocation("Europe/Berlin")
if doc.Find("h1").Text() == "Aktuelle Sportangebote" {
return nil, errors.New("not a sport course page")
}
doc.Find(".eventHead").Each(func(i int, s *goquery.Selection) {
var event model.SportEntry
var details model.EventDetails
fullTitle := strings.TrimSpace(s.Find("h3").Text())
titleParts := strings.Split(fullTitle, "-")
if len(titleParts) > 0 {
event.Title = strings.TrimSpace(titleParts[0])
}
if len(titleParts) > 2 {
details.Type = strings.TrimSpace(titleParts[len(titleParts)-1])
}
event.ID = parseEventID(fullTitle)
s.NextFiltered("table.eventDetails").Find("tr").Each(func(i int, s *goquery.Selection) {
key := strings.TrimSpace(s.Find("td").First().Text())
value := strings.TrimSpace(s.Find("td").Last().Text())
switch key {
case "Zeitraum":
dates := strings.Split(value, "-")
if len(dates) == 2 {
startDate, _ := time.ParseInLocation("02.01.2006", strings.TrimSpace(dates[0]), germanTime)
endDate, _ := time.ParseInLocation("02.01.2006", strings.TrimSpace(dates[1]), germanTime)
details.DateRange = model.DateRange{Start: startDate, End: endDate}
}
case "Zyklus":
details.Cycle = value
case "Geschlecht":
details.Gender = value
case "Leiter":
leaderName := strings.TrimSpace(s.Find("td a").Text())
leadersSlice := strings.Split(leaderName, "\n")
for i, leader := range leadersSlice {
leadersSlice[i] = strings.TrimSpace(leader)
}
formattedLeaders := strings.Join(leadersSlice, ", ")
leaderLink, _ := s.Find("td a").Attr("href")
details.CourseLead = model.CourseLead{Name: formattedLeaders, Link: leaderLink}
case "Ort":
locationDetails := strings.Split(value, "(")
if len(locationDetails) == 2 {
details.Location = model.Location{
Name: strings.TrimSpace(locationDetails[0]),
Address: strings.TrimRight(strings.TrimSpace(locationDetails[1]), ")"),
}
}
case "Teilnehmer":
parts := strings.Split(value, "/")
if len(parts) >= 3 {
bookings, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
totalPlaces, _ := strconv.Atoi(strings.TrimSpace(parts[1]))
waitList, _ := strconv.Atoi(strings.TrimSpace(parts[2]))
details.Participants = model.Participants{Bookings: bookings, TotalPlaces: totalPlaces, WaitList: waitList}
}
case "Kosten":
details.Cost = value // makes no sense since you need to be logged in to see the price
case "Hinweis":
var allNotes []string
s.Find("td").Last().Contents().Each(func(i int, s *goquery.Selection) {
if s.Is("h4.eventAdvice") || goquery.NodeName(s) == "#text" {
note := strings.TrimSpace(s.Text())
if note != "" {
allNotes = append(allNotes, note)
}
}
})
event.AdditionalNote = strings.Join(allNotes, " ")
}
})
event.Details = details
events = append(events, event)
})
return events, nil
}
// parseEventID from fulltitle
// the event id is a number in the fulltitle thats not a time like HH:MM and shoudl be found after Nr. or Nr:
func parseEventID(fulltitle string) string {
var eventID string
var numberRegExp = regexp.MustCompile("[0-9]{1,4}")
var fulltitleParts = strings.Split(fulltitle, " ")
for i, part := range fulltitleParts {
if part == "Nr." || part == "Nr:" {
eventID = fulltitleParts[i+1]
break
}
}
if eventID == "" {
eventID = numberRegExp.FindString(fulltitle)
}
return eventID
}

View File

@@ -0,0 +1,40 @@
package sport
import (
"reflect"
"testing"
)
func Test_splitByCommaWithTime(t *testing.T) {
type args struct {
input string
}
tests := []struct {
name string
args args
want []string
}{
{"one string", args{"one"}, []string{"one"}},
{"two strings", args{"one,two"}, []string{"one,two"}},
{"three strings", args{"one,two,three"}, []string{"one,two,three"}},
// e.g. "Mo 18:00-20:00, Di 18:00-20:00" -> ["Mo 18:00-20:00", "Di 18:00-20:00"]
// e.g. "Mo 18:00-20:00, Di 18:00-20:00, Mi 18:00-20:00" -> ["Mo 18:00-20:00", "Di 18:00-20:00", "Mi 18:00-20:00"]
// e.g. "Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00" -> ["Mo, Mi, Fr 18:00-20:00", "Sa 20:00-21:00"]
// e.g. "Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00" -> ["Mo, Mi, Fr 18:00-20:00", "Sa 20:00-21:00", "So 20:00-21:00"]
// e.g. "Mo+Mi+Fr 18:00-20:00, Sa 20:00-21:00" -> ["Mo+Mi+Fr 18:00-20:00", "Sa 20:00-21:00"]
// e.g. "Mo+Mi 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00" -> ["Mo+Mi 18:00-20:00", "Sa 20:00-21:00", "So 20:00-21:00"]
{"Mo 18:00-20:00, Di 18:00-20:00", args{"Mo 18:00-20:00, Di 18:00-20:00"}, []string{"Mo 18:00-20:00", "Di 18:00-20:00"}},
{"Mo 18:00-20:00, Di 18:00-20:00, Mi 18:00-20:00", args{"Mo 18:00-20:00, Di 18:00-20:00, Mi 18:00-20:00"}, []string{"Mo 18:00-20:00", "Di 18:00-20:00", "Mi 18:00-20:00"}},
{"Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00", args{"Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00"}, []string{"Mo, Mi, Fr 18:00-20:00", "Sa 20:00-21:00"}},
{"Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00", args{"Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00"}, []string{"Mo, Mi, Fr 18:00-20:00", "Sa 20:00-21:00", "So 20:00-21:00"}},
{"Mo+Mi+Fr 18:00-20:00, Sa 20:00-21:00", args{"Mo+Mi+Fr 18:00-20:00, Sa 20:00-21:00"}, []string{"Mo+Mi+Fr 18:00-20:00", "Sa 20:00-21:00"}},
{"Mo+Mi 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00", args{"Mo+Mi 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00"}, []string{"Mo+Mi 18:00-20:00", "Sa 20:00-21:00", "So 20:00-21:00"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := splitByCommaWithTime(tt.args.input); !reflect.DeepEqual(got, tt.want) {
t.Errorf("splitByCommaWithTime() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,14 @@
package functions
import "time"
// GetCurrentSemesterString returns the current semester as string
// if current month is between 10 and 03 -> winter semester "ws"
func GetCurrentSemesterString() string {
if time.Now().Month() >= 10 || time.Now().Month() <= 3 {
return "ws"
} else {
return "ss"
}
}

View File

@@ -1,10 +1,12 @@
package room
import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"htwkalender/model"
"htwkalender/service/db"
"net/http"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
)
func GetRooms(c echo.Context, app *pocketbase.PocketBase) error {
@@ -14,10 +16,19 @@ func GetRooms(c echo.Context, app *pocketbase.PocketBase) error {
func GetRoomScheduleForDay(c echo.Context, app *pocketbase.PocketBase, room string, date string) error {
events := db.GetRoomScheduleForDay(app, room, date)
return c.JSON(http.StatusOK, events)
return c.JSON(http.StatusOK, anonymizeRooms(events))
}
func GetRoomSchedule(c echo.Context, app *pocketbase.PocketBase, room string, from string, to string) error {
events := db.GetRoomSchedule(app, room, from, to)
return c.JSON(http.StatusOK, events)
return c.JSON(http.StatusOK, anonymizeRooms(events))
}
// Transform the events to anonymized events throwing away all unnecessary information
func anonymizeRooms(events []model.Event) []model.AnonymizedEventDTO {
var anonymizedEvents = []model.AnonymizedEventDTO{}
for _, event := range events {
anonymizedEvents = append(anonymizedEvents, event.AnonymizeEvent())
}
return anonymizedEvents
}

View File

@@ -0,0 +1,125 @@
package room
import (
"htwkalender/model"
"reflect"
"testing"
"github.com/pocketbase/pocketbase/tools/types"
)
func Test_anonymizeRooms(t *testing.T) {
type args struct {
events []model.Event
}
tests := []struct {
name string
args args
want []model.AnonymizedEventDTO
}{
{
name: "anonymize single event",
args: args{
events: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Secret",
Rooms: "Room",
Notes: "Secret",
BookedAt: "Secret",
Course: "42INM-3",
Semester: "ws",
Compulsory: "p",
},
},
},
want: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Rooms: "Room",
Free: false,
},
},
},
{
name: "anonymize empty list",
args: args{
events: []model.Event{},
},
want: []model.AnonymizedEventDTO{},
},
{
name: "anonymize multiple events",
args: args{
events: []model.Event{
{
UUID: "testUUID1",
Day: "Montag",
Week: "51",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Incognito",
EventType: "V",
Prof: "Prof. Dr. Incognito",
Rooms: "Room",
Notes: "Incognito",
BookedAt: "Incognito",
Course: "69INM-2",
Semester: "sose",
Compulsory: "p",
},
{
UUID: "testUUID2",
Day: "Dienstag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Private",
EventType: "S",
Prof: "Prof.In. Dr.-Ing. Private",
Rooms: "Room",
Notes: "Private",
BookedAt: "Private",
Course: "42MIM-3",
Semester: "ws",
Compulsory: "w",
},
},
},
want: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "51",
Start: types.DateTime{},
End: types.DateTime{},
Rooms: "Room",
Free: false,
},
{
Day: "Dienstag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Rooms: "Room",
Free: false,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := anonymizeRooms(tt.args.events); !reflect.DeepEqual(got, tt.want) {
t.Errorf("anonymizeRooms() = %v, want %v", got, tt.want)
}
})
}
}