feat: first test implementation for substitution system
This commit is contained in:
parent
8fe3d71dde
commit
3e2b6d46e6
11 changed files with 1048 additions and 249 deletions
|
|
@ -99,6 +99,17 @@ func createTables(db *sql.DB) {
|
|||
is_active BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS substitutions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
taken_by_user_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(taken_by_user_id) REFERENCES users(id)
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
|
|
@ -114,6 +125,7 @@ func createIndexes(db *sql.DB) {
|
|||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)",
|
||||
`CREATE INDEX IF NOT EXISTS idx_substitutions_date ON substitutions(date)`,
|
||||
}
|
||||
for _, idx := range indexes {
|
||||
db.Exec(idx)
|
||||
|
|
@ -577,15 +589,16 @@ func CreateManualTimeEntry(db *sql.DB, entry *TimeEntry, hours float64) error {
|
|||
}
|
||||
|
||||
func calculateHours(entry TimeEntry) float64 {
|
||||
if entry.Type == "lesson" {
|
||||
switch entry.Type {
|
||||
case "lesson":
|
||||
return 1.0
|
||||
} else if entry.Type == "manual" {
|
||||
case "manual":
|
||||
hours, err := strconv.ParseFloat(entry.StartTime, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return hours
|
||||
} else {
|
||||
default:
|
||||
return calculateHoursDiff(entry.StartTime, entry.EndTime)
|
||||
}
|
||||
}
|
||||
|
|
@ -633,3 +646,107 @@ func DeleteNonManualTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, w
|
|||
_, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4])
|
||||
return err
|
||||
}
|
||||
|
||||
func CreateSubstitution(db *sql.DB, date, start, end, title, notes string) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO substitutions (date, start_time, end_time, title, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, date, start, end, title, notes)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetOpenSubstitutions(db *sql.DB) ([]Substitution, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, date, start_time, end_time, title, notes, created_at
|
||||
FROM substitutions
|
||||
WHERE taken_by_user_id IS NULL
|
||||
AND date >= date('now')
|
||||
ORDER BY date ASC, start_time ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subs []Substitution
|
||||
for rows.Next() {
|
||||
var s Substitution
|
||||
if err := rows.Scan(&s.ID, &s.Date, &s.StartTime, &s.EndTime, &s.Title, &s.Notes, &s.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, s)
|
||||
}
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
func AcceptSubstitution(db *sql.DB, substitutionID int, userID int) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var currentDate, start, end, title string
|
||||
err = tx.QueryRow(`
|
||||
SELECT date, start_time, end_time, title
|
||||
FROM substitutions
|
||||
WHERE id = ? AND taken_by_user_id IS NULL
|
||||
`, substitutionID).Scan(¤tDate, &start, &end, &title)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("Vertretung wurde bereits vergeben oder existiert nicht")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
UPDATE substitutions
|
||||
SET taken_by_user_id = ?
|
||||
WHERE id = ?
|
||||
`, userID, substitutionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time)
|
||||
VALUES (?, 0, ?, 'lesson', ?, ?)
|
||||
`, userID, currentDate, start, end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func GetAllSubstitutions(db *sql.DB) ([]Substitution, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT s.id, s.date, s.start_time, s.end_time, s.title, s.notes, s.taken_by_user_id, u.username
|
||||
FROM substitutions s
|
||||
LEFT JOIN users u ON s.taken_by_user_id = u.id
|
||||
ORDER BY s.date DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subs []Substitution
|
||||
for rows.Next() {
|
||||
var s Substitution
|
||||
var takenID sql.NullInt64
|
||||
var takenName sql.NullString
|
||||
|
||||
if err := rows.Scan(&s.ID, &s.Date, &s.StartTime, &s.EndTime, &s.Title, &s.Notes, &takenID, &takenName); err != nil {
|
||||
continue
|
||||
}
|
||||
if takenID.Valid {
|
||||
id := int(takenID.Int64)
|
||||
s.TakenByUserID = &id
|
||||
s.TakenByUsername = takenName.String
|
||||
}
|
||||
subs = append(subs, s)
|
||||
}
|
||||
return subs, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ module school-timetracker
|
|||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/labstack/echo-jwt/v4 v4.3.1
|
||||
github.com/labstack/echo/v4 v4.13.4
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/time v0.11.0
|
||||
|
|
@ -12,9 +14,7 @@ require (
|
|||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/labstack/echo-jwt/v4 v4.3.1 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
|
|
|||
|
|
@ -71,6 +71,16 @@ func (app *App) LoginHandler(c echo.Context) error {
|
|||
return HandleError(c, ErrInvalidCredentialsMsg())
|
||||
}
|
||||
|
||||
if !user.IsAdmin {
|
||||
_, err := VerifyLicenseFile()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{
|
||||
"error": "Lizenzfehler: " + err.Error(),
|
||||
"code": "LICENSE_INVALID",
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
token, err := createToken(user.ID, user.Username, user.IsAdmin)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
|
|
@ -799,3 +809,106 @@ func (app *App) UploadLogoHandler(c echo.Context) error {
|
|||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Logo erfolgreich hochgeladen"})
|
||||
}
|
||||
|
||||
func (app *App) GetLicenseStatusHandler(c echo.Context) error {
|
||||
var count int
|
||||
app.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
|
||||
status := GetCurrentLicenseStatus(nil)
|
||||
status.UserCount = count
|
||||
|
||||
if status.IsValid && status.MaxUsers > 0 && count > status.MaxUsers {
|
||||
status.IsValid = false
|
||||
status.Message = fmt.Sprintf("Benutzerlimit überschritten (%d / %d)", count, status.MaxUsers)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (app *App) UploadLicenseHandler(c echo.Context) error {
|
||||
file, err := c.FormFile("license")
|
||||
if err != nil {
|
||||
return HandleError(c, ErrInvalidInputMsg("Keine Datei"))
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.Create("license.lic")
|
||||
if err != nil {
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err = io.Copy(dst, src); err != nil {
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
}
|
||||
|
||||
if _, err := VerifyLicenseFile(); err != nil {
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Lizenz hochgeladen, aber ungültig: " + err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Lizenz erfolgreich aktiviert"})
|
||||
}
|
||||
|
||||
func (app *App) CreateSubstitutionHandler(c echo.Context) error {
|
||||
var req CreateSubstitutionRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return HandleError(c, ErrInvalidInputMsg("Eingabedaten"))
|
||||
}
|
||||
if req.Date == "" || req.StartTime == "" || req.Title == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Pflichtfelder"))
|
||||
}
|
||||
|
||||
if err := CreateSubstitution(app.DB, req.Date, req.StartTime, req.EndTime, req.Title, req.Notes); err != nil {
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]string{"message": "Vertretung ausgeschrieben"})
|
||||
}
|
||||
|
||||
func (app *App) GetAllSubstitutionsHandler(c echo.Context) error {
|
||||
subs, err := GetAllSubstitutions(app.DB)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
if subs == nil {
|
||||
subs = []Substitution{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
func (app *App) GetOpenSubstitutionsHandler(c echo.Context) error {
|
||||
subs, err := GetOpenSubstitutions(app.DB)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
if subs == nil {
|
||||
subs = []Substitution{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
func (app *App) AcceptSubstitutionHandler(c echo.Context) error {
|
||||
claims, err := getClaims(c)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrUnauthorizedMsg())
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return HandleError(c, ErrInvalidInputMsg("ID"))
|
||||
}
|
||||
|
||||
if err := AcceptSubstitution(app.DB, id, claims.UserID); err != nil {
|
||||
if err.Error() == "Vertretung wurde bereits vergeben oder existiert nicht" {
|
||||
return HandleError(c, ErrAlreadyExistsMsg("Diese Vertretung ist leider schon vergeben"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Vertretung erfolgreich übernommen!"})
|
||||
}
|
||||
|
|
|
|||
72
backend/license.go
Normal file
72
backend/license.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const PublicKeyHex = "ab8287380d4f26b66b3e8067e179a2e304dcb3c4070963edd213cca9b225978f"
|
||||
|
||||
func VerifyLicenseFile() (*LicenseData, error) {
|
||||
bytes, err := os.ReadFile("license.lic")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Keine Lizenzdatei gefunden")
|
||||
}
|
||||
|
||||
var lic LicenseFile
|
||||
if err := json.Unmarshal(bytes, &lic); err != nil {
|
||||
return nil, fmt.Errorf("Lizenzdatei beschädigt")
|
||||
}
|
||||
|
||||
pubBytes, _ := hex.DecodeString(PublicKeyHex)
|
||||
publicKey := ed25519.PublicKey(pubBytes)
|
||||
|
||||
dataBytes, _ := json.Marshal(lic.Data)
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(lic.Signature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Signatur ungültig")
|
||||
}
|
||||
|
||||
if !ed25519.Verify(publicKey, dataBytes, sigBytes) {
|
||||
return nil, fmt.Errorf("Lizenz-Signatur ungültig (Manipuliert?)")
|
||||
}
|
||||
|
||||
expiry, err := time.Parse("2006-01-02", lic.Data.ExpiresAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Ungültiges Datumsformat")
|
||||
}
|
||||
if time.Now().After(expiry) {
|
||||
return &lic.Data, fmt.Errorf("Lizenz abgelaufen am %s", lic.Data.ExpiresAt)
|
||||
}
|
||||
|
||||
return &lic.Data, nil
|
||||
}
|
||||
|
||||
func GetCurrentLicenseStatus(db *any) LicenseStatus {
|
||||
lic, err := VerifyLicenseFile()
|
||||
status := LicenseStatus{
|
||||
IsValid: err == nil,
|
||||
Message: "Gültig",
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
status.Message = err.Error()
|
||||
if lic != nil {
|
||||
status.SchoolName = lic.SchoolName
|
||||
status.ExpiresAt = lic.ExpiresAt
|
||||
status.MaxUsers = lic.MaxUsers
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
status.SchoolName = lic.SchoolName
|
||||
status.ExpiresAt = lic.ExpiresAt
|
||||
status.MaxUsers = lic.MaxUsers
|
||||
|
||||
return status
|
||||
}
|
||||
|
|
@ -70,6 +70,8 @@ func main() {
|
|||
protected.GET("/my-info", app.GetMyInfoHandler)
|
||||
protected.POST("/change-password", app.ChangeMyPasswordHandler)
|
||||
protected.GET("/school-year/active", app.GetActiveSchoolYearHandler)
|
||||
protected.GET("/substitutions/open", app.GetOpenSubstitutionsHandler)
|
||||
protected.POST("/substitutions/:id/accept", app.AcceptSubstitutionHandler)
|
||||
}
|
||||
|
||||
admin := e.Group("/api/admin")
|
||||
|
|
@ -94,6 +96,10 @@ func main() {
|
|||
admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler)
|
||||
admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler)
|
||||
admin.POST("/settings/logo", app.UploadLogoHandler)
|
||||
admin.GET("/settings/license", app.GetLicenseStatusHandler)
|
||||
admin.POST("/settings/license", app.UploadLicenseHandler)
|
||||
admin.GET("/substitutions", app.GetAllSubstitutionsHandler)
|
||||
admin.POST("/substitutions", app.CreateSubstitutionHandler)
|
||||
}
|
||||
|
||||
distDir, err := fs.Sub(frontendDist, "dist")
|
||||
|
|
|
|||
|
|
@ -106,3 +106,43 @@ type ChangePasswordRequest struct {
|
|||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
type LicenseData struct {
|
||||
SchoolName string `json:"school_name"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type LicenseFile struct {
|
||||
Data LicenseData `json:"data"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
type LicenseStatus struct {
|
||||
IsValid bool `json:"is_valid"`
|
||||
SchoolName string `json:"school_name"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
UserCount int `json:"user_count"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type Substitution struct {
|
||||
ID int `json:"id"`
|
||||
Date string `json:"date"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Title string `json:"title"`
|
||||
Notes string `json:"notes"`
|
||||
TakenByUserID *int `json:"taken_by_user_id,omitempty"`
|
||||
TakenByUsername string `json:"taken_by_username,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateSubstitutionRequest struct {
|
||||
Date string `json:"date" validate:"required"`
|
||||
StartTime string `json:"start_time" validate:"required"`
|
||||
EndTime string `json:"end_time" validate:"required"`
|
||||
Title string `json:"title" validate:"required"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue