Merge branch 'main' into 15-calendar-preview

# Conflicts:
#	frontend/src/view/AdditionalModules.vue
#	frontend/src/view/EditCalendarView.vue
This commit is contained in:
masterElmar
2023-11-28 19:16:10 +01:00
27 changed files with 1006 additions and 89 deletions

View File

@@ -35,6 +35,14 @@ htwkalender-demo
Execute the following api calls to fetch data manually from HTWK and store it in the database: Execute the following api calls to fetch data manually from HTWK and store it in the database:
Both api calls need a token to be executed.
You can get a token by logging in to the admin ui and copy the token from the local storage.
Add this attribute to the request header of the api call:
```
Authorization : eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDE3MDA3ODQsImlkIjoicnA0Ym54YXNyczM5emR4IiwidHlwZSI6ImFkbWluIn0.j7Bt3-uaZ8CoNt8D9Oxjk7ZwvHDGZJy1xe3aq4BID3w
```
The first command will fetch all groups and store them in the database. The first command will fetch all groups and store them in the database.
This should be done quick in a few seconds (0-5s). This should be done quick in a few seconds (0-5s).
When you execute the command again, it will update the groups in the When you execute the command again, it will update the groups in the

View File

@@ -0,0 +1,52 @@
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/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId("d65h4wh7zk13gxp")
if err != nil {
return err
}
// add
new_retrieved := &schema.SchemaField{}
json.Unmarshal([]byte(`{
"system": false,
"id": "wmmney8x",
"name": "retrieved",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
}`), new_retrieved)
collection.Schema.AddField(new_retrieved)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId("d65h4wh7zk13gxp")
if err != nil {
return err
}
// remove
collection.Schema.RemoveField("wmmney8x")
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,444 @@
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-11-01 21:17:43.567Z",
"name": "groups",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "85msl21p",
"name": "university",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "2sii4dtp",
"name": "shortcut",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "uiwgo28f",
"name": "groupId",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "y0l1lrzs",
"name": "course",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "kr62mhbz",
"name": "faculty",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "ya6znpez",
"name": "facultyId",
"type": "text",
"required": false,
"presentable": 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-11-20 20:38:58.258Z",
"name": "feeds",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "cowxjfmc",
"name": "modules",
"type": "json",
"required": true,
"presentable": false,
"unique": false,
"options": {}
},
{
"system": false,
"id": "wmmney8x",
"name": "retrieved",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": "",
"createRule": null,
"updateRule": "",
"deleteRule": null,
"options": {}
},
{
"id": "7her4515qsmrxe8",
"created": "2023-09-19 17:31:15.958Z",
"updated": "2023-11-01 21:17:43.567Z",
"name": "events",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "m8ne8e3m",
"name": "Day",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "xnsxqp7j",
"name": "Week",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "aeuskrjo",
"name": "Name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "klrzqyw0",
"name": "EventType",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "5zltexoy",
"name": "Prof",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "gy3nvfmx",
"name": "Rooms",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "hn7b8dfy",
"name": "Notes",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "axskpwm8",
"name": "BookedAt",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "vyyefxp7",
"name": "course",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "vlbpm9fz",
"name": "semester",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "0kahthzr",
"name": "uuid",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "6hkjwgb4",
"name": "start",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "szbefpjf",
"name": "end",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "nlnnxu7x",
"name": "Compulsory",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_4vOTAiC` + "`" + ` ON ` + "`" + `events` + "`" + ` (\n ` + "`" + `Name` + "`" + `,\n ` + "`" + `course` + "`" + `,\n ` + "`" + `start` + "`" + `,\n ` + "`" + `end` + "`" + `,\n ` + "`" + `semester` + "`" + `,\n ` + "`" + `EventType` + "`" + `,\n ` + "`" + `Compulsory` + "`" + `\n)"
],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "_pb_users_auth_",
"created": "2023-11-01 21:17:43.390Z",
"updated": "2023-11-01 21:17:43.567Z",
"name": "users",
"type": "auth",
"system": false,
"schema": [
{
"system": false,
"id": "users_name",
"name": "name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "users_avatar",
"name": "avatar",
"type": "file",
"required": false,
"presentable": 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
})
}

View File

