diff --git a/.idea/swagger-settings.xml b/.idea/swagger-settings.xml new file mode 100644 index 0000000..01d844c --- /dev/null +++ b/.idea/swagger-settings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/backend/.idea/.gitignore b/backend/.idea/.gitignore index 13566b8..9ed1b03 100644 --- a/backend/.idea/.gitignore +++ b/backend/.idea/.gitignore @@ -6,3 +6,4 @@ # Datasource local storage ignored files /dataSources/ /dataSources.local.xml +/jsLibraryMappings.xml diff --git a/backend/main.go b/backend/main.go index 32af53e..bc579dd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -24,6 +24,8 @@ func main() { service.AddRoutes(app) + service.AddSchedules(app) + if err := app.Start(); err != nil { log.Fatal(err) } diff --git a/backend/migrations/1697532023_collections_snapshot.go b/backend/migrations/1697532023_collections_snapshot.go new file mode 100644 index 0000000..7edc1f3 --- /dev/null +++ b/backend/migrations/1697532023_collections_snapshot.go @@ -0,0 +1,384 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models" +) + +func init() { + m.Register(func(db dbx.Builder) error { + jsonData := `[ + { + "id": "cfq9mqlmd97v8z5", + "created": "2023-09-19 17:31:15.957Z", + "updated": "2023-09-19 17:31:15.957Z", + "name": "groups", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "85msl21p", + "name": "university", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "2sii4dtp", + "name": "shortcut", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "uiwgo28f", + "name": "groupId", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "y0l1lrzs", + "name": "course", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "kr62mhbz", + "name": "faculty", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "ya6znpez", + "name": "facultyId", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + } + ], + "indexes": [ + "CREATE UNIQUE INDEX ` + "`" + `idx_rcaN2Oq` + "`" + ` ON ` + "`" + `groups` + "`" + ` (` + "`" + `course` + "`" + `)" + ], + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": {} + }, + { + "id": "d65h4wh7zk13gxp", + "created": "2023-09-19 17:31:15.957Z", + "updated": "2023-10-17 08:37:17.943Z", + "name": "feeds", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "cowxjfmc", + "name": "modules", + "type": "json", + "required": true, + "unique": false, + "options": {} + } + ], + "indexes": [], + "listRule": null, + "viewRule": "", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": {} + }, + { + "id": "7her4515qsmrxe8", + "created": "2023-09-19 17:31:15.958Z", + "updated": "2023-09-19 17:31:15.958Z", + "name": "events", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "m8ne8e3m", + "name": "Day", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "xnsxqp7j", + "name": "Week", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "7vsr9h6p", + "name": "Start", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "wwpokofe", + "name": "End", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "aeuskrjo", + "name": "Name", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "klrzqyw0", + "name": "EventType", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "5zltexoy", + "name": "Prof", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "gy3nvfmx", + "name": "Rooms", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "hn7b8dfy", + "name": "Notes", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "axskpwm8", + "name": "BookedAt", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "vyyefxp7", + "name": "course", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "vlbpm9fz", + "name": "semester", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + } + ], + "indexes": [ + "CREATE UNIQUE INDEX ` + "`" + `idx_orp1NWL` + "`" + ` ON ` + "`" + `events` + "`" + ` (\n ` + "`" + `Day` + "`" + `,\n ` + "`" + `Week` + "`" + `,\n ` + "`" + `Start` + "`" + `,\n ` + "`" + `End` + "`" + `,\n ` + "`" + `Name` + "`" + `,\n ` + "`" + `course` + "`" + `,\n ` + "`" + `Prof` + "`" + `,\n ` + "`" + `Rooms` + "`" + `,\n ` + "`" + `EventType` + "`" + `\n)" + ], + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": {} + }, + { + "id": "_pb_users_auth_", + "created": "2023-10-08 16:32:34.131Z", + "updated": "2023-10-08 16:32:34.315Z", + "name": "users", + "type": "auth", + "system": false, + "schema": [ + { + "system": false, + "id": "users_name", + "name": "name", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "users_avatar", + "name": "avatar", + "type": "file", + "required": false, + "unique": false, + "options": { + "maxSelect": 1, + "maxSize": 5242880, + "mimeTypes": [ + "image/jpeg", + "image/png", + "image/svg+xml", + "image/gif", + "image/webp" + ], + "thumbs": null, + "protected": false + } + } + ], + "indexes": [], + "listRule": "id = @request.auth.id", + "viewRule": "id = @request.auth.id", + "createRule": "", + "updateRule": "id = @request.auth.id", + "deleteRule": "id = @request.auth.id", + "options": { + "allowEmailAuth": true, + "allowOAuth2Auth": true, + "allowUsernameAuth": true, + "exceptEmailDomains": null, + "manageRule": null, + "minPasswordLength": 8, + "onlyEmailDomains": null, + "requireEmail": false + } + } + ]` + + collections := []*models.Collection{} + if err := json.Unmarshal([]byte(jsonData), &collections); err != nil { + return err + } + + return daos.New(db).ImportCollections(collections, true, nil) + }, func(db dbx.Builder) error { + return nil + }) +} diff --git a/backend/migrations/1697570688_collections_snapshot.go b/backend/migrations/1697570688_collections_snapshot.go new file mode 100644 index 0000000..5cfab17 --- /dev/null +++ b/backend/migrations/1697570688_collections_snapshot.go @@ -0,0 +1,384 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models" +) + +func init() { + m.Register(func(db dbx.Builder) error { + jsonData := `[ + { + "id": "cfq9mqlmd97v8z5", + "created": "2023-09-19 17:31:15.957Z", + "updated": "2023-10-17 10:50:08.270Z", + "name": "groups", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "85msl21p", + "name": "university", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "2sii4dtp", + "name": "shortcut", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "uiwgo28f", + "name": "groupId", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "y0l1lrzs", + "name": "course", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "kr62mhbz", + "name": "faculty", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "ya6znpez", + "name": "facultyId", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + } + ], + "indexes": [ + "CREATE UNIQUE INDEX ` + "`" + `idx_rcaN2Oq` + "`" + ` ON ` + "`" + `groups` + "`" + ` (` + "`" + `course` + "`" + `)" + ], + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": {} + }, + { + "id": "d65h4wh7zk13gxp", + "created": "2023-09-19 17:31:15.957Z", + "updated": "2023-10-17 18:47:10.221Z", + "name": "feeds", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "cowxjfmc", + "name": "modules", + "type": "json", + "required": true, + "unique": false, + "options": {} + } + ], + "indexes": [], + "listRule": null, + "viewRule": "", + "createRule": null, + "updateRule": "", + "deleteRule": null, + "options": {} + }, + { + "id": "7her4515qsmrxe8", + "created": "2023-09-19 17:31:15.958Z", + "updated": "2023-10-17 10:50:08.270Z", + "name": "events", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "m8ne8e3m", + "name": "Day", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "xnsxqp7j", + "name": "Week", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "7vsr9h6p", + "name": "Start", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "wwpokofe", + "name": "End", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "aeuskrjo", + "name": "Name", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "klrzqyw0", + "name": "EventType", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "5zltexoy", + "name": "Prof", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "gy3nvfmx", + "name": "Rooms", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "hn7b8dfy", + "name": "Notes", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "axskpwm8", + "name": "BookedAt", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "vyyefxp7", + "name": "course", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "vlbpm9fz", + "name": "semester", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + } + ], + "indexes": [ + "CREATE UNIQUE INDEX ` + "`" + `idx_orp1NWL` + "`" + ` ON ` + "`" + `events` + "`" + ` (\n ` + "`" + `Day` + "`" + `,\n ` + "`" + `Week` + "`" + `,\n ` + "`" + `Start` + "`" + `,\n ` + "`" + `End` + "`" + `,\n ` + "`" + `Name` + "`" + `,\n ` + "`" + `course` + "`" + `,\n ` + "`" + `Prof` + "`" + `,\n ` + "`" + `Rooms` + "`" + `,\n ` + "`" + `EventType` + "`" + `\n)" + ], + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": {} + }, + { + "id": "_pb_users_auth_", + "created": "2023-09-22 09:31:11.498Z", + "updated": "2023-10-17 10:50:08.270Z", + "name": "users", + "type": "auth", + "system": false, + "schema": [ + { + "system": false, + "id": "users_name", + "name": "name", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "users_avatar", + "name": "avatar", + "type": "file", + "required": false, + "unique": false, + "options": { + "maxSelect": 1, + "maxSize": 5242880, + "mimeTypes": [ + "image/jpeg", + "image/png", + "image/svg+xml", + "image/gif", + "image/webp" + ], + "thumbs": null, + "protected": false + } + } + ], + "indexes": [], + "listRule": "id = @request.auth.id", + "viewRule": "id = @request.auth.id", + "createRule": "", + "updateRule": "id = @request.auth.id", + "deleteRule": "id = @request.auth.id", + "options": { + "allowEmailAuth": true, + "allowOAuth2Auth": true, + "allowUsernameAuth": true, + "exceptEmailDomains": null, + "manageRule": null, + "minPasswordLength": 8, + "onlyEmailDomains": null, + "requireEmail": false + } + } + ]` + + collections := []*models.Collection{} + if err := json.Unmarshal([]byte(jsonData), &collections); err != nil { + return err + } + + return daos.New(db).ImportCollections(collections, true, nil) + }, func(db dbx.Builder) error { + return nil + }) +} diff --git a/backend/model/eventModel.go b/backend/model/eventModel.go index f83e22d..daaf229 100644 --- a/backend/model/eventModel.go +++ b/backend/model/eventModel.go @@ -1,3 +1,18 @@ package model type Events []*Event + +type Event struct { + Day string `db:"Day" json:"day"` + Week string `db:"Week" json:"week"` + Start string `db:"Start" json:"start"` + End string `db:"End" json:"end"` + Name string `db:"Name" json:"name"` + EventType string `db:"EventType" json:"eventType"` + 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"` +} diff --git a/backend/model/icalModel.go b/backend/model/icalModel.go index 4cfa1b1..192a528 100644 --- a/backend/model/icalModel.go +++ b/backend/model/icalModel.go @@ -23,7 +23,7 @@ type Entry struct { type Entries []*Entry type FeedCollection struct { - Name string `db:"Name" json:"Name"` - Course string `db:"course" json:"Course"` - UserDefinedName string `db:"userDefinedName" json:"UserDefinedName"` + Name string `db:"Name" json:"name"` + Course string `db:"course" json:"course"` + UserDefinedName string `db:"userDefinedName" json:"userDefinedName"` } diff --git a/backend/model/moduleModel.go b/backend/model/moduleModel.go new file mode 100644 index 0000000..ab0d17f --- /dev/null +++ b/backend/model/moduleModel.go @@ -0,0 +1,9 @@ +package model + +type Module struct { + Name string `json:"name"` + Prof string `json:"prof"` + Course string `json:"course"` + Semester string `json:"semester"` + Events Events `json:"events"` +} diff --git a/backend/model/seminarGroup.go b/backend/model/seminarGroup.go index d9aedbc..d3d63c9 100644 --- a/backend/model/seminarGroup.go +++ b/backend/model/seminarGroup.go @@ -9,18 +9,3 @@ type SeminarGroup struct { FacultyId string Events []Event } - -type Event struct { - Day string `db:"course"` - Week string `db:"Week"` - Start string `db:"Start"` - End string `db:"End"` - Name string `db:"Name"` - EventType string `db:"EventType"` - Prof string `db:"Prof"` - Rooms string `db:"Rooms"` - Notes string `db:"Notes"` - BookedAt string `db:"BookedAt"` - Course string `db:"course"` - Semester string `db:"semester"` -} diff --git a/backend/openapi.yml b/backend/openapi.yml index 1220dcd..d1aee05 100644 --- a/backend/openapi.yml +++ b/backend/openapi.yml @@ -1,7 +1,12 @@ openapi: 3.0.0 info: - title: Your API - version: 1.0.0 + title: HTWKalendar API + version: 1.0.1 +servers: + - url: https://cal.ekresse.de + description: Production server + - url: http://localhost:8090 + description: Local server paths: /api/fetchPlans: get: @@ -29,7 +34,20 @@ paths: description: Successful response /api/feed: get: - summary: Get iCal Feed + summary: Get iCal Feed for calendar + responses: + '200': + description: Successful response + /api/collections/feeds/records/{id}: + get: + summary: Get Modules selected for iCal feed + parameters: + - name: id + in: path + description: calendar token + required: true + schema: + type: string responses: '200': description: Successful response diff --git a/backend/service/addRoute.go b/backend/service/addRoute.go index e94e1cc..18dda6f 100644 --- a/backend/service/addRoute.go +++ b/backend/service/addRoute.go @@ -9,7 +9,9 @@ import ( "htwkalender/service/fetch" "htwkalender/service/ical" "htwkalender/service/room" + "io" "net/http" + "net/url" "os" ) @@ -71,6 +73,7 @@ func AddRoutes(app *pocketbase.PocketBase) { return nil }) + // API Endpoint to get all events for a specific room on a specific day app.OnBeforeServe().Add(func(e *core.ServeEvent) error { _, err := e.Router.AddRoute(echo.Route{ Method: http.MethodGet, @@ -90,6 +93,7 @@ func AddRoutes(app *pocketbase.PocketBase) { return nil }) + // API Endpoint to create a new iCal feed app.OnBeforeServe().Add(func(e *core.ServeEvent) error { _, err := e.Router.AddRoute(echo.Route{ Method: http.MethodGet, @@ -114,7 +118,13 @@ func AddRoutes(app *pocketbase.PocketBase) { Method: http.MethodPost, Path: "/api/createFeed", Handler: func(c echo.Context) error { - return ical.CreateIndividualFeed(c, app) + requestBody, _ := io.ReadAll(c.Request().Body) + result, err := ical.CreateIndividualFeed(requestBody, app) + if err != nil { + return c.JSON(http.StatusInternalServerError, err) + } + return c.JSON(http.StatusOK, result) + }, Middlewares: []echo.MiddlewareFunc{ apis.ActivityLogger(app), @@ -151,7 +161,13 @@ func AddRoutes(app *pocketbase.PocketBase) { Handler: func(c echo.Context) error { course := c.QueryParam("course") semester := c.QueryParam("semester") - return events.GetModulesForCourseDistinct(app, c, course, semester) + modules, err := events.GetModulesForCourseDistinct(app, course, semester) + + if err != nil { + return c.JSON(400, err) + } else { + return c.JSON(200, modules) + } }, Middlewares: []echo.MiddlewareFunc{ apis.ActivityLogger(app), @@ -180,12 +196,62 @@ 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/module", + Handler: func(c echo.Context) error { + name := c.Request().Header.Get("Name") + name, err := url.QueryUnescape(name) + module, err := events.GetModuleByName(app, name) + + if err != nil { + return c.JSON(400, err) + } else { + return c.JSON(200, module) + } + }, + Middlewares: []echo.MiddlewareFunc{ + apis.ActivityLogger(app), + }, + }) + if err != nil { + return err + } + return nil + }) + app.OnBeforeServe().Add(func(e *core.ServeEvent) error { _, err := e.Router.AddRoute(echo.Route{ Method: http.MethodGet, Path: "/api/courses", Handler: func(c echo.Context) error { - return events.GetAllCourses(app, c) + courses := events.GetAllCourses(app) + return c.JSON(200, courses) + }, + Middlewares: []echo.MiddlewareFunc{ + apis.ActivityLogger(app), + }, + }) + if err != nil { + return err + } + return nil + }) + + app.OnBeforeServe().Add(func(e *core.ServeEvent) error { + _, err := e.Router.AddRoute(echo.Route{ + Method: http.MethodDelete, + Path: "/api/events", + Handler: func(c echo.Context) error { + course := c.QueryParam("course") + semester := c.QueryParam("semester") + err := events.DeleteAllEventsByCourseAndSemester(app, course, semester) + if err != nil { + return c.JSON(400, err) + } else { + return c.JSON(200, "Events deleted") + } }, Middlewares: []echo.MiddlewareFunc{ apis.ActivityLogger(app), diff --git a/backend/service/addSchedule.go b/backend/service/addSchedule.go new file mode 100644 index 0000000..4cf41bc --- /dev/null +++ b/backend/service/addSchedule.go @@ -0,0 +1,40 @@ +package service + +import ( + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/cron" + "htwkalender/service/events" + "log" +) + +func AddSchedules(app *pocketbase.PocketBase) { + + app.OnBeforeServe().Add(func(e *core.ServeEvent) error { + scheduler := cron.New() + + // Every hour update all courses (5 segments - minute, hour, day, month, weekday) "0 * * * *" + // Every three hours update all courses (5 segments - minute, hour, day, month, weekday) "0 */3 * * *" + // Every 10 minutes update all courses (5 segments - minute, hour, day, month, weekday) "*/10 * * * *" + scheduler.MustAdd("updateCourse", "0 */3 * * *", func() { + + courses := events.GetAllCourses(app) + + for _, course := range courses { + err := events.UpdateModulesForCourse(app, course) + if err != nil { + log.Println("Update Course: " + course + " failed") + log.Println(err) + } else { + log.Println("Update Course: " + course + " successful") + } + } + + }) + + scheduler.Start() + + return nil + }) + +} diff --git a/backend/service/db/dbEvents.go b/backend/service/db/dbEvents.go index 20dc862..f763260 100644 --- a/backend/service/db/dbEvents.go +++ b/backend/service/db/dbEvents.go @@ -77,74 +77,113 @@ func findEventByDayWeekStartEndNameCourse(event model.Event, course string, app return &event, err } +func buildIcalQueryForModules(modules []model.FeedCollection) dbx.Expression { + + // build where conditions for each module + + //first check if modules is empty + if len(modules) == 0 { + return dbx.HashExp{} + } + + //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}, + ) + } + + //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}, + ) + wheres = append(wheres, where) + } + + // Use dbx.And or dbx.Or to combine the where conditions as needed + where := dbx.Or(wheres...) + + return where + +} + +// GetPlanForModules returns all events for the given modules with the given course +// used for the ical feed func GetPlanForModules(app *pocketbase.PocketBase, modules []model.FeedCollection) model.Events { - // build query functions with name equals elements in modules for dbx query + var events model.Events - var queryString string - for i, module := range modules { - if i == 0 { - queryString = "Name = '" + module.Name + "' AND course = '" + module.Course + "'" + // iterate over modules in 100 batch sizes + for i := 0; i < len(modules); i += 100 { + var moduleBatch []model.FeedCollection + + if i+100 > len(modules) { + moduleBatch = modules[i:] } else { - queryString = queryString + " OR Name = '" + module.Name + "' AND course = '" + module.Course + "'" + moduleBatch = modules[i : i+100] + } + + var selectedModulesQuery = buildIcalQueryForModules(moduleBatch) + // get all events from event records in the events collection + err := app.Dao().DB().Select("*").From("events").Where(selectedModulesQuery).All(&events) + if err != nil { + print("Error while getting events from database: ", err) + return nil } } - var events model.Events - // get all events from event records in the events collection - err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp(queryString)).All(&events) - if err != nil { - print("Error while getting events from database: ", err) - return nil - } return events } -func GetAllModulesForCourse(app *pocketbase.PocketBase, course string, semester string) ([]string, error) { - var events []struct { - Name string `db:"Name" json:"Name"` - } - - var eventArray []string +func GetAllModulesForCourse(app *pocketbase.PocketBase, course string, semester string) (model.Events, error) { + var events model.Events // get all events from event records in the events collection - err := app.Dao().DB().Select("Name").From("events").Where(dbx.NewExp("course = {:course} AND semester = {:semester}", dbx.Params{"course": course, "semester": semester})).Distinct(true).All(&events) + err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("course = {:course} AND semester = {:semester}", dbx.Params{"course": course, "semester": semester})).GroupBy("Name").Distinct(true).All(&events) if err != nil { print("Error while getting events from database: ", err) - return eventArray, err + return nil, err } - for _, event := range events { - eventArray = append(eventArray, event.Name) - } - return eventArray, nil + return events, nil } -func GetAllModulesDistinct(app *pocketbase.PocketBase) ([]struct { - Name string - Course string -}, error) { - var events []struct { - Name string `db:"Name" json:"Name"` - Course string `db:"course" json:"course"` - } +func GetAllModulesDistinctByNameAndCourse(app *pocketbase.PocketBase) (model.Events, error) { + var events model.Events - var eventArray []struct { - Name string - Course string - } - - err := app.Dao().DB().Select("Name", "course").From("events").Distinct(true).All(&events) + err := app.Dao().DB().Select("*").From("events").GroupBy("Name", "course").Distinct(true).All(&events) if err != nil { print("Error while getting events from database: ", err) - return eventArray, err + return nil, err } - for _, event := range events { - eventArray = append(eventArray, struct { - Name string - Course string - }{event.Name, event.Course}) - } - return eventArray, nil + return events, nil +} + +func DeleteAllEventsForCourse(app *pocketbase.PocketBase, course string, semester string) error { + _, err := app.Dao().DB().Delete("events", dbx.NewExp("course = {:course} AND semester = {:semester}", dbx.Params{"course": course, "semester": semester})).Execute() + + if err != nil { + print("Error while deleting events from database: ", err) + return err + } + + return nil +} + +func FindAllEventsByModule(app *pocketbase.PocketBase, moduleName string) (model.Events, error) { + var events model.Events + + err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Name = {:moduleName}", dbx.Params{"moduleName": moduleName})).All(&events) + if err != nil { + print("Error while getting events from database: ", err) + return nil, err + } + + return events, nil } diff --git a/backend/service/db/dbEvents_test.go b/backend/service/db/dbEvents_test.go new file mode 100644 index 0000000..8d46131 --- /dev/null +++ b/backend/service/db/dbEvents_test.go @@ -0,0 +1,42 @@ +package db + +import ( + "github.com/pocketbase/dbx" + "htwkalender/model" + "reflect" + "testing" +) + +func Test_buildIcalQueryForModules(t *testing.T) { + type args struct { + modules []model.FeedCollection + } + tests := []struct { + name string + args args + want dbx.Expression + }{ + { + name: "empty modules", + args: args{modules: []model.FeedCollection{}}, + want: dbx.HashExp{}, + }, + { + name: "one module", + args: args{modules: []model.FeedCollection{{Name: "test", Course: "test"}}}, + want: dbx.And(dbx.HashExp{"Name": "test"}, dbx.HashExp{"course": "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"})), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildIcalQueryForModules(tt.args.modules); !reflect.DeepEqual(got, tt.want) { + t.Errorf("buildIcalQueryForModules() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/backend/service/events/courseService.go b/backend/service/events/courseService.go index daf9f09..8388925 100644 --- a/backend/service/events/courseService.go +++ b/backend/service/events/courseService.go @@ -1,12 +1,10 @@ package events import ( - "github.com/labstack/echo/v5" "github.com/pocketbase/pocketbase" "htwkalender/service/db" ) -func GetAllCourses(app *pocketbase.PocketBase, c echo.Context) error { - courses := db.GetAllCourses(app) - return c.JSON(200, courses) +func GetAllCourses(app *pocketbase.PocketBase) []string { + return db.GetAllCourses(app) } diff --git a/backend/service/events/eventService.go b/backend/service/events/eventService.go index b5684f0..450ae43 100644 --- a/backend/service/events/eventService.go +++ b/backend/service/events/eventService.go @@ -3,36 +3,24 @@ package events import ( "github.com/labstack/echo/v5" "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/apis" + "htwkalender/model" "htwkalender/service/db" + "htwkalender/service/fetch" "htwkalender/service/functions" ) -func GetModulesForCourseDistinct(app *pocketbase.PocketBase, c echo.Context, course string, semester string) error { +func GetModulesForCourseDistinct(app *pocketbase.PocketBase, course string, semester string) (model.Events, error) { modules, err := db.GetAllModulesForCourse(app, course, semester) - replaceEmptyEntryInStringArray(modules, "Sonderveranstaltungen") - - if err != nil { - return c.JSON(400, err) - } else { - return c.JSON(200, modules) - } + replaceEmptyEntry(modules, "Sonderveranstaltungen") + return modules, err } -func replaceEmptyEntryInStringArray(modules []string, replacement string) { - //replace empty functions with "Sonderveranstaltungen" - for i, module := range modules { - if functions.OnlyWhitespace(module) { - modules[i] = replacement - } - } -} +// 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) { -func replaceEmptyEntry(modules []struct { - Name string - Course string -}, replacement string) { - //replace empty functions with "Sonderveranstaltungen" for i, module := range modules { if functions.OnlyWhitespace(module.Name) { modules[i].Name = replacement @@ -40,8 +28,10 @@ func replaceEmptyEntry(modules []struct { } } +// GetAllModulesDistinct returns all modules distinct by name and course from the database +// That means you get all modules with duplicates if they have different courses func GetAllModulesDistinct(app *pocketbase.PocketBase, c echo.Context) error { - modules, err := db.GetAllModulesDistinct(app) + modules, err := db.GetAllModulesDistinctByNameAndCourse(app) replaceEmptyEntry(modules, "Sonderveranstaltungen") @@ -51,3 +41,134 @@ func GetAllModulesDistinct(app *pocketbase.PocketBase, c echo.Context) error { return c.JSON(200, modules) } } + +// GetModuleByName returns a module by its name +// If the module does not exist, an error is returned +// If the module exists, the module is returned +// Module is a struct that exists in database as events +func GetModuleByName(app *pocketbase.PocketBase, name string) (model.Module, error) { + events, err := db.FindAllEventsByModule(app, name) + + if err != nil || len(events) == 0 { + return model.Module{}, err + } else { + return model.Module{ + Name: name, + Events: events, + Prof: events[0].Prof, + Course: events[0].Course, + Semester: events[0].Semester, + }, nil + } +} + +// DeleteAllEventsByCourseAndSemester deletes all events for a course and a semester +// If the deletion was successful, nil is returned +// If the deletion was not successful, an error is returned +func DeleteAllEventsByCourseAndSemester(app *pocketbase.PocketBase, course string, semester string) error { + err := db.DeleteAllEventsForCourse(app, course, semester) + if err != nil { + return err + } else { + return nil + } +} + +// UpdateModulesForCourse updates all modules for a course +// Does Updates for ws and ss semester sequentially +// Update runs through the following steps: +// 1. Delete all events for the course and the semester +// 2. Fetch all events for the course and the semester +// 3. Save all events for the course and the semester +// If the update was successful, nil is returned +// If the update was not successful, an error is returned +func UpdateModulesForCourse(app *pocketbase.PocketBase, course string) error { + + //new string array with one element (course) + var courses []string + courses = append(courses, course) + + seminarGroups := fetch.GetSeminarGroupsEventsFromHTML(courses) + + collection, dbError := db.FindCollection(app, "events") + if dbError != nil { + return apis.NewNotFoundError("Collection not found", dbError) + } + + seminarGroups = fetch.ClearEmptySeminarGroups(seminarGroups) + + seminarGroups = fetch.ReplaceEmptyEventNames(seminarGroups) + + //check if events in the seminarGroups Events are already in the database + //if yes, keep the database as it is + //if no, delete all events for the course and the semester and save the new events + //if there are no events in the database, save the new events + + //get all events for the course and the semester + events, err := db.GetAllModulesForCourse(app, course, "ws") + if err != nil { + return apis.NewNotFoundError("Events for winter semester could not be found", err) + } + + // append all events for the course and the semester to the events array for ss + summerEvents, err := db.GetAllModulesForCourse(app, course, "ss") + if err != nil { + return apis.NewNotFoundError("Events for summer semester could not be found", err) + } + + events = append(events, summerEvents...) + + //if there are no events in the database, save the new events + if len(events) == 0 { + _, dbError = db.SaveEvents(seminarGroups, collection, app) + if dbError != nil { + return apis.NewNotFoundError("Events could not be saved", dbError) + } + return nil + } + + //check if events in the seminarGroups Events are already in the database + //if yes, keep the database as it is + //if no, delete all events for the course and the semester and save the new events + for _, seminarGroup := range seminarGroups { + for _, event := range seminarGroup.Events { + // if the event is not in the database, delete all events for the course and the semester and save the new events + if !ContainsEvent(events, event) { + + err = DeleteAllEventsByCourseAndSemester(app, course, "ws") + if err != nil { + return err + } + + err = DeleteAllEventsByCourseAndSemester(app, course, "ss") + if err != nil { + return err + } + + //save the new events + _, dbError = db.SaveEvents(seminarGroups, collection, app) + if dbError != nil { + return apis.NewNotFoundError("Events could not be saved", dbError) + } + return nil + } + } + } + + return nil +} + +func ContainsEvent(events model.Events, event model.Event) bool { + for _, e := range events { + if e.Name == event.Name && + e.Prof == event.Prof && + e.Rooms == event.Rooms && + e.Semester == event.Semester && + e.Start == event.Start && + e.End == event.End && + e.Course == event.Course { + return true + } + } + return false +} diff --git a/backend/service/fetch/fetchSeminarEventService.go b/backend/service/fetch/fetchSeminarEventService.go index 17a6125..24e6baa 100644 --- a/backend/service/fetch/fetchSeminarEventService.go +++ b/backend/service/fetch/fetchSeminarEventService.go @@ -28,7 +28,9 @@ func GetSeminarEvents(c echo.Context, app *pocketbase.PocketBase) error { return apis.NewNotFoundError("Collection not found", dbError) } - seminarGroups = clearEmptySeminarGroups(seminarGroups) + seminarGroups = ClearEmptySeminarGroups(seminarGroups) + + seminarGroups = ReplaceEmptyEventNames(seminarGroups) savedRecords, dbError := db.SaveEvents(seminarGroups, collection, app) @@ -39,7 +41,18 @@ func GetSeminarEvents(c echo.Context, app *pocketbase.PocketBase) error { return c.JSON(http.StatusOK, savedRecords) } -func clearEmptySeminarGroups(seminarGroups []model.SeminarGroup) []model.SeminarGroup { +func ReplaceEmptyEventNames(groups []model.SeminarGroup) []model.SeminarGroup { + for i, group := range groups { + for j, event := range group.Events { + if event.Name == "" { + groups[i].Events[j].Name = "Sonderveranstaltungen" + } + } + } + return groups +} + +func ClearEmptySeminarGroups(seminarGroups []model.SeminarGroup) []model.SeminarGroup { var newSeminarGroups []model.SeminarGroup for _, seminarGroup := range seminarGroups { if len(seminarGroup.Events) > 0 && seminarGroup.Course != "" { diff --git a/backend/service/fetch/fetchSeminarEventService_test.go b/backend/service/fetch/fetchSeminarEventService_test.go index ecbe082..3284402 100644 --- a/backend/service/fetch/fetchSeminarEventService_test.go +++ b/backend/service/fetch/fetchSeminarEventService_test.go @@ -1,6 +1,10 @@ package fetch -import "testing" +import ( + "htwkalender/model" + "reflect" + "testing" +) func Test_extractSemesterAndYear(t *testing.T) { type args struct { @@ -12,7 +16,6 @@ func Test_extractSemesterAndYear(t *testing.T) { want string want1 string }{ - // TODO: Add test cases. { name: "Test 1", args: args{ @@ -50,3 +53,68 @@ func Test_extractSemesterAndYear(t *testing.T) { }) } } + +func Test_replaceEmptyEventNames(t *testing.T) { + type args struct { + groups []model.SeminarGroup + } + tests := []struct { + name string + args args + want []model.SeminarGroup + }{ + { + name: "Test 1", + args: args{ + groups: []model.SeminarGroup{ + { + Events: []model.Event{ + { + Name: "Test", + }, + }, + }, + }, + }, + want: []model.SeminarGroup{ + { + Events: []model.Event{ + { + Name: "Test", + }, + }, + }, + }, + }, + { + name: "Test 1", + args: args{ + groups: []model.SeminarGroup{ + { + Events: []model.Event{ + { + Name: "", + }, + }, + }, + }, + }, + want: []model.SeminarGroup{ + { + Events: []model.Event{ + { + Name: "Sonderveranstaltungen", + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ReplaceEmptyEventNames(tt.args.groups); !reflect.DeepEqual(got, tt.want) { + t.Errorf("ReplaceEmptyEventNames() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/backend/service/functions/string.go b/backend/service/functions/string.go index 66bd45c..f89ac44 100644 --- a/backend/service/functions/string.go +++ b/backend/service/functions/string.go @@ -20,3 +20,10 @@ func Contains(s []string, e string) bool { } return false } + +func ReplaceEmptyString(word string, replacement string) string { + if OnlyWhitespace(word) { + return replacement + } + return word +} diff --git a/backend/service/ical/ical.go b/backend/service/ical/ical.go index 17f66f5..a0ad8d4 100644 --- a/backend/service/ical/ical.go +++ b/backend/service/ical/ical.go @@ -9,7 +9,6 @@ import ( "github.com/pocketbase/pocketbase/apis" "htwkalender/model" "htwkalender/service/db" - "io" "net/http" "time" ) @@ -63,18 +62,12 @@ func writeSuccess(message string, w http.ResponseWriter) { } } -func CreateIndividualFeed(c echo.Context, app *pocketbase.PocketBase) error { - - // read json from request body +func CreateIndividualFeed(requestBody []byte, app *pocketbase.PocketBase) (string, error) { var modules []model.FeedCollection - requestBodyBytes, err := io.ReadAll(c.Request().Body) - if err != nil { - return apis.NewApiError(400, "Could not bind request body", err) - } - err = json.Unmarshal(requestBodyBytes, &modules) + err := json.Unmarshal(requestBody, &modules) if err != nil { - return apis.NewApiError(400, "Could not bind request body", err) + return "", apis.NewNotFoundError("Could not parse request body", err) } var feed model.Feed @@ -83,13 +76,13 @@ func CreateIndividualFeed(c echo.Context, app *pocketbase.PocketBase) error { collection, dbError := db.FindCollection(app, "feeds") if dbError != nil { - return apis.NewNotFoundError("Collection not found", dbError) + return "", apis.NewNotFoundError("Collection could not be found", dbError) } record, err := db.SaveFeed(feed, collection, app) if err != nil { - return apis.NewNotFoundError("Feed could not be saved", dbError) + return "", apis.NewNotFoundError("Could not save feed", err) } - return c.JSON(http.StatusOK, record.Id) + return record.Id, nil } diff --git a/backend/service/ical/icalFileGeneration.go b/backend/service/ical/icalFileGeneration.go index 0279bf7..14d64ba 100644 --- a/backend/service/ical/icalFileGeneration.go +++ b/backend/service/ical/icalFileGeneration.go @@ -62,6 +62,9 @@ func generateDescription(event *model.Event) string { if !functions.OnlyWhitespace(event.Course) { description += "Gruppe: " + event.Course + "\n" } + if !functions.OnlyWhitespace(event.EventType) { + description += "Typ: " + event.EventType + "\n" + } return description } diff --git a/frontend/src/api/createFeed.ts b/frontend/src/api/createFeed.ts index 1667331..da95ab0 100644 --- a/frontend/src/api/createFeed.ts +++ b/frontend/src/api/createFeed.ts @@ -18,3 +18,23 @@ export async function createIndividualFeed(modules: Module[]): Promise { }); return token; } + +export async function saveIndividualFeed( + token: string, + modules: Module[], +): Promise { + await fetch("/api/collections/feeds/records/" + token, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: '{"modules":' + JSON.stringify(modules) + "}", + }) + .then((response) => { + return response.json(); + }) + .then((response) => { + token = response; + }); + return token; +} diff --git a/frontend/src/api/fetchCourse.ts b/frontend/src/api/fetchCourse.ts index 75bc575..3f23516 100644 --- a/frontend/src/api/fetchCourse.ts +++ b/frontend/src/api/fetchCourse.ts @@ -24,8 +24,17 @@ export async function fetchModulesByCourseAndSemester( return response.json(); }) .then((modulesResponse) => { - modulesResponse.forEach((module: string) => - modules.push(new Module(module, course, module)), + modulesResponse.forEach((module: Module) => + modules.push( + new Module( + module.name, + course, + module.name, + module.prof, + semester, + module.events, + ), + ), ); }); return modules; @@ -39,7 +48,16 @@ export async function fetchAllModules(): Promise { }) .then((responseModules: Module[]) => { responseModules.forEach((module: Module) => { - modules.push(new Module(module.Name, module.Course, module.Name)); + modules.push( + new Module( + module.name, + module.course, + module.name, + module.prof, + module.semester, + module.events, + ), + ); }); }); diff --git a/frontend/src/api/fetchModule.ts b/frontend/src/api/fetchModule.ts new file mode 100644 index 0000000..17a7ec9 --- /dev/null +++ b/frontend/src/api/fetchModule.ts @@ -0,0 +1,27 @@ +import { Module } from "../model/module"; + +export async function fetchModule(name: string): Promise { + const request = new Request("/api/module", { + method: "GET", + headers: { + "Content-Type": "application/json", + Name: encodeURI(name), + }, + }); + + return await fetch(request) + .then((response) => { + return response.json(); + }) + .then( + (module: Module) => + new Module( + module.name, + module.course, + module.name, + module.prof, + module.semester, + module.events, + ), + ); +} diff --git a/frontend/src/api/loadCalendar.ts b/frontend/src/api/loadCalendar.ts new file mode 100644 index 0000000..6ec03aa --- /dev/null +++ b/frontend/src/api/loadCalendar.ts @@ -0,0 +1,14 @@ +import { Module } from "../model/module"; +import { Calendar } from "../model/calendar"; + +export async function getCalender(token: string): Promise { + const request = new Request("/api/collections/feeds/records/" + token, { + method: "GET", + }); + + return await fetch(request) + .then((response) => { + return response.json(); + }) + .then((calendarResponse: Calendar) => calendarResponse.modules); +} diff --git a/frontend/src/components/AdditionalModules.vue b/frontend/src/components/AdditionalModules.vue index c9ef74b..7074611 100644 --- a/frontend/src/components/AdditionalModules.vue +++ b/frontend/src/components/AdditionalModules.vue @@ -1,11 +1,15 @@ + diff --git a/frontend/src/components/Imprint.vue b/frontend/src/components/Imprint.vue deleted file mode 100644 index 5184f44..0000000 --- a/frontend/src/components/Imprint.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - - - diff --git a/frontend/src/components/ImprintPage.vue b/frontend/src/components/ImprintPage.vue new file mode 100644 index 0000000..d8ff591 --- /dev/null +++ b/frontend/src/components/ImprintPage.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index 17af22c..c8cf9e5 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -7,6 +7,11 @@ const items = ref([ icon: "pi pi-fw pi-plus", url: "/", }, + { + label: "Edit Calendar", + icon: "pi pi-fw pi-pencil", + url: "/edit", + }, { label: "FAQ", icon: "pi pi-fw pi-book", diff --git a/frontend/src/components/ModuleInformation.vue b/frontend/src/components/ModuleInformation.vue new file mode 100644 index 0000000..e5789c4 --- /dev/null +++ b/frontend/src/components/ModuleInformation.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/ModuleSelection.vue b/frontend/src/components/ModuleSelection.vue index ab43ec5..b7981b5 100644 --- a/frontend/src/components/ModuleSelection.vue +++ b/frontend/src/components/ModuleSelection.vue @@ -1,5 +1,5 @@