From 34ad90d50dcbeac87261810cddfa56561860901b Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Sat, 22 Nov 2025 20:20:00 +0100 Subject: [PATCH] feat: Add new data-manager service for professor and module management and a new frontend application with i18n and a professor dashboard. --- docker-compose.yml | 7 +- frontend/Dockerfile | 3 + frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/components/MenuBar.vue | 129 +++++--- frontend/src/i18n/translations/de.json | 16 +- frontend/src/i18n/translations/en.json | 16 +- frontend/src/router/index.ts | 8 + frontend/src/service/pocketbase.ts | 27 ++ .../src/view/professor/ProfessorDashboard.vue | 233 ++++++++++++++ frontend/vite.config.ts | 1 + reverseproxy.local.conf | 4 +- services/data-manager/main.go | 14 +- .../migrations/1745249436_enable_oauth2.go | 32 ++ services/data-manager/model/moduleModel.go | 13 +- .../model/serviceModel/serviceModel.go | 11 +- services/data-manager/service/addRoute.go | 2 + .../service/professor/professorService.go | 284 ++++++++++++++++++ .../data-manager/service/professor/routes.go | 29 ++ 19 files changed, 769 insertions(+), 68 deletions(-) create mode 100644 frontend/src/service/pocketbase.ts create mode 100644 frontend/src/view/professor/ProfessorDashboard.vue create mode 100644 services/data-manager/migrations/1745249436_enable_oauth2.go create mode 100644 services/data-manager/service/professor/professorService.go create mode 100644 services/data-manager/service/professor/routes.go diff --git a/docker-compose.yml b/docker-compose.yml index f2bb6b2..2df09a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,10 +42,13 @@ services: build: dockerfile: Dockerfile context: ./frontend - target: prod + target: dev args: - COMMIT_HASH=${COMMIT_HASH} # open port 8000 + volumes: + - ./frontend/src/:/app/src + - ./frontend/public/:/app/public ports: - "8000:8000" @@ -57,7 +60,7 @@ services: - htwkalender-data-manager - htwkalender-frontend ports: - - "8080:8080" + - "80:80" volumes: pb_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 3aaa1dd..f8a2c2a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -39,6 +39,9 @@ RUN echo "COMMIT_HASH=$COMMIT_HASH" >> .env.commit RUN npm install COPY . ./ +# execute the npm run dev command +CMD ["npm", "run", "dev"] + # production stage # https://hub.docker.com/r/bitnami/nginx -> always run as non-root user FROM docker.io/bitnami/nginx:latest AS prod diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ce1e327..9951268 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "@vueuse/core": "^13.0.0", "country-flag-emoji-polyfill": "^0.1.8", "pinia": "^3.0.1", + "pocketbase": "^0.26.3", "primeflex": "^3.3.1", "primeicons": "^7.0.0", "primevue": "^3.53.1", @@ -5593,6 +5594,12 @@ "@vue/devtools-kit": "^7.7.2" } }, + "node_modules/pocketbase": { + "version": "0.26.3", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.3.tgz", + "integrity": "sha512-5deUKRoEczpxxuHzwr6/DHVmgbggxylEVig8CKN+MjvtYxPUqX/C6puU0yaR2yhTi8zrh7J9s7Ty+qBGwVzWOQ==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6c62dd3..094e868 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@vueuse/core": "^13.0.0", "country-flag-emoji-polyfill": "^0.1.8", "pinia": "^3.0.1", + "pocketbase": "^0.26.3", "primeflex": "^3.3.1", "primeicons": "^7.0.0", "primevue": "^3.53.1", diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index 6e4649d..468d192 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -17,61 +17,86 @@ along with this program. If not, see . --> + + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 719f22f..32ab09b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -79,6 +79,7 @@ export default defineConfig({ watch: { usePolling: true, }, + allowedHosts: ["localhost", "127.0.0.1", "::1", "cal.htwk-leipzig.de", "htwkalender-frontend"], proxy: { "/api": { target: "http://localhost:8090/api", diff --git a/reverseproxy.local.conf b/reverseproxy.local.conf index bb05c34..42a12cf 100644 --- a/reverseproxy.local.conf +++ b/reverseproxy.local.conf @@ -25,8 +25,8 @@ http { limit_req_zone $ratelimit_key zone=createFeed:10m rate=1r/m; server { - listen 8080; - listen [::]:8080; + listen 80; + listen [::]:80; http2 on; location /api/feed { diff --git a/services/data-manager/main.go b/services/data-manager/main.go index a5fbcf6..afdfc7d 100644 --- a/services/data-manager/main.go +++ b/services/data-manager/main.go @@ -17,27 +17,31 @@ package main import ( - "github.com/pocketbase/pocketbase" - "github.com/pocketbase/pocketbase/plugins/migratecmd" _ "htwkalender/data-manager/migrations" "htwkalender/data-manager/model/serviceModel" "htwkalender/data-manager/service" "htwkalender/data-manager/service/events" "htwkalender/data-manager/service/grpc" + "htwkalender/data-manager/service/professor" "log/slog" "os" "strings" + + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/plugins/migratecmd" ) func setupApp() *pocketbase.PocketBase { app := pocketbase.New() courseService := events.NewPocketBaseCourseService(app) eventService := events.NewPocketBaseEventService(app) + professorService := professor.NewProfessorService(app) services := serviceModel.Service{ - CourseService: courseService, - EventService: eventService, - App: app, + CourseService: courseService, + EventService: eventService, + ProfessorService: professorService, + App: app, } // loosely check if it was executed using "go run" diff --git a/services/data-manager/migrations/1745249436_enable_oauth2.go b/services/data-manager/migrations/1745249436_enable_oauth2.go new file mode 100644 index 0000000..98a0a62 --- /dev/null +++ b/services/data-manager/migrations/1745249436_enable_oauth2.go @@ -0,0 +1,32 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("users") + if err != nil { + return err + } + + // Enable OAuth2 + collection.OAuth2.Enabled = true + + // Optional: Map fields if necessary, for now just enabling it + // collection.OAuth2.MappedFields.Name = "name" + + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("users") + if err != nil { + return err + } + + collection.OAuth2.Enabled = false + + return app.Save(collection) + }) +} diff --git a/services/data-manager/model/moduleModel.go b/services/data-manager/model/moduleModel.go index 1a9e3e9..b8e0ad6 100644 --- a/services/data-manager/model/moduleModel.go +++ b/services/data-manager/model/moduleModel.go @@ -30,12 +30,13 @@ func (m *Module) SetName(name string) { } type ModuleDTO struct { - UUID string `json:"uuid" db:"uuid"` - Name string `json:"name" db:"Name"` - Prof string `json:"prof" db:"Prof"` - Course string `json:"course" db:"course"` - Semester string `json:"semester" db:"semester"` - EventType string `db:"EventType" json:"eventType"` + UUID string `json:"uuid" db:"uuid"` + Name string `json:"name" db:"Name"` + Prof string `json:"prof" db:"Prof"` + Course string `json:"course" db:"course"` + Semester string `json:"semester" db:"semester"` + EventType string `db:"EventType" json:"eventType"` + ConfidenceScore float64 `json:"confidenceScore,omitempty"` } func (m *ModuleDTO) GetName() string { diff --git a/services/data-manager/model/serviceModel/serviceModel.go b/services/data-manager/model/serviceModel/serviceModel.go index 8e2fe69..bcc2f75 100644 --- a/services/data-manager/model/serviceModel/serviceModel.go +++ b/services/data-manager/model/serviceModel/serviceModel.go @@ -1,12 +1,15 @@ package serviceModel import ( - "github.com/pocketbase/pocketbase" "htwkalender/data-manager/service/events" + "htwkalender/data-manager/service/professor" + + "github.com/pocketbase/pocketbase" ) type Service struct { - App *pocketbase.PocketBase - EventService events.EventService - CourseService events.CourseService + App *pocketbase.PocketBase + EventService events.EventService + CourseService events.CourseService + ProfessorService *professor.ProfessorService } diff --git a/services/data-manager/service/addRoute.go b/services/data-manager/service/addRoute.go index 898f712..3a1098e 100644 --- a/services/data-manager/service/addRoute.go +++ b/services/data-manager/service/addRoute.go @@ -23,6 +23,7 @@ import ( v1 "htwkalender/data-manager/service/fetch/v1" v2 "htwkalender/data-manager/service/fetch/v2" "htwkalender/data-manager/service/functions/time" + "htwkalender/data-manager/service/professor" "htwkalender/data-manager/service/room" "log/slog" "net/http" @@ -231,6 +232,7 @@ func AddRoutes(services serviceModel.Service) { }).Bind(apis.RequireSuperuserAuth()) addFeedRoutes(se, services.App) + professor.RegisterRoutes(se, services.ProfessorService) return se.Next() }) diff --git a/services/data-manager/service/professor/professorService.go b/services/data-manager/service/professor/professorService.go new file mode 100644 index 0000000..a4f6325 --- /dev/null +++ b/services/data-manager/service/professor/professorService.go @@ -0,0 +1,284 @@ +package professor + +import ( + "fmt" + "htwkalender/data-manager/model" + "log/slog" + "strings" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase" +) + +type ProfessorService struct { + app *pocketbase.PocketBase +} + +func NewProfessorService(app *pocketbase.PocketBase) *ProfessorService { + return &ProfessorService{app: app} +} + +func (s *ProfessorService) GetModulesForProfessor(email string) ([]model.ModuleDTO, error) { + // Extract name from email + // Format: firstname.lastname@htwk-leipzig.de + parts := strings.Split(email, "@") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid email format") + } + + nameParts := strings.Split(parts[0], ".") + if len(nameParts) < 2 { + slog.Warn("Email does not contain dot separator", "email", email) + return []model.ModuleDTO{}, nil + } + + // Extract first and last name + firstName := nameParts[0] + lastName := nameParts[len(nameParts)-1] + + // Capitalize first letter + if len(firstName) > 0 { + firstName = strings.ToUpper(firstName[:1]) + firstName[1:] + } + if len(lastName) > 0 { + lastName = strings.ToUpper(lastName[:1]) + lastName[1:] + } + + slog.Info("Searching for modules for professor", "firstName", firstName, "lastName", lastName, "email", email) + + // First, get all distinct modules with their professors + type EventProf struct { + Name string `db:"Name" json:"name"` + EventType string `db:"EventType" json:"eventType"` + Prof string `db:"Prof" json:"prof"` + Course string `db:"course" json:"course"` + Semester string `db:"semester" json:"semester"` + UUID string `db:"uuid" json:"uuid"` + } + + var allEvents []EventProf + err := s.app.DB(). + Select("Name", "EventType", "Prof", "course", "semester", "uuid"). + From("events"). + Where(dbx.NewExp("Prof != ''")). + GroupBy("Name", "course", "Prof"). + Distinct(true). + All(&allEvents) + + if err != nil { + return nil, err + } + + // Filter events by matching professor name and calculate confidence scores + var modules []model.ModuleDTO + seenModules := make(map[string]bool) // key: Name+Course to avoid duplicates + + for _, event := range allEvents { + score := calculateConfidenceScore(event.Prof, firstName, lastName) + if score > 0 { // Include all modules with any match + key := event.Name + "|" + event.Course + if !seenModules[key] { + modules = append(modules, model.ModuleDTO{ + Name: event.Name, + EventType: event.EventType, + Prof: event.Prof, + Course: event.Course, + Semester: event.Semester, + UUID: event.UUID, + ConfidenceScore: score, + }) + seenModules[key] = true + } + } + } + + slog.Info("Found modules for professor", "count", len(modules), "lastName", lastName) + return modules, nil +} + +// calculateConfidenceScore returns a score from 0.0 to 1.0 indicating how confident we are +// that this professor string matches the given first and last name +// 1.0 = perfect match (both first and last name exact) +// 0.7-0.9 = good match (last name exact, first name fuzzy or present) +// 0.4-0.6 = possible match (last name fuzzy or partial) +// 0.1-0.3 = weak match (last name substring) +// 0.0 = no match +func calculateConfidenceScore(profString, firstName, lastName string) float64 { + // Normalize the professor string: remove common titles and split into words + profString = strings.ToLower(profString) + + // Remove common titles + titles := []string{"prof.", "dr.", "arch.", "ing.", "dipl.", "m.sc.", "b.sc.", "ph.d."} + for _, title := range titles { + profString = strings.ReplaceAll(profString, title, "") + } + + // Split by spaces, hyphens, and other separators + words := strings.FieldsFunc(profString, func(r rune) bool { + return r == ' ' || r == '-' || r == ',' || r == '.' + }) + + // Normalize firstName and lastName + firstNameLower := strings.ToLower(firstName) + lastNameLower := strings.ToLower(lastName) + + lastNameExact := false + lastNameFuzzy := false + lastNameSubstring := false + firstNameExact := false + firstNameFuzzy := false + + for _, word := range words { + word = strings.TrimSpace(word) + if word == "" { + continue + } + + // Check last name + if word == lastNameLower { + lastNameExact = true + } else if levenshteinDistance(word, lastNameLower) <= 1 && len(lastNameLower) > 3 { + lastNameFuzzy = true + } else if strings.Contains(word, lastNameLower) || strings.Contains(lastNameLower, word) { + lastNameSubstring = true + } + + // Check first name + if word == firstNameLower { + firstNameExact = true + } else if levenshteinDistance(word, firstNameLower) <= 1 && len(firstNameLower) > 3 { + firstNameFuzzy = true + } + } + + // Calculate confidence score based on matches + score := 0.0 + + if lastNameExact { + if firstNameExact { + score = 1.0 // Perfect match + } else if firstNameFuzzy { + score = 0.9 // Excellent match + } else { + score = 0.8 // Good match (last name exact, no first name match) + } + } else if lastNameFuzzy { + if firstNameExact || firstNameFuzzy { + score = 0.6 // Decent match (fuzzy last name but first name matches) + } else { + score = 0.5 // Medium match (fuzzy last name, no first name) + } + } else if lastNameSubstring { + score = 0.2 // Weak match (substring only) + } + + return score +} + +// matchesProfessor is deprecated, use calculateConfidenceScore instead +func matchesProfessor(profString, firstName, lastName string) bool { + // Normalize the professor string: remove common titles and split into words + profString = strings.ToLower(profString) + + // Remove common titles + titles := []string{"prof.", "dr.", "arch.", "ing.", "dipl.", "m.sc.", "b.sc.", "ph.d."} + for _, title := range titles { + profString = strings.ReplaceAll(profString, title, "") + } + + // Split by spaces, hyphens, and other separators + words := strings.FieldsFunc(profString, func(r rune) bool { + return r == ' ' || r == '-' || r == ',' || r == '.' + }) + + // Normalize firstName and lastName + firstNameLower := strings.ToLower(firstName) + lastNameLower := strings.ToLower(lastName) + + lastNameFound := false + firstNameFound := false + + for _, word := range words { + word = strings.TrimSpace(word) + if word == "" { + continue + } + + // Exact match for last name + if word == lastNameLower { + lastNameFound = true + } + + // Exact match for first name (optional, but increases confidence) + if word == firstNameLower { + firstNameFound = true + } + + // Also check Levenshtein distance for typos + if !lastNameFound && levenshteinDistance(word, lastNameLower) <= 1 { + lastNameFound = true + } + if !firstNameFound && levenshteinDistance(word, firstNameLower) <= 1 { + firstNameFound = true + } + } + + // Match if last name is found (first name is optional for additional confidence) + // We require at least the last name to match + return lastNameFound +} + +// levenshteinDistance calculates the Levenshtein distance between two strings +func levenshteinDistance(s1, s2 string) int { + if len(s1) == 0 { + return len(s2) + } + if len(s2) == 0 { + return len(s1) + } + + // Create a 2D array for dynamic programming + d := make([][]int, len(s1)+1) + for i := range d { + d[i] = make([]int, len(s2)+1) + } + + // Initialize first column and row + for i := 0; i <= len(s1); i++ { + d[i][0] = i + } + for j := 0; j <= len(s2); j++ { + d[0][j] = j + } + + // Fill the matrix + for i := 1; i <= len(s1); i++ { + for j := 1; j <= len(s2); j++ { + cost := 0 + if s1[i-1] != s2[j-1] { + cost = 1 + } + + d[i][j] = min( + d[i-1][j]+1, // deletion + d[i][j-1]+1, // insertion + d[i-1][j-1]+cost, // substitution + ) + } + } + + return d[len(s1)][len(s2)] +} + +func min(a, b, c int) int { + if a < b { + if a < c { + return a + } + return c + } + if b < c { + return b + } + return c +} diff --git a/services/data-manager/service/professor/routes.go b/services/data-manager/service/professor/routes.go new file mode 100644 index 0000000..0494bf8 --- /dev/null +++ b/services/data-manager/service/professor/routes.go @@ -0,0 +1,29 @@ +package professor + +import ( + "net/http" + + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" +) + +func RegisterRoutes(se *core.ServeEvent, service *ProfessorService) { + se.Router.GET("/api/professor/modules", func(e *core.RequestEvent) error { + record := e.Auth + if record == nil { + return apis.NewForbiddenError("Only authenticated users can access this endpoint", nil) + } + + email := record.GetString("email") + if email == "" { + return apis.NewBadRequestError("User has no email", nil) + } + + modules, err := service.GetModulesForProfessor(email) + if err != nil { + return apis.NewBadRequestError("Failed to fetch modules", err) + } + + return e.JSON(http.StatusOK, modules) + }).Bind(apis.RequireAuth()) +}