@@ -1,9 +1,13 @@
package model package model
import "github.com/pocketbase/pocketbase/models" import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
)
type Feed struct { type Feed struct {
Modules string `db:"modules" json:"modules"` Modules string `db:"modules" json:"modules"`
Retrieved types.DateTime `db:"retrieved" json:"retrieved"`
models.BaseModel models.BaseModel
} }

View File

@@ -1,10 +1,10 @@
package model package model
type Module struct { type Module struct {
UUID string `json:"uuid"` UUID string `json:"uuid" db:"uuid"`
Name string `json:"name"` Name string `json:"name" db:"Name"`
Prof string `json:"prof"` Prof string `json:"prof" db:"Prof"`
Course string `json:"course"` Course string `json:"course" db:"course"`
Semester string `json:"semester"` Semester string `json:"semester" db:"semester"`
Events Events `json:"events"` Events Events `json:"events"`
} }

View File

@@ -11,30 +11,150 @@ paths:
/api/fetchPlans: /api/fetchPlans:
get: get:
summary: Fetch Seminar Plans summary: Fetch Seminar Plans
security:
- ApiKeyAuth: []
responses: responses:
'200': '200':
description: Successful response description: Successful response
/api/fetchGroups: /api/fetchGroups:
get: get:
summary: Fetch Seminar Groups summary: Fetch Seminar Groups
security:
- ApiKeyAuth: []
responses: responses:
'200': '200':
description: Successful response description: Successful response
/api/modules:
delete:
summary: Delete Module
security:
- ApiKeyAuth: []
responses:
'200':
description: Successful response
/api/rooms: /api/rooms:
get: get:
summary: Get Rooms summary: Get Rooms
responses: responses:
'200': '200':
description: Successful response description: Successful response
/api/feedURL: /api/schedule/day:
get: get:
summary: Get iCal Feed URL summary: Get Day Schedule
parameters:
- name: room
in: query
description: room
example: "LN006-H"
required: true
schema:
type: string
- name: date
in: query
description: date
example: "2023-11-26"
required: true
schema:
type: string
responses:
'200':
description: Successful response
/api/schedule:
get:
summary: Get Schedule
parameters:
- name: room
in: query
description: room
example: "LN006-H"
required: true
schema:
type: string
- name: from
in: query
description: date
example: "2023-11-26"
required: true
schema:
type: string
- name: to
in: query
description: date
example: "2023-11-30"
required: true
schema:
type: string
responses:
'200':
description: Successful response
/api/createFeed:
post:
summary: Create iCal Feed
requestBody:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Module'
required:
- name
- modules
responses: responses:
'200': '200':
description: Successful response description: Successful response
/api/feed: /api/feed:
get: get:
summary: Get iCal Feed for calendar summary: Get iCal Feed for calendar
parameters:
- name: token
in: query
description: calendar token
required: true
example: "ldluwzg3e73ffxq"
schema:
type: string
responses:
'200':
description: Successful response
/api/course/modules:
get:
summary: Get Modules for Course
parameters:
- name: course
in: query
description: course
required: true
example: "Software Engineering"
schema:
type: string
- name: semester
in: query
description: semester
required: true
example: "ws"
schema:
type: string
responses:
'200':
description: Successful response
/api/module:
get:
summary: Get Module
parameters:
- name: uuid
in: query
description: uuid
required: true
example: "d0b3a0e0-2f1a-4e1a-8b0a-0b9e1a0b9e1a"
schema:
type: string
responses:
'200':
description: Successful response
/api/courses:
get:
summary: Get Courses
responses: responses:
'200': '200':
description: Successful response description: Successful response
@@ -51,3 +171,52 @@ paths:
responses: responses:
'200': '200':
description: Successful response description: Successful response
/api/events:
delete:
summary: Delete Event
security:
- ApiKeyAuth: []
responses:
'200':
description: Successful response
/api/feeds/migrate:
get:
summary: Migrates all iCal Feeds in the database to the new format
security:
- ApiKeyAuth: []
responses:
'200':
description: Successful response
security:
- ApiKeyAuth: []
components:
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: Authorization
schemas:
Module:
type: object
properties:
name:
type: string
description: name
example: "Software Engineering"
uuid:
type: string
format: uuid
course:
type: string
userDefinedName:
type: string
prof:
type: string
semester:
type: string
reminder:
type: boolean
events:
type: array
items:
type: string

