Merge pull request #111 from HTWK-Leipzig/82-add-university-sports-course

82 add university sports course
This commit is contained in:
masterElmar
2023-12-13 10:39:37 +01:00
committed by GitHub
11 changed files with 672 additions and 2 deletions

View File

@@ -59,6 +59,32 @@ Stay for this time on the page and wait for the response.
http://127.0.0.1/api/fetch/events http://127.0.0.1/api/fetch/events
After you fetched the events you can optional fetch sport events.
This will scrape the sport events from the HTWK website and store them in the database.
This will take a few seconds (2s-10s).
http://127.0.0.1/api/fetch/sports
### View/Filter/Search in Admin UI ### View/Filter/Search in Admin UI
If you want some easy first api endpoints and data views, you can use the Admin UI. If you want some easy first api endpoints and data views, you can use the Admin UI.
### Schedules
In our project we used schedules to fetch data from HTWK and store it in the database.
So they get periodically executed and keep the database up to date.
You can find the schedules in the following directory:
```
service/addSchedule.go
```
Currently, there are 3 schedules:
- FetchGroupsSchedule
- FetchEventsSchedule
- FetchSportsSchedule

View File

@@ -3,6 +3,7 @@ module htwkalender
go 1.21 go 1.21
require ( require (
github.com/PuerkitoBio/goquery v1.8.1
github.com/google/uuid v1.3.1 github.com/google/uuid v1.3.1
github.com/jordic/goics v0.0.0-20210404174824-5a0337b716a0 github.com/jordic/goics v0.0.0-20210404174824-5a0337b716a0
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
@@ -13,6 +14,7 @@ require (
require ( require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect 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/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go v1.46.3 // indirect github.com/aws/aws-sdk-go v1.46.3 // indirect
github.com/aws/aws-sdk-go-v2 v1.21.2 // 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/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 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 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-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 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 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-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-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-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.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.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 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 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= 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-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-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-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-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-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-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.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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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-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.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.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 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 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.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.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.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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 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.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 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=

View File

@@ -0,0 +1,55 @@
package model
import "time"
// MODELS
// SportEntry represents the overall event details.
type SportEntry struct {
Title string
Details EventDetails
AdditionalNote 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 - url: http://localhost:8090
description: Local server description: Local server
paths: paths:
/api/fetchPlans: /api/fetch/events:
get: get:
summary: Fetch Seminar Plans summary: Fetch Seminar Plans
security: security:
@@ -16,7 +16,7 @@ paths:
responses: responses:
'200': '200':
description: Successful response description: Successful response
/api/fetchGroups: /api/fetch/groups:
get: get:
summary: Fetch Seminar Groups summary: Fetch Seminar Groups
security: security:
@@ -24,6 +24,14 @@ paths:
responses: responses:
'200': '200':
description: Successful response description: Successful response
/api/fetch/sports:
get:
summary: Fetch Sport Events from HTWK Leipzig
security:
- ApiKeyAuth: [ ]
responses:
'200':
description: Successful response
/api/modules: /api/modules:
delete: delete:
summary: Delete Module summary: Delete Module

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"htwkalender/service/db" "htwkalender/service/db"
"htwkalender/service/events" "htwkalender/service/events"
"htwkalender/service/fetch/sport"
v1 "htwkalender/service/fetch/v1" v1 "htwkalender/service/fetch/v1"
v2 "htwkalender/service/fetch/v2" v2 "htwkalender/service/fetch/v2"
"htwkalender/service/ical" "htwkalender/service/ical"
@@ -54,6 +55,26 @@ func AddRoutes(app *pocketbase.PocketBase) {
return nil 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 { app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{ _, err := e.Router.AddRoute(echo.Route{
Method: http.MethodDelete, Method: http.MethodDelete,

View File

@@ -6,6 +6,7 @@ import (
"github.com/pocketbase/pocketbase/tools/cron" "github.com/pocketbase/pocketbase/tools/cron"
"htwkalender/service/course" "htwkalender/service/course"
"htwkalender/service/feed" "htwkalender/service/feed"
"htwkalender/service/fetch/sport"
"htwkalender/service/functions/time" "htwkalender/service/functions/time"
) )
@@ -26,6 +27,12 @@ func AddSchedules(app *pocketbase.PocketBase) {
// clean feeds older than 6 months // clean feeds older than 6 months
feed.ClearFeeds(app.Dao(), 6, time.RealClock{}) 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)
})
scheduler.Start() scheduler.Start()
return nil return nil
}) })

View File

@@ -2,6 +2,7 @@ package db
import ( import (
"htwkalender/model" "htwkalender/model"
"time"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
@@ -251,3 +252,15 @@ func FindAllEventsByModule(app *pocketbase.PocketBase, module model.Module) (mod
return events, nil 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,473 @@
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 i, 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+strconv.FormatInt(int64(i), 10))).String(),
Day: toGermanWeekdayString(entry.Details.DateRange.Start.Weekday()),
Week: strconv.Itoa(23),
Start: start,
End: end,
Name: entry.Title,
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
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])
}
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.Parse("02.01.2006", strings.TrimSpace(dates[0]))
endDate, _ := time.Parse("02.01.2006", strings.TrimSpace(dates[1]))
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
}

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"
}
}