View File

@@ -1,7 +1,6 @@
package service package service
import ( import (
"htwkalender/model"
"htwkalender/service/events" "htwkalender/service/events"
"htwkalender/service/fetch" "htwkalender/service/fetch"
"htwkalender/service/ical" "htwkalender/service/ical"
@@ -26,6 +25,7 @@ func AddRoutes(app *pocketbase.PocketBase) {
}, },
Middlewares: []echo.MiddlewareFunc{ Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app), apis.ActivityLogger(app),
apis.RequireAdminAuth(),
}, },
}) })
if err != nil { if err != nil {
@@ -61,6 +61,7 @@ func AddRoutes(app *pocketbase.PocketBase) {
}, },
Middlewares: []echo.MiddlewareFunc{ Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app), apis.ActivityLogger(app),
apis.RequireAdminAuth(),
}, },
}) })
if err != nil { if err != nil {
@@ -212,18 +213,11 @@ func AddRoutes(app *pocketbase.PocketBase) {
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.MethodPost, Method: http.MethodGet,
Path: "/api/module", Path: "/api/module",
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
requestModule := c.QueryParam("uuid")
var requestModule model.Module module, err := events.GetModuleByUUID(app, requestModule)
if err := c.Bind(&requestModule); err != nil {
return apis.NewBadRequestError("Failed to read request body", err)
}
module, err := events.GetModuleByName(app, requestModule)
if err != nil { if err != nil {
return c.JSON(400, err) return c.JSON(400, err)
} else { } else {
@@ -286,7 +280,7 @@ func AddRoutes(app *pocketbase.PocketBase) {
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.MethodGet, Method: http.MethodGet,
Path: "/api/feed/migrate", Path: "/api/feeds/migrate",
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
err := ical.MigrateFeedJson(app) err := ical.MigrateFeedJson(app)

View File

@@ -4,8 +4,9 @@ import (
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/cron" "github.com/pocketbase/pocketbase/tools/cron"
"htwkalender/service/events" "htwkalender/service/course"
"log" "htwkalender/service/feed"
"htwkalender/service/functions/time"
) )
func AddSchedules(app *pocketbase.PocketBase) { func AddSchedules(app *pocketbase.PocketBase) {
@@ -17,23 +18,15 @@ func AddSchedules(app *pocketbase.PocketBase) {
// Every three hours update all courses (5 segments - minute, hour, day, month, weekday) "0 */3 * * *" // 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 * * * *" // Every 10 minutes update all courses (5 segments - minute, hour, day, month, weekday) "*/10 * * * *"
scheduler.MustAdd("updateCourse", "0 */3 * * *", func() { scheduler.MustAdd("updateCourse", "0 */3 * * *", func() {
course.UpdateCourse(app)
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")
}
}
}) })
// Every sunday at 3am clean all courses (5 segments - minute, hour, day, month, weekday) "0 3 * * 0"
scheduler.MustAdd("cleanFeeds", "0 3 * * 0", func() {
// clean feeds older than 6 months
feed.ClearFeeds(app.Dao(), 6, time.RealClock{})
})
scheduler.Start() scheduler.Start()
return nil return nil
}) })

View File

@@ -0,0 +1,20 @@
package course
import (
"github.com/pocketbase/pocketbase"
"htwkalender/service/events"
"log"
)
func UpdateCourse(app *pocketbase.PocketBase) {
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")
}
}
}

View File

@@ -196,6 +196,18 @@ func DeleteAllEvents(app *pocketbase.PocketBase) error {
return nil return nil
} }
func FindModuleByUUID(app *pocketbase.PocketBase, uuid string) (model.Module, error) {
var module model.Module
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("uuid = {:uuid}", dbx.Params{"uuid": uuid})).One(&module)
if err != nil {
print("Error while getting events from database: ", err)
return model.Module{}, err
}
return module, nil
}
func FindAllEventsByModule(app *pocketbase.PocketBase, module model.Module) (model.Events, error) { func FindAllEventsByModule(app *pocketbase.PocketBase, module model.Module) (model.Events, error) {
var events model.Events var events model.Events

View File

@@ -1,10 +1,11 @@
package db package db
import ( import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"htwkalender/model" "htwkalender/model"
"time"
) )
func SaveFeed(feed model.Feed, collection *models.Collection, app *pocketbase.PocketBase) (*models.Record, error) { func SaveFeed(feed model.Feed, collection *models.Collection, app *pocketbase.PocketBase) (*models.Record, error) {
@@ -19,10 +20,30 @@ func SaveFeed(feed model.Feed, collection *models.Collection, app *pocketbase.Po
} }
func FindFeedByToken(token string, app *pocketbase.PocketBase) (*model.Feed, error) { func FindFeedByToken(token string, app *pocketbase.PocketBase) (*model.Feed, error) {
var feed model.Feed
err := app.Dao().DB().Select("*").From("feeds").Where(dbx.NewExp("id = {:id}", dbx.Params{"id": token})).One(&feed) record, err := app.Dao().FindRecordById("feeds", token)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var feed model.Feed
feed.Modules = record.GetString("modules")
feed.Retrieved = record.GetDateTime("retrieved")
//update retrieved time
record.Set("retrieved", time.Now())
err = app.Dao().SaveRecord(record)
return &feed, err return &feed, err
} }
func GetAllFeeds(db *daos.Dao) ([]model.Feed, error) {
var feeds []model.Feed
err := db.DB().Select("*").From("feeds").All(&feeds)
if err != nil {
return nil, err
}
return feeds, nil
}

View File

@@ -42,11 +42,8 @@ func GetAllModulesDistinct(app *pocketbase.PocketBase, c echo.Context) error {
} }
} }
// GetModuleByName returns a module by its name func GetModuleByUUID(app *pocketbase.PocketBase, uuid string) (model.Module, error) {
// If the module does not exist, an error is returned module, err := db.FindModuleByUUID(app, uuid)
// If the module exists, the module is returned
// Module is a struct that exists in database as events
func GetModuleByName(app *pocketbase.PocketBase, module model.Module) (model.Module, error) {
events, err := db.FindAllEventsByModule(app, module) events, err := db.FindAllEventsByModule(app, module)
if err != nil || len(events) == 0 { if err != nil || len(events) == 0 {

View File

@@ -0,0 +1,35 @@
package feed
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
database "htwkalender/service/db"
localTime "htwkalender/service/functions/time"
"log"
)
func ClearFeeds(db *daos.Dao, months int, clock localTime.Clock) {
feeds, err := database.GetAllFeeds(db)
if err != nil {
log.Println("CleanFeeds: get all feeds failed")
return
}
for _, feed := range feeds {
// if retrieved time is older than a half year delete feed
now := clock.Now()
feedRetrievedTime := feed.Retrieved.Time()
timeShift := now.AddDate(0, -months, 0)
if feedRetrievedTime.Before(timeShift) {
// delete feed
sqlResult, err := db.DB().Delete("feeds", dbx.NewExp("id = {:id}", dbx.Params{"id": feed.GetId()})).Execute()
if err != nil {
log.Println("CleanFeeds: delete feed " + feed.GetId() + " failed")
log.Println(err)
log.Println(sqlResult)
} else {
log.Println("CleanFeeds: delete feed " + feed.GetId() + " successful")
}
}
}
}

View File

@@ -0,0 +1,83 @@
package feed
import (
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tests"
"htwkalender/model"
mockTime "htwkalender/service/functions/time"
"testing"
"time"
)
const testDataDir = "./mockData"
func TestClearFeeds(t *testing.T) {
setupTestApp := func(t *testing.T) *daos.Dao {
testApp, err := tests.NewTestApp(testDataDir)
if err != nil {
t.Fatal(err)
}
dao := daos.New(testApp.Dao().DB())
return dao
}
type args struct {
db *daos.Dao
months int
mockClock mockTime.MockClock
}
testCases := []struct {
name string
args args
want int
}{
{
name: "TestClearFeeds",
args: args{
db: setupTestApp(t),
months: 6,
mockClock: mockTime.MockClock{
NowTime: time.Date(2023, 12, 1, 0, 0, 0, 0, time.UTC),
},
},
want: 1,
},
{
name: "TestClearAllFeeds",
args: args{
db: setupTestApp(t),
months: 1,
mockClock: mockTime.MockClock{
NowTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
want: 0,
},
{
name: "TestClearFeedsClearBeforeRetrievedTime",
args: args{
db: setupTestApp(t),
months: 1,
mockClock: mockTime.MockClock{
NowTime: time.Date(2010, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
want: 3,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ClearFeeds(tt.args.db, tt.args.months, tt.args.mockClock)
// count all feeds in db
var feeds []*model.Feed
err := tt.args.db.DB().Select("id").From("feeds").All(&feeds)
if err != nil {
t.Fatal(err)
}
if got := len(feeds); got != tt.want {
t.Errorf("ClearFeeds() = %v, want %v", got, tt.want)
}
})
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,10 @@
package time
import "time"
type MockClock struct {
NowTime time.Time
}
func (m MockClock) Now() time.Time { return m.NowTime }
func (MockClock) After(d time.Duration) <-chan time.Time { return time.After(d) }

View File

@@ -0,0 +1,8 @@
package time
import "time"
type RealClock struct{}
func (RealClock) Now() time.Time { return time.Now() }
func (RealClock) After(d time.Duration) <-chan time.Time { return time.After(d) }

View File

@@ -0,0 +1,8 @@
package time
import "time"
type Clock interface {
Now() time.Time
After(d time.Duration) <-chan time.Time
}

View File

@@ -1,13 +1,8 @@
import { Module } from "../model/module"; import { Module } from "../model/module";
export async function fetchModule(module: Module): Promise<Module> { export async function fetchModule(module: Module): Promise<Module> {
const request = new Request("/api/module", { // request to the backend on /api/module with query parameters name as the module name
method: "POST", const request = new Request("/api/module?uuid=" + module.uuid);
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(module),
});
return await fetch(request) return await fetch(request)
.then((response) => { .then((response) => {

View File

@@ -6,9 +6,13 @@ export async function getCalender(token: string): Promise<Module[]> {
method: "GET", method: "GET",
}); });
return await fetch(request) return await fetch(request).then((response) => {
.then((response) => { if (response.ok) {
return response.json(); return response
}) .json()
.then((calendarResponse: Calendar) => calendarResponse.modules); .then((calendarResponse: Calendar) => calendarResponse.modules);
} else {
return [];
}
});
} }

View File

@@ -1,13 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, ref, Ref } from "vue"; import { defineAsyncComponent, ref, Ref } from "vue";
import { Module } from "../../model/module"; import { Module } from "../../model/module.ts";
import { fetchAllModules } from "../../api/fetchCourse"; import { fetchAllModules } from "../../api/fetchCourse.ts";
import moduleStore from "../../store/moduleStore"; import moduleStore from "../../store/moduleStore";
import { MultiSelectAllChangeEvent } from "primevue/multiselect"; import { MultiSelectAllChangeEvent } from "primevue/multiselect";
import router from "../../router"; import router from "../../router";
import { fetchModule } from "../../api/fetchModule"; import { fetchModule } from "../../api/fetchModule.ts";
import { useDialog } from "primevue/usedialog"; import { useDialog } from "primevue/usedialog";
import { useI18n } from "vue-i18n";
const dialog = useDialog(); const dialog = useDialog();
const { t } = useI18n({ useScope: "global" });
const fetchedModules = async () => { const fetchedModules = async () => {
return await fetchAllModules(); return await fetchAllModules();
@@ -70,14 +73,29 @@ const onSelectAllChange = (event: MultiSelectAllChangeEvent) => {
function selectChange() { function selectChange() {
selectAll.value = selectedModules.value.length === modules.value.length; selectAll.value = selectedModules.value.length === modules.value.length;
} }
function itemsLabel(selectedModules: Module[]): string {
return (selectedModules ? selectedModules.length : 0) != 1
? t("additionalModules.modules")
: t("additionalModules.module");
}
function itemsLabelWithNumber(selectedModules: Module[]): string {
return (
selectedModules.length.toString() +
" " +
itemsLabel(selectedModules) +
" " +
t("additionalModules.dropDownFooterSelected")
);
}
</script> </script>
<template> <template>
<div class="flex flex-column"> <div class="flex flex-column">
<div class="flex align-items-center justify-content-center h-4rem m-2"> <div class="flex align-items-center justify-content-center h-4rem m-2">
<h3> <h3>
Select additional Modules that are not listed in the regular semester {{ $t("additionalModules.subTitle") }}
for your Course
</h3> </h3>
</div> </div>
<div class="card flex align-items-center justify-content-center m-2"> <div class="card flex align-items-center justify-content-center m-2">
@@ -90,8 +108,10 @@ function selectChange() {
:virtual-scroller-options="{ itemSize: 70 }" :virtual-scroller-options="{ itemSize: 70 }"
class="custom-multiselect" class="custom-multiselect"
filter filter
placeholder="Select additional modules" :placeholder="$t('additionalModules.dropDown')"
:auto-filter-focus="true" :auto-filter-focus="true"
:show-toggle-all="false"
:selected-items-label="itemsLabelWithNumber(selectedModules)"
@change="selectChange()" @change="selectChange()"
@selectall-change="onSelectAllChange($event)" @selectall-change="onSelectAllChange($event)"
> >
@@ -104,6 +124,7 @@ function selectChange() {
</div> </div>
<div class="flex align-items-center justify-content-center ml-2"> <div class="flex align-items-center justify-content-center ml-2">
<Button <Button
class="small-button"
icon="pi pi-info" icon="pi pi-info"
severity="secondary" severity="secondary"
rounded rounded
@@ -127,7 +148,9 @@ function selectChange() {
</MultiSelect> </MultiSelect>
</div> </div>
<div class="flex align-items-center justify-content-center h-4rem m-2"> <div class="flex align-items-center justify-content-center h-4rem m-2">
<Button @click="nextStep()">Next Step</Button> <Button @click="nextStep()">{{
$t("additionalModules.nextStep")
}}</Button>
</div> </div>
</div> </div>
</template> </template>
@@ -140,4 +163,10 @@ function selectChange() {
:deep(.custom-multiselect li) { :deep(.custom-multiselect li) {
height: unset; height: unset;
} }
.small-button.p-button {
width: 2rem;
height: 2rem;
padding: 0;
}
</style> </style>

View File

@@ -1,13 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, Ref, ref } from "vue"; import { computed, Ref, ref } from "vue";
import { Module } from "../../model/module"; import { Module } from "../../model/module.ts";
import moduleStore from "../../store/moduleStore"; import moduleStore from "../../store/moduleStore";
import { fetchAllModules } from "../../api/fetchCourse"; import { fetchAllModules } from "../../api/fetchCourse.ts";
import { saveIndividualFeed } from "../../api/createFeed"; import { saveIndividualFeed } from "../../api/createFeed.ts";
import tokenStore from "../../store/tokenStore"; import tokenStore from "../../store/tokenStore";
import router from "../../router"; import router from "../../router";
import ModuleTemplateDialog from "../ModuleTemplateDialog.vue"; import ModuleTemplateDialog from "../ModuleTemplateDialog.vue";
import { onlyWhitespace } from "../../helpers/strings.ts"; import { onlyWhitespace } from "../../helpers/strings.ts";
import { useI18n } from "vue-i18n";
const { t } = useI18n({ useScope: "global" });
const store = moduleStore(); const store = moduleStore();
const tableData = computed(() => const tableData = computed(() =>
@@ -20,9 +22,9 @@ const tableData = computed(() =>
); );
const columns = ref([ const columns = ref([
{ field: "Course", header: "Course" }, { field: "Course", header: t("moduleInformation.course") },
{ field: "Module", header: "Module" }, { field: "Module", header: t("moduleInformation.module") },
{ field: "Reminder", header: "Reminder" }, { field: "Reminder", header: t("renameModules.reminder") },
]); ]);
const fetchedModules = async () => { const fetchedModules = async () => {
@@ -52,7 +54,7 @@ async function finalStep() {
<template> <template>
<div class="flex flex-column card-container mx-8 mt-2"> <div class="flex flex-column card-container mx-8 mt-2">
<div class="flex align-items-center justify-content-center h-4rem m-2"> <div class="flex align-items-center justify-content-center h-4rem m-2">
<h3>Rename your selected Modules to your liking.</h3> <h3>{{ $t("renameModules.subTitle") }}</h3>
<ModuleTemplateDialog /> <ModuleTemplateDialog />
</div> </div>
<div <div
@@ -66,7 +68,7 @@ async function finalStep() {
> >
<template #header> <template #header>
<div class="flex align-items-center justify-content-end"> <div class="flex align-items-center justify-content-end">
Enable all notifications: {{ $t("renameModules.enableAllNotifications") }}
<InputSwitch <InputSwitch
class="mx-4" class="mx-4"
:model-value=" :model-value="
@@ -153,7 +155,7 @@ async function finalStep() {
<div <div
class="flex align-items-center justify-content-center border-round m-2" class="flex align-items-center justify-content-center border-round m-2"
> >
<Button label="Save Calendar" @click="finalStep()" /> <Button @click="finalStep()">{{ $t("renameModules.nextStep") }}</Button>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -49,7 +49,8 @@
"invalidToken": "Ungültiger Token", "invalidToken": "Ungültiger Token",
"headline": "Bearbeite deinen HTWKalender", "headline": "Bearbeite deinen HTWKalender",
"subTitle": "Füge deinen Link oder Token ein um den Kalender zu bearbeiten", "subTitle": "Füge deinen Link oder Token ein um den Kalender zu bearbeiten",
"loadCalendar": "Kalender laden" "loadCalendar": "Kalender laden",
"noCalendarFound": "Keinen Kalender gefunden"
}, },
"additionalModules": { "additionalModules": {
"subTitle": "Füge weitere Module hinzu die nicht in deinem Studiengang enthalten sind.", "subTitle": "Füge weitere Module hinzu die nicht in deinem Studiengang enthalten sind.",
@@ -63,7 +64,7 @@
"reminder": "Erinnerung", "reminder": "Erinnerung",
"enableAllNotifications": "Alle Benachrichtigungen aktivieren", "enableAllNotifications": "Alle Benachrichtigungen aktivieren",
"subTitle": "Konfigurieren Sie die ausgewählten Module nach Ihren Wünschen.", "subTitle": "Konfigurieren Sie die ausgewählten Module nach Ihren Wünschen.",
"nextStep": "Weiter" "nextStep": "Speichern"
}, },
"moduleTemplateDialog": { "moduleTemplateDialog": {
"explanationOne": "Hier können Module nach Wunsch umbenannt werden, welche dann als Anzeigename im Kalender dargestellt werden.", "explanationOne": "Hier können Module nach Wunsch umbenannt werden, welche dann als Anzeigename im Kalender dargestellt werden.",

View File

@@ -49,7 +49,8 @@
"invalidToken": "invalid token", "invalidToken": "invalid token",
"headline": "edit your HTWKalender", "headline": "edit your HTWKalender",
"subTitle": "please enter your link or calendar token", "subTitle": "please enter your link or calendar token",
"loadCalendar": "load calendar" "loadCalendar": "load calendar",
"noCalendarFound": "no calendar found"
}, },
"additionalModules": { "additionalModules": {
"subTitle": "Select additional Modules that are not listed in the regular semester for your Course", "subTitle": "Select additional Modules that are not listed in the regular semester for your Course",
@@ -63,7 +64,7 @@
"reminder": "reminder", "reminder": "reminder",
"enableAllNotifications": "enable all notifications", "enableAllNotifications": "enable all notifications",
"subTitle": "Configure your selected Modules to your liking.", "subTitle": "Configure your selected Modules to your liking.",
"nextStep": "next step" "nextStep": "Save"
}, },
"moduleTemplateDialog": { "moduleTemplateDialog": {
"explanationOne": "Here you can rename your modules to your liking. This will be the name of the event in your calendar.", "explanationOne": "Here you can rename your modules to your liking. This will be the name of the event in your calendar.",

View File

@@ -7,8 +7,10 @@ import { FilterMatchMode } from "primevue/api";
import { useDialog } from "primevue/usedialog"; import { useDialog } from "primevue/usedialog";
import router from "../router"; import router from "../router";
import { fetchModule } from "../api/fetchModule.ts"; import { fetchModule } from "../api/fetchModule.ts";
import { useI18n } from "vue-i18n";
const dialog = useDialog(); const dialog = useDialog();
const { t } = useI18n({ useScope: "global" });
const fetchedModules = async () => { const fetchedModules = async () => {
return await fetchAllModules(); return await fetchAllModules();
@@ -111,11 +113,19 @@ function selectChange(event : MultiSelectChangeEvent) {
} }
function itemsLabel(selectedModules: Module[]): string { function itemsLabel(selectedModules: Module[]): string {
return (selectedModules ? selectedModules.length : 0) != 1 ? t("additionalModules.modules") : t("additionalModules.module"); return (selectedModules ? selectedModules.length : 0) != 1
? t("additionalModules.modules")
: t("additionalModules.module");
} }
function itemsLabelWithNumber(selectedModules: Module[]): string { function itemsLabelWithNumber(selectedModules: Module[]): string {
return selectedModules.length.toString() + " " + itemsLabel(selectedModules) + " " + t("additionalModules.dropDownFooterSelected"); return (
selectedModules.length.toString() +
" " +
itemsLabel(selectedModules) +
" " +
t("additionalModules.dropDownFooterSelected")
);
} }
*/ */
</script> </script>
@@ -228,11 +238,11 @@ function itemsLabelWithNumber(selectedModules: Module[]): string {
:placeholder="$t('additionalModules.dropDown')" :placeholder="$t('additionalModules.dropDown')"
:auto-filter-focus="true" :auto-filter-focus="true"
:show-toggle-all="false" :show-toggle-all="false"
:selected-items-label="itemsLabelWithNumber(selectedModules)"
@change="selectChange()" @change="selectChange()"
placeholder="Select additional modules" placeholder="Select additional modules"
@change="selectChange($event)" @change="selectChange($event)"
@selectall-change="onSelectAllChange($event)" @selectall-change="onSelectAllChange($event)"
:selectedItemsLabel="itemsLabelWithNumber(selectedModules)"
> >
<template #option="slotProps"> <template #option="slotProps">
<div class="flex justify-content-between w-full"> <div class="flex justify-content-between w-full">
@@ -243,6 +253,7 @@ function itemsLabelWithNumber(selectedModules: Module[]): string {
</div> </div>
<div class="flex align-items-center justify-content-center ml-2"> <div class="flex align-items-center justify-content-center ml-2">
<Button <Button
class="small-button"
icon="pi pi-info" icon="pi pi-info"
severity="secondary" severity="secondary"
rounded rounded
@@ -258,8 +269,10 @@ function itemsLabelWithNumber(selectedModules: Module[]): string {
<template #footer> <template #footer>
<div class="py-2 px-3"> <div class="py-2 px-3">
<b>{{ selectedModules ? selectedModules.length : 0 }}</b> <b>{{ selectedModules ? selectedModules.length : 0 }}</b>
{{ itemsLabel(selectedModules) }} item{{
{{ $t("additionalModules.dropDownFooterSelected") }} (selectedModules ? selectedModules.length : 0) > 1 ? "s" : ""
}}
selected.
</div> </div>
</template> </template>
</MultiSelect> </MultiSelect>
@@ -280,4 +293,10 @@ function itemsLabelWithNumber(selectedModules: Module[]): string {
:deep(.custom-multiselect li) { :deep(.custom-multiselect li) {
height: unset; height: unset;
} }
.small-button.p-button {
width: 2rem;
height: 2rem;
padding: 0;
}
</style> </style>

View File

@@ -46,14 +46,22 @@ function loadCalendar(): void {
moduleStore().removeAllModules(); moduleStore().removeAllModules();
tokenStore().setToken(token.value); tokenStore().setToken(token.value);
getCalender(token.value).then((data) => { getCalender(token.value).then((data: Module[]) => {
data.forEach((module) => { if (data.length > 0) {
moduleStore().addModule(module); data.forEach((module) => {
}); moduleStore().addModule(module);
modules.value = moduleStore().modules; });
modules.value = moduleStore().modules;
router.push("/edit-additional-modules");
} else {
toast.add({
severity: "error",
summary: t("editCalendarView.error"),
detail: t("editCalendarView.noCalendarFound"),
life: 3000,
});
}
}); });
router.push("/edit-additional-modules");
} }
</script> </script>