Add basic web app and API integration

This commit is contained in:
Jan Meinl
2026-05-16 20:39:27 +02:00
commit 802906f9d4
33 changed files with 4340 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/penalty-tracker
/penaltytracker.db
/penaltytracker.db-shm
/penaltytracker.db-wal
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CsvFileAttributes">
<option name="attributeMap">
<map>
<entry key="/README.md">
<value>
<Attribute>
<option name="separator" value="," />
</Attribute>
</value>
</entry>
<entry key="/rules/de.csv">
<value>
<Attribute>
<option name="separator" value="," />
</Attribute>
</value>
</entry>
<entry key="/rules/en.csv">
<value>
<Attribute>
<option name="separator" value="," />
</Attribute>
</value>
</entry>
<entry key="/rules/es.csv">
<value>
<Attribute>
<option name="separator" value="," />
</Attribute>
</value>
</entry>
<entry key="/rules/fr.csv">
<value>
<Attribute>
<option name="separator" value="," />
</Attribute>
</value>
</entry>
<entry key="/rules/pl.csv">
<value>
<Attribute>
<option name="separator" value="," />
</Attribute>
</value>
</entry>
<entry key="/rules/pt.csv">
<value>
<Attribute>
<option name="separator" value="," />
</Attribute>
</value>
</entry>
<entry key="/rules/ru.csv">
<value>
<Attribute>
<option name="separator" value="," />
</Attribute>
</value>
</entry>
</map>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/PenaltyTracker.iml" filepath="$PROJECT_DIR$/.idea/PenaltyTracker.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+84
View File
@@ -0,0 +1,84 @@
# Penalty Tracker
A small, self-contained system for tracking competition penalties (e.g. for hot-air balloon competitions).
## Features
- **System Admin** can create competitions and manage all users.
- **Chief Scorer** (per competition) manages pilots, members, settings, and can export penalties as CSV. Chief Scorers can promote others to Chief Scorer or Scorer.
- **Scorer** (per competition) adds penalties. Scorers can only edit/delete their own entries unless the competition setting "allow any scorer to edit" is on.
- Pilots can be added manually or imported from CSV (`number,last_name,first_name,country,balloon_id`).
- Rule catalogues per language are loaded from `rules/*.csv`. The penalty form lets you search rules by number or text and shows the suggested penalty plus repeat-behaviour (same / doubled / escalates).
- All open clients receive **live updates** for a competition via WebSocket — works for 30+ concurrent users on a single SQLite process.
- The penalty table is **client-side sortable** by every column and updates **in-place without flicker** when new rows arrive.
- Multi-lingual UI: English, German, Polish, Russian, French, Spanish, Portuguese (set per user in user settings).
- Clean black / white design with `#2b6cb0` accent, max border-radius `0.375rem`.
## Build & run
```bash
go build -o penaltytracker .
./penaltytracker
```
Then open <http://localhost:8080>.
**Default login (created automatically on first start):**
```
username: admin
password: admin
```
Change the password immediately under the user menu.
## Configuration (env vars)
| Variable | Default | Description |
|-----------------|----------------------|--------------------------------------------------------------------------|
| `ADDR` | `:8080` | HTTP listen address |
| `DB_PATH` | `penaltytracker.db` | SQLite database path |
| `RULES_DIR` | `rules` | Directory containing per-language rule CSV files |
| `WEB_DIR` | `web` | Directory containing the frontend static files |
| `CORS_ORIGINS` | *(empty)* | Comma-separated allowed frontend origins (e.g. `http://127.0.0.1:36823,https://app.example.com`). When set, CORS headers are added. Use `*` to allow any origin. |
| `COOKIE_CROSS_SITE` | *(empty)* | Set to `true` only when the frontend runs on a different *site* (different registrable domain) than the API and you need cross-site cookies. Switches the session cookie to `SameSite=None; Secure` — requires HTTPS on both sides. Leave unset for same-host different-port setups (e.g. `127.0.0.1:36823 → 127.0.0.1:8080`); `SameSite=Lax` already works there. |
### Frontend backend URL
If you host the frontend on a different origin than the API, open the login screen and fill in **Backend URL** (e.g. `http://192.168.0.10:8080` or `https://api.example.com`). It is stored in `localStorage` (`api_base`). Leave it empty to use the same origin as the page. You can also pre-set it by editing `web/config.js` (`DEFAULT_API_BASE`).
Cross-origin cookies require **HTTPS on both sides** (so the `Secure` cookie flag is accepted). For LAN testing on plain HTTP, host the frontend and backend on the same origin (the default).
## Rules CSV format
`rules/<lang>.csv` — one file per language code (`en`, `de`, `pl`, `ru`, `fr`, `es`, `pt`).
```csv
rule_number,rule_text,suggested_penalty,escalation_mode
R1.1,Late at briefing,Warning,escalate:Warning|50 CP|100 CP|DSQ
R3.1,Unsafe flying,200 CP,doubled
R4.2,Incorrect declaration,No Result,same
```
`escalation_mode`:
- `same` — penalty stays the same on repeat.
- `doubled` — penalty doubles each time.
- `escalate:tier1|tier2|tier3|...` — penalty climbs through the listed tiers.
To reload rules without restart, POST `/api/rules/reload` as a system admin.
## Pilot import CSV format
```csv
number,last_name,first_name,country,balloon_id
1,Doe,John,DE,D-OABC
2,Müller,Anna,DE,
```
Header row is optional and auto-detected.
## Notes
- Storage is a single SQLite file in WAL mode — easy to back up by copying the `.db` file (along with `-wal` / `-shm` if present) while the server is briefly stopped.
- The frontend is pure HTML/CSS/JS, no build step.
- The Go binary embeds nothing — it serves `./web` and `./rules` from the working directory.
+256
View File
@@ -0,0 +1,256 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
type ctxKey string
const userCtxKey ctxKey = "user"
const sessionCookie = "pt_session"
const sessionDuration = 24 * time.Hour * 14
func registerAuthRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/login", handleLogin)
mux.HandleFunc("POST /api/logout", handleLogout)
mux.HandleFunc("GET /api/me", requireAuth(handleMe))
mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe))
}
func ensureDefaultAdmin() error {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = db.Exec(
"INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,1)",
"admin", string(hash), "System Admin", "en",
)
return err
}
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "missing_credentials")
return
}
var id int64
var hash string
err := db.QueryRow("SELECT id,password_hash FROM users WHERE username=?", req.Username).Scan(&id, &hash)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid_credentials")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
writeError(w, http.StatusUnauthorized, "invalid_credentials")
return
}
token, err := newToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "token_error")
return
}
expires := time.Now().Add(sessionDuration)
if _, err := db.Exec("INSERT INTO sessions(token,user_id,expires_at) VALUES(?,?,?)", token, id, expires.Format(time.RFC3339)); err != nil {
writeError(w, http.StatusInternalServerError, "session_error")
return
}
cookie := &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
Expires: expires,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
if crossSiteCookies {
cookie.SameSite = http.SameSiteNoneMode
cookie.Secure = true
}
http.SetCookie(w, cookie)
user, _ := loadUser(id)
writeJSON(w, http.StatusOK, user)
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err == nil {
db.Exec("DELETE FROM sessions WHERE token=?", c.Value)
}
clear := &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
if crossSiteCookies {
clear.SameSite = http.SameSiteNoneMode
clear.Secure = true
}
http.SetCookie(w, clear)
w.WriteHeader(http.StatusNoContent)
}
func handleMe(w http.ResponseWriter, r *http.Request) {
u := userFromCtx(r)
writeJSON(w, http.StatusOK, u)
}
func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
u := userFromCtx(r)
var req struct {
Username *string `json:"username"`
Language *string `json:"language"`
DisplayName *string `json:"display_name"`
Password *string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
if req.Username != nil {
newName := strings.TrimSpace(*req.Username)
if newName == "" {
writeError(w, http.StatusBadRequest, "missing_username")
return
}
if _, err := db.Exec("UPDATE users SET username=? WHERE id=?", newName, u.ID); err != nil {
writeError(w, http.StatusConflict, "username_taken")
return
}
}
if req.Language != nil {
if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", *req.Language, u.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
}
if req.DisplayName != nil {
if _, err := db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, u.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
}
if req.Password != nil && *req.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, "hash_error")
return
}
if _, err := db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), u.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
}
user, _ := loadUser(u.ID)
writeJSON(w, http.StatusOK, user)
}
func loadUser(id int64) (*User, error) {
u := &User{}
var admin int
err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin FROM users WHERE id=?", id).
Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin)
if err != nil {
return nil, err
}
u.IsSystemAdmin = admin == 1
return u, nil
}
func authUser(r *http.Request) (*User, error) {
c, err := r.Cookie(sessionCookie)
if err != nil {
return nil, errors.New("no_session")
}
var userID int64
var expiresAt string
err = db.QueryRow("SELECT user_id, expires_at FROM sessions WHERE token=?", c.Value).Scan(&userID, &expiresAt)
if err != nil {
return nil, errors.New("invalid_session")
}
if t, err := time.Parse(time.RFC3339, expiresAt); err == nil && time.Now().After(t) {
db.Exec("DELETE FROM sessions WHERE token=?", c.Value)
return nil, errors.New("expired")
}
return loadUser(userID)
}
func requireAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, err := authUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
ctx := context.WithValue(r.Context(), userCtxKey, u)
h.ServeHTTP(w, r.WithContext(ctx))
}
}
func requireAdmin(h http.HandlerFunc) http.HandlerFunc {
return requireAuth(func(w http.ResponseWriter, r *http.Request) {
u := userFromCtx(r)
if !u.IsSystemAdmin {
writeError(w, http.StatusForbidden, "forbidden")
return
}
h.ServeHTTP(w, r)
})
}
func userFromCtx(r *http.Request) *User {
v := r.Context().Value(userCtxKey)
if v == nil {
return nil
}
return v.(*User)
}
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if body != nil {
_ = json.NewEncoder(w).Encode(body)
}
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
+248
View File
@@ -0,0 +1,248 @@
package main
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
)
func registerCompetitionRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/competitions", requireAuth(handleListCompetitions))
mux.HandleFunc("POST /api/competitions", requireAdmin(handleCreateCompetition))
mux.HandleFunc("GET /api/competitions/{id}", requireAuth(handleGetCompetition))
mux.HandleFunc("PATCH /api/competitions/{id}", requireAuth(handleUpdateCompetition))
mux.HandleFunc("DELETE /api/competitions/{id}", requireAdmin(handleDeleteCompetition))
mux.HandleFunc("GET /api/competitions/{id}/members", requireAuth(handleListMembers))
mux.HandleFunc("POST /api/competitions/{id}/members", requireAuth(handleAddMember))
mux.HandleFunc("DELETE /api/competitions/{id}/members/{uid}", requireAuth(handleRemoveMember))
}
func userRole(competitionID, userID int64) (string, error) {
var role string
err := db.QueryRow("SELECT role FROM competition_users WHERE competition_id=? AND user_id=?", competitionID, userID).Scan(&role)
if err == sql.ErrNoRows {
return "", nil
}
return role, err
}
func canAccessCompetition(u *User, competitionID int64) (string, bool) {
if u.IsSystemAdmin {
return "system_admin", true
}
role, err := userRole(competitionID, u.ID)
if err != nil || role == "" {
return "", false
}
return role, true
}
func handleListCompetitions(w http.ResponseWriter, r *http.Request) {
u := userFromCtx(r)
var rows *sql.Rows
var err error
if u.IsSystemAdmin {
rows, err = db.Query("SELECT id,name,allow_any_scorer_edit,created_at FROM competitions ORDER BY created_at DESC")
} else {
rows, err = db.Query(`SELECT c.id,c.name,c.allow_any_scorer_edit,c.created_at,cu.role
FROM competitions c JOIN competition_users cu ON cu.competition_id=c.id
WHERE cu.user_id=? ORDER BY c.created_at DESC`, u.ID)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
defer rows.Close()
out := []Competition{}
for rows.Next() {
var c Competition
var allow int
if u.IsSystemAdmin {
rows.Scan(&c.ID, &c.Name, &allow, &c.CreatedAt)
c.Role = "system_admin"
} else {
rows.Scan(&c.ID, &c.Name, &allow, &c.CreatedAt, &c.Role)
}
c.AllowAnyScorerEdit = allow == 1
out = append(out, c)
}
writeJSON(w, http.StatusOK, out)
}
func handleCreateCompetition(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "missing_name")
return
}
allow := 0
if req.AllowAnyScorerEdit {
allow = 1
}
res, err := db.Exec("INSERT INTO competitions(name,allow_any_scorer_edit) VALUES(?,?)", req.Name, allow)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
id, _ := res.LastInsertId()
c := Competition{ID: id, Name: req.Name, AllowAnyScorerEdit: req.AllowAnyScorerEdit}
writeJSON(w, http.StatusCreated, c)
}
func handleGetCompetition(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
role, ok := canAccessCompetition(u, id)
if !ok {
writeError(w, http.StatusForbidden, "forbidden")
return
}
var c Competition
var allow int
err = db.QueryRow("SELECT id,name,allow_any_scorer_edit,created_at FROM competitions WHERE id=?", id).
Scan(&c.ID, &c.Name, &allow, &c.CreatedAt)
if err != nil {
writeError(w, http.StatusNotFound, "not_found")
return
}
c.AllowAnyScorerEdit = allow == 1
c.Role = role
writeJSON(w, http.StatusOK, c)
}
func handleUpdateCompetition(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
role, ok := canAccessCompetition(u, id)
if !ok || (role != "system_admin" && role != "chief_scorer") {
writeError(w, http.StatusForbidden, "forbidden")
return
}
var req struct {
Name *string `json:"name"`
AllowAnyScorerEdit *bool `json:"allow_any_scorer_edit"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
if req.Name != nil {
db.Exec("UPDATE competitions SET name=? WHERE id=?", *req.Name, id)
}
if req.AllowAnyScorerEdit != nil {
v := 0
if *req.AllowAnyScorerEdit {
v = 1
}
db.Exec("UPDATE competitions SET allow_any_scorer_edit=? WHERE id=?", v, id)
}
hub.broadcast(id, "competition_updated", nil)
w.WriteHeader(http.StatusNoContent)
}
func handleDeleteCompetition(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
db.Exec("DELETE FROM competitions WHERE id=?", id)
w.WriteHeader(http.StatusNoContent)
}
func handleListMembers(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
if _, ok := canAccessCompetition(u, id); !ok {
writeError(w, http.StatusForbidden, "forbidden")
return
}
rows, err := db.Query(`SELECT u.id,u.username,u.display_name,cu.role
FROM competition_users cu JOIN users u ON u.id=cu.user_id
WHERE cu.competition_id=? ORDER BY cu.role,u.username`, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
defer rows.Close()
out := []CompetitionUser{}
for rows.Next() {
var m CompetitionUser
rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role)
out = append(out, m)
}
writeJSON(w, http.StatusOK, out)
}
func handleAddMember(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
role, ok := canAccessCompetition(u, id)
if !ok || (role != "system_admin" && role != "chief_scorer") {
writeError(w, http.StatusForbidden, "forbidden")
return
}
var req struct {
UserID int64 `json:"user_id"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
if req.Role != "chief_scorer" && req.Role != "scorer" {
writeError(w, http.StatusBadRequest, "invalid_role")
return
}
_, err = db.Exec("INSERT OR REPLACE INTO competition_users(competition_id,user_id,role) VALUES(?,?,?)", id, req.UserID, req.Role)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
w.WriteHeader(http.StatusNoContent)
}
func handleRemoveMember(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
uid, err := strconv.ParseInt(r.PathValue("uid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
role, ok := canAccessCompetition(u, id)
if !ok || (role != "system_admin" && role != "chief_scorer") {
writeError(w, http.StatusForbidden, "forbidden")
return
}
db.Exec("DELETE FROM competition_users WHERE competition_id=? AND user_id=?", id, uid)
w.WriteHeader(http.StatusNoContent)
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"database/sql"
_ "modernc.org/sqlite"
)
var db *sql.DB
func openDB(path string) error {
d, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
if err != nil {
return err
}
d.SetMaxOpenConns(1)
if err := d.Ping(); err != nil {
return err
}
db = d
return nil
}
func migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT 'en',
is_system_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS competitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
allow_any_scorer_edit INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS competition_users (
competition_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
PRIMARY KEY (competition_id, user_id),
FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS pilots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
competition_id INTEGER NOT NULL,
number TEXT NOT NULL,
last_name TEXT NOT NULL,
first_name TEXT NOT NULL,
country TEXT NOT NULL DEFAULT '',
balloon_id TEXT NOT NULL DEFAULT '',
UNIQUE(competition_id, number),
FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS penalties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
competition_id INTEGER NOT NULL,
flight TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL DEFAULT '',
pilot_number TEXT NOT NULL DEFAULT '',
rule_number TEXT NOT NULL DEFAULT '',
task TEXT NOT NULL DEFAULT '',
penalties_text TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
created_by INTEGER NOT NULL,
transferred INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE,
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE RESTRICT
)`,
`CREATE INDEX IF NOT EXISTS idx_penalties_competition ON penalties(competition_id)`,
`CREATE INDEX IF NOT EXISTS idx_pilots_competition ON pilots(competition_id)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
return err
}
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
module penaltytracker
go 1.25.0
require (
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.51.0
modernc.org/sqlite v1.50.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/gc/v3 v3.1.3 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/strutil v1.2.1 // indirect
modernc.org/token v1.1.0 // indirect
)
+34
View File
@@ -0,0 +1,34 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+213
View File
@@ -0,0 +1,213 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
type Config struct {
Addr string `json:"addr"`
DBPath string `json:"db_path"`
RulesDir string `json:"rules_dir"`
CORSOrigins []string `json:"cors_origins"`
CrossSiteCookies bool `json:"cross_site_cookies"`
}
var corsOrigins []string
var crossSiteCookies bool
func defaultConfig() *Config {
return &Config{
Addr: ":8080",
DBPath: "penaltytracker.db",
RulesDir: "rules",
CORSOrigins: []string{},
CrossSiteCookies: false,
}
}
func writeConfig(path string, cfg *Config) error {
if dir := filepath.Dir(path); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func loadConfig(path string) (*Config, error) {
cfg := defaultConfig()
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
if err := writeConfig(path, cfg); err != nil {
return nil, err
}
log.Printf("config file created at %s with defaults", path)
return cfg, nil
}
return nil, err
}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
if cfg.Addr == "" {
cfg.Addr = ":8080"
}
if cfg.DBPath == "" {
cfg.DBPath = "penaltytracker.db"
}
if cfg.RulesDir == "" {
cfg.RulesDir = "rules"
}
for i, o := range cfg.CORSOrigins {
cfg.CORSOrigins[i] = strings.TrimSpace(o)
}
return cfg, nil
}
func ensureDir(dir string) error {
if dir == "" || dir == "." {
return nil
}
return os.MkdirAll(dir, 0o755)
}
func ensureDirectories(cfg *Config) error {
if err := ensureDir(cfg.RulesDir); err != nil {
return err
}
if dbDir := filepath.Dir(cfg.DBPath); dbDir != "" {
if err := ensureDir(dbDir); err != nil {
return err
}
}
return nil
}
func main() {
configPath := flag.String("config", "config.json", "path to config file")
flag.Parse()
cfg, err := loadConfig(*configPath)
if err != nil {
log.Fatalf("config load: %v", err)
}
if err := ensureDirectories(cfg); err != nil {
log.Fatalf("ensure directories: %v", err)
}
corsOrigins = cfg.CORSOrigins
crossSiteCookies = cfg.CrossSiteCookies
if err := openDB(cfg.DBPath); err != nil {
log.Fatalf("db open: %v", err)
}
if err := migrate(); err != nil {
log.Fatalf("migrate: %v", err)
}
if err := ensureDefaultAdmin(); err != nil {
log.Fatalf("default admin: %v", err)
}
if err := loadRules(cfg.RulesDir); err != nil {
log.Printf("rules load warning: %v", err)
}
hub = newHub()
go hub.run()
mux := http.NewServeMux()
registerAuthRoutes(mux)
registerUserRoutes(mux)
registerCompetitionRoutes(mux)
registerPilotRoutes(mux)
registerPenaltyRoutes(mux)
registerRuleRoutes(mux)
registerWSRoutes(mux)
server := &http.Server{
Addr: cfg.Addr,
Handler: withLog(withCORS(mux)),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("listening on %s (cors_origins=%v)", cfg.Addr, corsOrigins)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}
func withLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
func originAllowed(origin string) bool {
if origin == "" {
return false
}
for _, o := range corsOrigins {
if o == "*" {
return true
}
if strings.EqualFold(o, origin) {
return true
}
}
return false
}
func withCORS(next http.Handler) http.Handler {
if len(corsOrigins) == 0 {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if originAllowed(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS")
reqHeaders := r.Header.Get("Access-Control-Request-Headers")
if reqHeaders == "" {
reqHeaders = "Content-Type"
}
w.Header().Set("Access-Control-Allow-Headers", reqHeaders)
w.Header().Set("Access-Control-Max-Age", "600")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+60
View File
@@ -0,0 +1,60 @@
package main
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Language string `json:"language"`
IsSystemAdmin bool `json:"is_system_admin"`
}
type Competition struct {
ID int64 `json:"id"`
Name string `json:"name"`
AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"`
CreatedAt string `json:"created_at"`
Role string `json:"role,omitempty"`
}
type CompetitionUser struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
}
type Pilot struct {
ID int64 `json:"id"`
CompetitionID int64 `json:"competition_id"`
Number string `json:"number"`
LastName string `json:"last_name"`
FirstName string `json:"first_name"`
Country string `json:"country"`
BalloonID string `json:"balloon_id"`
}
type Penalty struct {
ID int64 `json:"id"`
CompetitionID int64 `json:"competition_id"`
Flight string `json:"flight"`
Date string `json:"date"`
PilotNumber string `json:"pilot_number"`
PilotName string `json:"pilot_name"`
RuleNumber string `json:"rule_number"`
Task string `json:"task"`
PenaltiesText string `json:"penalties_text"`
Description string `json:"description"`
CreatedBy int64 `json:"created_by"`
CreatedByName string `json:"created_by_name"`
Transferred bool `json:"transferred"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type Rule struct {
Number string `json:"number"`
Text string `json:"text"`
SuggestedPenalty string `json:"suggested_penalty"`
EscalationMode string `json:"escalation_mode"`
EscalationTiers []string `json:"escalation_tiers,omitempty"`
}
+293
View File
@@ -0,0 +1,293 @@
package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
)
func registerPenaltyRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/competitions/{id}/penalties", requireAuth(handleListPenalties))
mux.HandleFunc("POST /api/competitions/{id}/penalties", requireAuth(handleCreatePenalty))
mux.HandleFunc("PATCH /api/competitions/{id}/penalties/{pid}", requireAuth(handleUpdatePenalty))
mux.HandleFunc("DELETE /api/competitions/{id}/penalties/{pid}", requireAuth(handleDeletePenalty))
mux.HandleFunc("GET /api/competitions/{id}/penalties.csv", requireAuth(handleExportPenalties))
}
func loadPenalty(id int64) (*Penalty, error) {
row := db.QueryRow(`SELECT p.id,p.competition_id,p.flight,p.date,p.pilot_number,
COALESCE(pl.last_name || ', ' || pl.first_name, ''),
p.rule_number,p.task,p.penalties_text,p.description,p.created_by,
COALESCE(u.display_name, u.username),p.transferred,p.created_at,p.updated_at
FROM penalties p
LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number
LEFT JOIN users u ON u.id=p.created_by
WHERE p.id=?`, id)
var pen Penalty
var transferred int
err := row.Scan(&pen.ID, &pen.CompetitionID, &pen.Flight, &pen.Date, &pen.PilotNumber,
&pen.PilotName, &pen.RuleNumber, &pen.Task, &pen.PenaltiesText, &pen.Description,
&pen.CreatedBy, &pen.CreatedByName, &transferred, &pen.CreatedAt, &pen.UpdatedAt)
if err != nil {
return nil, err
}
pen.Transferred = transferred == 1
return &pen, nil
}
func handleListPenalties(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
if _, ok := canAccessCompetition(u, id); !ok {
writeError(w, http.StatusForbidden, "forbidden")
return
}
rows, err := db.Query(`SELECT p.id,p.competition_id,p.flight,p.date,p.pilot_number,
COALESCE(pl.last_name || ', ' || pl.first_name, ''),
p.rule_number,p.task,p.penalties_text,p.description,p.created_by,
COALESCE(u.display_name, u.username),p.transferred,p.created_at,p.updated_at
FROM penalties p
LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number
LEFT JOIN users u ON u.id=p.created_by
WHERE p.competition_id=? ORDER BY p.id DESC`, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
defer rows.Close()
out := []Penalty{}
for rows.Next() {
var pen Penalty
var transferred int
rows.Scan(&pen.ID, &pen.CompetitionID, &pen.Flight, &pen.Date, &pen.PilotNumber,
&pen.PilotName, &pen.RuleNumber, &pen.Task, &pen.PenaltiesText, &pen.Description,
&pen.CreatedBy, &pen.CreatedByName, &transferred, &pen.CreatedAt, &pen.UpdatedAt)
pen.Transferred = transferred == 1
out = append(out, pen)
}
writeJSON(w, http.StatusOK, out)
}
func handleCreatePenalty(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
role, ok := canAccessCompetition(u, id)
if !ok || (role != "scorer" && role != "chief_scorer" && role != "system_admin") {
writeError(w, http.StatusForbidden, "forbidden")
return
}
var pen Penalty
if err := json.NewDecoder(r.Body).Decode(&pen); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
transferred := 0
if pen.Transferred {
transferred = 1
}
res, err := db.Exec(`INSERT INTO penalties(competition_id,flight,date,pilot_number,rule_number,task,penalties_text,description,created_by,transferred)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
id, pen.Flight, pen.Date, pen.PilotNumber, pen.RuleNumber, pen.Task, pen.PenaltiesText, pen.Description, u.ID, transferred)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
pid, _ := res.LastInsertId()
created, err := loadPenalty(pid)
if err != nil {
writeError(w, http.StatusInternalServerError, "load_error")
return
}
hub.broadcast(id, "penalty_created", created)
writeJSON(w, http.StatusCreated, created)
}
func canEditPenalty(u *User, competitionID, createdBy int64) bool {
role, ok := canAccessCompetition(u, competitionID)
if !ok {
return false
}
if role == "system_admin" || role == "chief_scorer" {
return true
}
if role == "scorer" {
if u.ID == createdBy {
return true
}
var allow int
db.QueryRow("SELECT allow_any_scorer_edit FROM competitions WHERE id=?", competitionID).Scan(&allow)
return allow == 1
}
return false
}
func handleUpdatePenalty(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
var createdBy int64
if err := db.QueryRow("SELECT created_by FROM penalties WHERE id=? AND competition_id=?", pid, id).Scan(&createdBy); err != nil {
writeError(w, http.StatusNotFound, "not_found")
return
}
if !canEditPenalty(u, id, createdBy) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
var req struct {
Flight *string `json:"flight"`
Date *string `json:"date"`
PilotNumber *string `json:"pilot_number"`
RuleNumber *string `json:"rule_number"`
Task *string `json:"task"`
PenaltiesText *string `json:"penalties_text"`
Description *string `json:"description"`
Transferred *bool `json:"transferred"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
sets := []string{}
args := []any{}
if req.Flight != nil {
sets = append(sets, "flight=?")
args = append(args, *req.Flight)
}
if req.Date != nil {
sets = append(sets, "date=?")
args = append(args, *req.Date)
}
if req.PilotNumber != nil {
sets = append(sets, "pilot_number=?")
args = append(args, *req.PilotNumber)
}
if req.RuleNumber != nil {
sets = append(sets, "rule_number=?")
args = append(args, *req.RuleNumber)
}
if req.Task != nil {
sets = append(sets, "task=?")
args = append(args, *req.Task)
}
if req.PenaltiesText != nil {
sets = append(sets, "penalties_text=?")
args = append(args, *req.PenaltiesText)
}
if req.Description != nil {
sets = append(sets, "description=?")
args = append(args, *req.Description)
}
if req.Transferred != nil {
v := 0
if *req.Transferred {
v = 1
}
sets = append(sets, "transferred=?")
args = append(args, v)
}
if len(sets) > 0 {
sets = append(sets, "updated_at=datetime('now')")
args = append(args, pid)
query := "UPDATE penalties SET " + strings.Join(sets, ",") + " WHERE id=?"
if _, err := db.Exec(query, args...); err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
}
updated, err := loadPenalty(pid)
if err != nil {
writeError(w, http.StatusInternalServerError, "load_error")
return
}
hub.broadcast(id, "penalty_updated", updated)
writeJSON(w, http.StatusOK, updated)
}
func handleDeletePenalty(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
var createdBy int64
if err := db.QueryRow("SELECT created_by FROM penalties WHERE id=? AND competition_id=?", pid, id).Scan(&createdBy); err != nil {
writeError(w, http.StatusNotFound, "not_found")
return
}
if !canEditPenalty(u, id, createdBy) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
db.Exec("DELETE FROM penalties WHERE id=?", pid)
hub.broadcast(id, "penalty_deleted", map[string]int64{"id": pid})
w.WriteHeader(http.StatusNoContent)
}
func handleExportPenalties(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
role, ok := canAccessCompetition(u, id)
if !ok || (role != "chief_scorer" && role != "system_admin") {
writeError(w, http.StatusForbidden, "forbidden")
return
}
rows, err := db.Query(`SELECT p.id,p.flight,p.date,p.pilot_number,
COALESCE(pl.last_name || ', ' || pl.first_name, ''),
p.rule_number,p.task,p.penalties_text,p.description,
COALESCE(u.display_name, u.username),p.transferred,p.created_at
FROM penalties p
LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number
LEFT JOIN users u ON u.id=p.created_by
WHERE p.competition_id=? ORDER BY p.id`, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
defer rows.Close()
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"penalties_%d.csv\"", id))
cw := csv.NewWriter(w)
cw.Write([]string{"id", "flight", "date", "pilot_number", "pilot_name", "rule_number", "task", "penalties", "description", "created_by", "transferred", "created_at"})
for rows.Next() {
var idv int64
var flight, date, pnum, pname, rnum, task, pens, desc, creator, createdAt string
var transferred int
rows.Scan(&idv, &flight, &date, &pnum, &pname, &rnum, &task, &pens, &desc, &creator, &transferred, &createdAt)
t := "0"
if transferred == 1 {
t = "1"
}
cw.Write([]string{strconv.FormatInt(idv, 10), flight, date, pnum, pname, rnum, task, pens, desc, creator, t, createdAt})
}
cw.Flush()
}
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"encoding/csv"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
)
func registerPilotRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/competitions/{id}/pilots", requireAuth(handleListPilots))
mux.HandleFunc("POST /api/competitions/{id}/pilots", requireAuth(handleCreatePilot))
mux.HandleFunc("PATCH /api/competitions/{id}/pilots/{pid}", requireAuth(handleUpdatePilot))
mux.HandleFunc("DELETE /api/competitions/{id}/pilots/{pid}", requireAuth(handleDeletePilot))
mux.HandleFunc("POST /api/competitions/{id}/pilots/import", requireAuth(handleImportPilots))
}
func requireChiefOrAdmin(r *http.Request, competitionID int64) bool {
u := userFromCtx(r)
role, ok := canAccessCompetition(u, competitionID)
if !ok {
return false
}
return role == "system_admin" || role == "chief_scorer"
}
func handleListPilots(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
if _, ok := canAccessCompetition(u, id); !ok {
writeError(w, http.StatusForbidden, "forbidden")
return
}
rows, err := db.Query("SELECT id,competition_id,number,last_name,first_name,country,balloon_id FROM pilots WHERE competition_id=? ORDER BY number", id)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
defer rows.Close()
out := []Pilot{}
for rows.Next() {
var p Pilot
rows.Scan(&p.ID, &p.CompetitionID, &p.Number, &p.LastName, &p.FirstName, &p.Country, &p.BalloonID)
out = append(out, p)
}
writeJSON(w, http.StatusOK, out)
}
func handleCreatePilot(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
if !requireChiefOrAdmin(r, id) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
var p Pilot
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
p.Number = strings.TrimSpace(p.Number)
if p.Number == "" || p.LastName == "" {
writeError(w, http.StatusBadRequest, "missing_fields")
return
}
res, err := db.Exec(
"INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id) VALUES(?,?,?,?,?,?)",
id, p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID,
)
if err != nil {
writeError(w, http.StatusConflict, "duplicate_number")
return
}
p.ID, _ = res.LastInsertId()
p.CompetitionID = id
hub.broadcast(id, "pilot_changed", nil)
writeJSON(w, http.StatusCreated, p)
}
func handleUpdatePilot(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
if !requireChiefOrAdmin(r, id) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
var p Pilot
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
_, err = db.Exec(
"UPDATE pilots SET number=?,last_name=?,first_name=?,country=?,balloon_id=? WHERE id=? AND competition_id=?",
p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID, pid, id,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
hub.broadcast(id, "pilot_changed", nil)
w.WriteHeader(http.StatusNoContent)
}
func handleDeletePilot(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
if !requireChiefOrAdmin(r, id) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
db.Exec("DELETE FROM pilots WHERE id=? AND competition_id=?", pid, id)
hub.broadcast(id, "pilot_changed", nil)
w.WriteHeader(http.StatusNoContent)
}
func handleImportPilots(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
if !requireChiefOrAdmin(r, id) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "read_error")
return
}
reader := csv.NewReader(strings.NewReader(string(body)))
reader.FieldsPerRecord = -1
records, err := reader.ReadAll()
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_csv")
return
}
tx, err := db.Begin()
if err != nil {
writeError(w, http.StatusInternalServerError, "tx_error")
return
}
defer tx.Rollback()
stmt, _ := tx.Prepare(`INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id)
VALUES(?,?,?,?,?,?)
ON CONFLICT(competition_id,number) DO UPDATE SET
last_name=excluded.last_name, first_name=excluded.first_name,
country=excluded.country, balloon_id=excluded.balloon_id`)
defer stmt.Close()
count := 0
for i, rec := range records {
if i == 0 {
lower := strings.ToLower(strings.TrimSpace(rec[0]))
if lower == "number" || lower == "nummer" || lower == "no" {
continue
}
}
if len(rec) < 3 {
continue
}
number := strings.TrimSpace(rec[0])
lastName := strings.TrimSpace(rec[1])
firstName := strings.TrimSpace(rec[2])
country := ""
balloon := ""
if len(rec) >= 4 {
country = strings.TrimSpace(rec[3])
}
if len(rec) >= 5 {
balloon = strings.TrimSpace(rec[4])
}
if number == "" {
continue
}
if _, err := stmt.Exec(id, number, lastName, firstName, country, balloon); err == nil {
count++
}
}
if err := tx.Commit(); err != nil {
writeError(w, http.StatusInternalServerError, "commit_error")
return
}
hub.broadcast(id, "pilot_changed", nil)
writeJSON(w, http.StatusOK, map[string]int{"imported": count})
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"encoding/csv"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
)
var (
rulesMu sync.RWMutex
rules = map[string]map[string]Rule{}
)
func loadRules(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
out := map[string]map[string]Rule{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".csv") {
continue
}
lang := strings.TrimSuffix(e.Name(), ".csv")
f, err := os.Open(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
r := csv.NewReader(f)
r.FieldsPerRecord = -1
records, err := r.ReadAll()
f.Close()
if err != nil {
continue
}
langMap := map[string]Rule{}
for i, rec := range records {
if len(rec) < 4 {
continue
}
if i == 0 {
lower := strings.ToLower(strings.TrimSpace(rec[0]))
if lower == "number" || lower == "rule_number" || lower == "rule" {
continue
}
}
ru := Rule{
Number: strings.TrimSpace(rec[0]),
Text: strings.TrimSpace(rec[1]),
SuggestedPenalty: strings.TrimSpace(rec[2]),
EscalationMode: strings.TrimSpace(rec[3]),
}
if ru.EscalationMode == "" {
ru.EscalationMode = "same"
}
if strings.HasPrefix(ru.EscalationMode, "escalate:") {
tiers := strings.Split(strings.TrimPrefix(ru.EscalationMode, "escalate:"), "|")
for j, t := range tiers {
tiers[j] = strings.TrimSpace(t)
}
ru.EscalationTiers = tiers
ru.EscalationMode = "escalate"
}
langMap[ru.Number] = ru
}
out[lang] = langMap
}
rulesMu.Lock()
rules = out
rulesMu.Unlock()
return nil
}
func getRules(lang string) []Rule {
rulesMu.RLock()
defer rulesMu.RUnlock()
m, ok := rules[lang]
if !ok {
m = rules["en"]
}
out := make([]Rule, 0, len(m))
for _, r := range m {
out = append(out, r)
}
return out
}
func registerRuleRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/rules", requireAuth(handleListRules))
mux.HandleFunc("POST /api/rules/reload", requireAdmin(handleReloadRules))
}
func handleListRules(w http.ResponseWriter, r *http.Request) {
lang := r.URL.Query().Get("lang")
if lang == "" {
u := userFromCtx(r)
lang = u.Language
}
writeJSON(w, http.StatusOK, getRules(lang))
}
func handleReloadRules(w http.ResponseWriter, r *http.Request) {
dir := os.Getenv("RULES_DIR")
if dir == "" {
dir = "rules"
}
if err := loadRules(dir); err != nil {
writeError(w, http.StatusInternalServerError, "load_error")
return
}
w.WriteHeader(http.StatusNoContent)
}
+44
View File
@@ -0,0 +1,44 @@
rule_number,rule_text,suggested_penalty,escalation_mode
R3.8,Wettbewerbsnummer,,same
R3.8 Wrng.,Wettbewerbsnummer - Warnung,Warning,same
R6.3.2,FRF verspätet,bis zu 100 TP,same
R6.3.2 Wrng.,FRF verspätet - Warnung,Warning,same
R7.5,PZ-Verletzung PZ##,bis zu 1000 CP,same
R7.8,Unzulässige Deklaration,bis zu 100 TP,same
R8.4.2,Aufgabenreihenfolge,bis zu 1000 TP,same
R8.4.7d,Falsches Logger-Ziel,25 TP,same
R8.4.7m,Falscher Marker,25 TP,same
R8.4.8,"Zwei Marker innerhalb MMA, gewertet auf LM",auf elektronischen Mark gewertet,same
R8.12,Verspäteter Eintritt,"50 TP je angef. Min. (>5 Min), 100 TP wenn <5 Min",same
R9.2.2,Landeigentümer-Erlaubnis,bis zu 250 TP,same
R9.3.1,Missachten des Startleiters,bis zu 200 TP,same
R9.3.2,Schnellverschluss nicht benutzt,,same
R9.4.1,Mehr als 1 Fahrzeug im Startbereich,100 TP,same
R9.4.3,Fahrzeug fährt nach gelber Flagge in den Startbereich,100 TP,same
R9.9,Startperiode überschritten,50 TP je angef. Minute,same
R10.1.3,Ballonkollision,bis zu 1000 CP + RFS,same
R10.2.1,Gefährliches Fliegen,bis zur Disqualifikation + RFS,doubled
R10.2.2,Überschreitung der vertikalen Geschwindigkeitsgrenze,BSA + RFS,same
R10.5,Rücksichtsloses Verhalten,bis zu 1000 CP,same
R10.6,Vieh stören oder Feldfrüchte beschädigen,bis zu 1000 CP,same
R10.8,Kollision,bis zu 500 CP,same
R10.11,Fahrweise,bis zu 500 CP,same
R11.4,Bodenkontakt 1,200 TP,same
R11.5l,Bodenkontakt 2 - leicht,100 TP,same
R11.5s,Bodenkontakt 2 - massiv,500 TP,same
R12.3.5,Verspätete Deklaration,50 TP je angef. Minute,same
R12.5,Veränderte oder nicht zugelassene Marker,bis zu 250 TP,same
R12.7,GMD nicht in Ordnung,50 TP,same
R12.7a,GMD nicht in Ordnung (+50m),+50m,same
R12.8,MKR nicht entrollt,50 TP,same
R12.8a,MKR nicht entrollt (+50m),+50m,same
R12.13.3,Außerhalb der Wertungsperiode,NR,same
R13.3,Distanzverletzung,"2 TP je 0,1% Verletzung (ANG, ELB, LRN: Summe der % Verletzung)",same
R13.3>,Distanzverletzung >25%,NR,same
R13.3.1,Start zu nah am Ziel,"2 TP je 0,1% Verletzung",same
R13.3.3,Landung zu nah am Ziel oder MMA,200 TP,same
R15.1,Deklaration ungültig / nicht konform,,same
R15.5.2,Keine gültige Deklaration,,same
R15.12.2,Zeitlimit überschritten,,same
R15.15,Benachbarte Segmente,,same
RII.18.d,BalloonLive- oder BLS-Handhabung,,same
1 rule_number rule_text suggested_penalty escalation_mode
2 R3.8 Wettbewerbsnummer same
3 R3.8 Wrng. Wettbewerbsnummer - Warnung Warning same
4 R6.3.2 FRF verspätet bis zu 100 TP same
5 R6.3.2 Wrng. FRF verspätet - Warnung Warning same
6 R7.5 PZ-Verletzung PZ## bis zu 1000 CP same
7 R7.8 Unzulässige Deklaration bis zu 100 TP same
8 R8.4.2 Aufgabenreihenfolge bis zu 1000 TP same
9 R8.4.7d Falsches Logger-Ziel 25 TP same
10 R8.4.7m Falscher Marker 25 TP same
11 R8.4.8 Zwei Marker innerhalb MMA, gewertet auf LM auf elektronischen Mark gewertet same
12 R8.12 Verspäteter Eintritt 50 TP je angef. Min. (>5 Min), 100 TP wenn <5 Min same
13 R9.2.2 Landeigentümer-Erlaubnis bis zu 250 TP same
14 R9.3.1 Missachten des Startleiters bis zu 200 TP same
15 R9.3.2 Schnellverschluss nicht benutzt same
16 R9.4.1 Mehr als 1 Fahrzeug im Startbereich 100 TP same
17 R9.4.3 Fahrzeug fährt nach gelber Flagge in den Startbereich 100 TP same
18 R9.9 Startperiode überschritten 50 TP je angef. Minute same
19 R10.1.3 Ballonkollision bis zu 1000 CP + RFS same
20 R10.2.1 Gefährliches Fliegen bis zur Disqualifikation + RFS doubled
21 R10.2.2 Überschreitung der vertikalen Geschwindigkeitsgrenze BSA + RFS same
22 R10.5 Rücksichtsloses Verhalten bis zu 1000 CP same
23 R10.6 Vieh stören oder Feldfrüchte beschädigen bis zu 1000 CP same
24 R10.8 Kollision bis zu 500 CP same
25 R10.11 Fahrweise bis zu 500 CP same
26 R11.4 Bodenkontakt 1 200 TP same
27 R11.5l Bodenkontakt 2 - leicht 100 TP same
28 R11.5s Bodenkontakt 2 - massiv 500 TP same
29 R12.3.5 Verspätete Deklaration 50 TP je angef. Minute same
30 R12.5 Veränderte oder nicht zugelassene Marker bis zu 250 TP same
31 R12.7 GMD nicht in Ordnung 50 TP same
32 R12.7a GMD nicht in Ordnung (+50m) +50m same
33 R12.8 MKR nicht entrollt 50 TP same
34 R12.8a MKR nicht entrollt (+50m) +50m same
35 R12.13.3 Außerhalb der Wertungsperiode NR same
36 R13.3 Distanzverletzung 2 TP je 0,1% Verletzung (ANG, ELB, LRN: Summe der % Verletzung) same
37 R13.3> Distanzverletzung >25% NR same
38 R13.3.1 Start zu nah am Ziel 2 TP je 0,1% Verletzung same
39 R13.3.3 Landung zu nah am Ziel oder MMA 200 TP same
40 R15.1 Deklaration ungültig / nicht konform same
41 R15.5.2 Keine gültige Deklaration same
42 R15.12.2 Zeitlimit überschritten same
43 R15.15 Benachbarte Segmente same
44 RII.18.d BalloonLive- oder BLS-Handhabung same
+44
View File
@@ -0,0 +1,44 @@
rule_number,rule_text,suggested_penalty,escalation_mode
R3.8,Competition number,,same
R3.8 Wrng.,Competition number - Warning,Warning,same
R6.3.2,FRF delayed,up to 100 TP,same
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
R7.5,PZ Infringement PZ##,up to 1000 CP,same
R7.8,Inappropriate declaration,up to 100 TP,same
R8.4.2,Task Order,up to 1000 TP,same
R8.4.7d,Wrong Loggergoal,25 TP,same
R8.4.7m,Wrong Marker,25 TP,same
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
R9.2.2,Landowner Permission,up to 250 TP,same
R9.3.1,Disregard launch master,up to 200 TP,same
R9.3.2,Quick Release not used,,same
R9.4.1,More than 1 vehicle in launch area,100 TP,same
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
R9.9,Launch period exceeded,50 TP per part minute,same
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
R10.5,Inconsiderate behaviour,up to 1000 CP,same
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
R10.8,Collision,up to 500 CP,same
R10.11,Way of driving,up to 500 CP,same
R11.4,Ground Contact 1,200 TP,same
R11.5l,Ground Contact 2 - Light,100 TP,same
R11.5s,Ground Contact 2 - Solid,500 TP,same
R12.3.5,Late declaration,50 TP per part minute,same
R12.5,Modified or unauthorized markers,up to 250 TP,same
R12.7,GMD not ok,50 TP,same
R12.7a,GMD not ok (+50m),+50m,same
R12.8,MKR not unrolled,50 TP,same
R12.8a,MKR not unrolled (+50m),+50m,same
R12.13.3,Out of scoring period,NR,same
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
R13.3>,Distance Infringement >25%,NR,same
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
R13.3.3,Landing too close to goal or MMA,200 TP,same
R15.1,Declaration invalid / non compliant,,same
R15.5.2,No valid declaration,,same
R15.12.2,Exceeded Timing limit,,same
R15.15,Adjacent segments,,same
RII.18.d,BalloonLive or BLS handling,,same
1 rule_number rule_text suggested_penalty escalation_mode
2 R3.8 Competition number same
3 R3.8 Wrng. Competition number - Warning Warning same
4 R6.3.2 FRF delayed up to 100 TP same
5 R6.3.2 Wrng. FRF delayed - Warning Warning same
6 R7.5 PZ Infringement PZ## up to 1000 CP same
7 R7.8 Inappropriate declaration up to 100 TP same
8 R8.4.2 Task Order up to 1000 TP same
9 R8.4.7d Wrong Loggergoal 25 TP same
10 R8.4.7m Wrong Marker 25 TP same
11 R8.4.8 Two markers inside MMA, scored to LM scored to electronic mark same
12 R8.12 Late Entry 50 TP per part minute (>5 min), 100 TP if <5 min same
13 R9.2.2 Landowner Permission up to 250 TP same
14 R9.3.1 Disregard launch master up to 200 TP same
15 R9.3.2 Quick Release not used same
16 R9.4.1 More than 1 vehicle in launch area 100 TP same
17 R9.4.3 Vehicle entering launch area after yellow flag 100 TP same
18 R9.9 Launch period exceeded 50 TP per part minute same
19 R10.1.3 Balloon Collision up to 1000 CP + RFS same
20 R10.2.1 Dangerous flying up to disqualification + RFS doubled
21 R10.2.2 Exceeding vertical speed limits BSA + RFS same
22 R10.5 Inconsiderate behaviour up to 1000 CP same
23 R10.6 Disturbing livestock or damaging crop up to 1000 CP same
24 R10.8 Collision up to 500 CP same
25 R10.11 Way of driving up to 500 CP same
26 R11.4 Ground Contact 1 200 TP same
27 R11.5l Ground Contact 2 - Light 100 TP same
28 R11.5s Ground Contact 2 - Solid 500 TP same
29 R12.3.5 Late declaration 50 TP per part minute same
30 R12.5 Modified or unauthorized markers up to 250 TP same
31 R12.7 GMD not ok 50 TP same
32 R12.7a GMD not ok (+50m) +50m same
33 R12.8 MKR not unrolled 50 TP same
34 R12.8a MKR not unrolled (+50m) +50m same
35 R12.13.3 Out of scoring period NR same
36 R13.3 Distance Infringement 2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement) same
37 R13.3> Distance Infringement >25% NR same
38 R13.3.1 Take-off too close to goal 2 TP per 0.1% infringement same
39 R13.3.3 Landing too close to goal or MMA 200 TP same
40 R15.1 Declaration invalid / non compliant same
41 R15.5.2 No valid declaration same
42 R15.12.2 Exceeded Timing limit same
43 R15.15 Adjacent segments same
44 RII.18.d BalloonLive or BLS handling same
+44
View File
@@ -0,0 +1,44 @@
rule_number,rule_text,suggested_penalty,escalation_mode
R3.8,Competition number,,same
R3.8 Wrng.,Competition number - Warning,Warning,same
R6.3.2,FRF delayed,up to 100 TP,same
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
R7.5,PZ Infringement PZ##,up to 1000 CP,same
R7.8,Inappropriate declaration,up to 100 TP,same
R8.4.2,Task Order,up to 1000 TP,same
R8.4.7d,Wrong Loggergoal,25 TP,same
R8.4.7m,Wrong Marker,25 TP,same
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
R9.2.2,Landowner Permission,up to 250 TP,same
R9.3.1,Disregard launch master,up to 200 TP,same
R9.3.2,Quick Release not used,,same
R9.4.1,More than 1 vehicle in launch area,100 TP,same
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
R9.9,Launch period exceeded,50 TP per part minute,same
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
R10.5,Inconsiderate behaviour,up to 1000 CP,same
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
R10.8,Collision,up to 500 CP,same
R10.11,Way of driving,up to 500 CP,same
R11.4,Ground Contact 1,200 TP,same
R11.5l,Ground Contact 2 - Light,100 TP,same
R11.5s,Ground Contact 2 - Solid,500 TP,same
R12.3.5,Late declaration,50 TP per part minute,same
R12.5,Modified or unauthorized markers,up to 250 TP,same
R12.7,GMD not ok,50 TP,same
R12.7a,GMD not ok (+50m),+50m,same
R12.8,MKR not unrolled,50 TP,same
R12.8a,MKR not unrolled (+50m),+50m,same
R12.13.3,Out of scoring period,NR,same
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
R13.3>,Distance Infringement >25%,NR,same
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
R13.3.3,Landing too close to goal or MMA,200 TP,same
R15.1,Declaration invalid / non compliant,,same
R15.5.2,No valid declaration,,same
R15.12.2,Exceeded Timing limit,,same
R15.15,Adjacent segments,,same
RII.18.d,BalloonLive or BLS handling,,same
1 rule_number rule_text suggested_penalty escalation_mode
2 R3.8 Competition number same
3 R3.8 Wrng. Competition number - Warning Warning same
4 R6.3.2 FRF delayed up to 100 TP same
5 R6.3.2 Wrng. FRF delayed - Warning Warning same
6 R7.5 PZ Infringement PZ## up to 1000 CP same
7 R7.8 Inappropriate declaration up to 100 TP same
8 R8.4.2 Task Order up to 1000 TP same
9 R8.4.7d Wrong Loggergoal 25 TP same
10 R8.4.7m Wrong Marker 25 TP same
11 R8.4.8 Two markers inside MMA, scored to LM scored to electronic mark same
12 R8.12 Late Entry 50 TP per part minute (>5 min), 100 TP if <5 min same
13 R9.2.2 Landowner Permission up to 250 TP same
14 R9.3.1 Disregard launch master up to 200 TP same
15 R9.3.2 Quick Release not used same
16 R9.4.1 More than 1 vehicle in launch area 100 TP same
17 R9.4.3 Vehicle entering launch area after yellow flag 100 TP same
18 R9.9 Launch period exceeded 50 TP per part minute same
19 R10.1.3 Balloon Collision up to 1000 CP + RFS same
20 R10.2.1 Dangerous flying up to disqualification + RFS doubled
21 R10.2.2 Exceeding vertical speed limits BSA + RFS same
22 R10.5 Inconsiderate behaviour up to 1000 CP same
23 R10.6 Disturbing livestock or damaging crop up to 1000 CP same
24 R10.8 Collision up to 500 CP same
25 R10.11 Way of driving up to 500 CP same
26 R11.4 Ground Contact 1 200 TP same
27 R11.5l Ground Contact 2 - Light 100 TP same
28 R11.5s Ground Contact 2 - Solid 500 TP same
29 R12.3.5 Late declaration 50 TP per part minute same
30 R12.5 Modified or unauthorized markers up to 250 TP same
31 R12.7 GMD not ok 50 TP same
32 R12.7a GMD not ok (+50m) +50m same
33 R12.8 MKR not unrolled 50 TP same
34 R12.8a MKR not unrolled (+50m) +50m same
35 R12.13.3 Out of scoring period NR same
36 R13.3 Distance Infringement 2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement) same
37 R13.3> Distance Infringement >25% NR same
38 R13.3.1 Take-off too close to goal 2 TP per 0.1% infringement same
39 R13.3.3 Landing too close to goal or MMA 200 TP same
40 R15.1 Declaration invalid / non compliant same
41 R15.5.2 No valid declaration same
42 R15.12.2 Exceeded Timing limit same
43 R15.15 Adjacent segments same
44 RII.18.d BalloonLive or BLS handling same
+44
View File
@@ -0,0 +1,44 @@
rule_number,rule_text,suggested_penalty,escalation_mode
R3.8,Competition number,,same
R3.8 Wrng.,Competition number - Warning,Warning,same
R6.3.2,FRF delayed,up to 100 TP,same
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
R7.5,PZ Infringement PZ##,up to 1000 CP,same
R7.8,Inappropriate declaration,up to 100 TP,same
R8.4.2,Task Order,up to 1000 TP,same
R8.4.7d,Wrong Loggergoal,25 TP,same
R8.4.7m,Wrong Marker,25 TP,same
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
R9.2.2,Landowner Permission,up to 250 TP,same
R9.3.1,Disregard launch master,up to 200 TP,same
R9.3.2,Quick Release not used,,same
R9.4.1,More than 1 vehicle in launch area,100 TP,same
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
R9.9,Launch period exceeded,50 TP per part minute,same
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
R10.5,Inconsiderate behaviour,up to 1000 CP,same
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
R10.8,Collision,up to 500 CP,same
R10.11,Way of driving,up to 500 CP,same
R11.4,Ground Contact 1,200 TP,same
R11.5l,Ground Contact 2 - Light,100 TP,same
R11.5s,Ground Contact 2 - Solid,500 TP,same
R12.3.5,Late declaration,50 TP per part minute,same
R12.5,Modified or unauthorized markers,up to 250 TP,same
R12.7,GMD not ok,50 TP,same
R12.7a,GMD not ok (+50m),+50m,same
R12.8,MKR not unrolled,50 TP,same
R12.8a,MKR not unrolled (+50m),+50m,same
R12.13.3,Out of scoring period,NR,same
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
R13.3>,Distance Infringement >25%,NR,same
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
R13.3.3,Landing too close to goal or MMA,200 TP,same
R15.1,Declaration invalid / non compliant,,same
R15.5.2,No valid declaration,,same
R15.12.2,Exceeded Timing limit,,same
R15.15,Adjacent segments,,same
RII.18.d,BalloonLive or BLS handling,,same
1 rule_number rule_text suggested_penalty escalation_mode
2 R3.8 Competition number same
3 R3.8 Wrng. Competition number - Warning Warning same
4 R6.3.2 FRF delayed up to 100 TP same
5 R6.3.2 Wrng. FRF delayed - Warning Warning same
6 R7.5 PZ Infringement PZ## up to 1000 CP same
7 R7.8 Inappropriate declaration up to 100 TP same
8 R8.4.2 Task Order up to 1000 TP same
9 R8.4.7d Wrong Loggergoal 25 TP same
10 R8.4.7m Wrong Marker 25 TP same
11 R8.4.8 Two markers inside MMA, scored to LM scored to electronic mark same
12 R8.12 Late Entry 50 TP per part minute (>5 min), 100 TP if <5 min same
13 R9.2.2 Landowner Permission up to 250 TP same
14 R9.3.1 Disregard launch master up to 200 TP same
15 R9.3.2 Quick Release not used same
16 R9.4.1 More than 1 vehicle in launch area 100 TP same
17 R9.4.3 Vehicle entering launch area after yellow flag 100 TP same
18 R9.9 Launch period exceeded 50 TP per part minute same
19 R10.1.3 Balloon Collision up to 1000 CP + RFS same
20 R10.2.1 Dangerous flying up to disqualification + RFS doubled
21 R10.2.2 Exceeding vertical speed limits BSA + RFS same
22 R10.5 Inconsiderate behaviour up to 1000 CP same
23 R10.6 Disturbing livestock or damaging crop up to 1000 CP same
24 R10.8 Collision up to 500 CP same
25 R10.11 Way of driving up to 500 CP same
26 R11.4 Ground Contact 1 200 TP same
27 R11.5l Ground Contact 2 - Light 100 TP same
28 R11.5s Ground Contact 2 - Solid 500 TP same
29 R12.3.5 Late declaration 50 TP per part minute same
30 R12.5 Modified or unauthorized markers up to 250 TP same
31 R12.7 GMD not ok 50 TP same
32 R12.7a GMD not ok (+50m) +50m same
33 R12.8 MKR not unrolled 50 TP same
34 R12.8a MKR not unrolled (+50m) +50m same
35 R12.13.3 Out of scoring period NR same
36 R13.3 Distance Infringement 2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement) same
37 R13.3> Distance Infringement >25% NR same
38 R13.3.1 Take-off too close to goal 2 TP per 0.1% infringement same
39 R13.3.3 Landing too close to goal or MMA 200 TP same
40 R15.1 Declaration invalid / non compliant same
41 R15.5.2 No valid declaration same
42 R15.12.2 Exceeded Timing limit same
43 R15.15 Adjacent segments same
44 RII.18.d BalloonLive or BLS handling same
+44
View File
@@ -0,0 +1,44 @@
rule_number,rule_text,suggested_penalty,escalation_mode
R3.8,Competition number,,same
R3.8 Wrng.,Competition number - Warning,Warning,same
R6.3.2,FRF delayed,up to 100 TP,same
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
R7.5,PZ Infringement PZ##,up to 1000 CP,same
R7.8,Inappropriate declaration,up to 100 TP,same
R8.4.2,Task Order,up to 1000 TP,same
R8.4.7d,Wrong Loggergoal,25 TP,same
R8.4.7m,Wrong Marker,25 TP,same
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
R9.2.2,Landowner Permission,up to 250 TP,same
R9.3.1,Disregard launch master,up to 200 TP,same
R9.3.2,Quick Release not used,,same
R9.4.1,More than 1 vehicle in launch area,100 TP,same
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
R9.9,Launch period exceeded,50 TP per part minute,same
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
R10.5,Inconsiderate behaviour,up to 1000 CP,same
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
R10.8,Collision,up to 500 CP,same
R10.11,Way of driving,up to 500 CP,same
R11.4,Ground Contact 1,200 TP,same
R11.5l,Ground Contact 2 - Light,100 TP,same
R11.5s,Ground Contact 2 - Solid,500 TP,same
R12.3.5,Late declaration,50 TP per part minute,same
R12.5,Modified or unauthorized markers,up to 250 TP,same
R12.7,GMD not ok,50 TP,same
R12.7a,GMD not ok (+50m),+50m,same
R12.8,MKR not unrolled,50 TP,same
R12.8a,MKR not unrolled (+50m),+50m,same
R12.13.3,Out of scoring period,NR,same
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
R13.3>,Distance Infringement >25%,NR,same
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
R13.3.3,Landing too close to goal or MMA,200 TP,same
R15.1,Declaration invalid / non compliant,,same
R15.5.2,No valid declaration,,same
R15.12.2,Exceeded Timing limit,,same
R15.15,Adjacent segments,,same
RII.18.d,BalloonLive or BLS handling,,same
1 rule_number rule_text suggested_penalty escalation_mode
2 R3.8 Competition number same
3 R3.8 Wrng. Competition number - Warning Warning same
4 R6.3.2 FRF delayed up to 100 TP same
5 R6.3.2 Wrng. FRF delayed - Warning Warning same
6 R7.5 PZ Infringement PZ## up to 1000 CP same
7 R7.8 Inappropriate declaration up to 100 TP same
8 R8.4.2 Task Order up to 1000 TP same
9 R8.4.7d Wrong Loggergoal 25 TP same
10 R8.4.7m Wrong Marker 25 TP same
11 R8.4.8 Two markers inside MMA, scored to LM scored to electronic mark same
12 R8.12 Late Entry 50 TP per part minute (>5 min), 100 TP if <5 min same
13 R9.2.2 Landowner Permission up to 250 TP same
14 R9.3.1 Disregard launch master up to 200 TP same
15 R9.3.2 Quick Release not used same
16 R9.4.1 More than 1 vehicle in launch area 100 TP same
17 R9.4.3 Vehicle entering launch area after yellow flag 100 TP same
18 R9.9 Launch period exceeded 50 TP per part minute same
19 R10.1.3 Balloon Collision up to 1000 CP + RFS same
20 R10.2.1 Dangerous flying up to disqualification + RFS doubled
21 R10.2.2 Exceeding vertical speed limits BSA + RFS same
22 R10.5 Inconsiderate behaviour up to 1000 CP same
23 R10.6 Disturbing livestock or damaging crop up to 1000 CP same
24 R10.8 Collision up to 500 CP same
25 R10.11 Way of driving up to 500 CP same
26 R11.4 Ground Contact 1 200 TP same
27 R11.5l Ground Contact 2 - Light 100 TP same
28 R11.5s Ground Contact 2 - Solid 500 TP same
29 R12.3.5 Late declaration 50 TP per part minute same
30 R12.5 Modified or unauthorized markers up to 250 TP same
31 R12.7 GMD not ok 50 TP same
32 R12.7a GMD not ok (+50m) +50m same
33 R12.8 MKR not unrolled 50 TP same
34 R12.8a MKR not unrolled (+50m) +50m same
35 R12.13.3 Out of scoring period NR same
36 R13.3 Distance Infringement 2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement) same
37 R13.3> Distance Infringement >25% NR same
38 R13.3.1 Take-off too close to goal 2 TP per 0.1% infringement same
39 R13.3.3 Landing too close to goal or MMA 200 TP same
40 R15.1 Declaration invalid / non compliant same
41 R15.5.2 No valid declaration same
42 R15.12.2 Exceeded Timing limit same
43 R15.15 Adjacent segments same
44 RII.18.d BalloonLive or BLS handling same
+44
View File
@@ -0,0 +1,44 @@
rule_number,rule_text,suggested_penalty,escalation_mode
R3.8,Competition number,,same
R3.8 Wrng.,Competition number - Warning,Warning,same
R6.3.2,FRF delayed,up to 100 TP,same
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
R7.5,PZ Infringement PZ##,up to 1000 CP,same
R7.8,Inappropriate declaration,up to 100 TP,same
R8.4.2,Task Order,up to 1000 TP,same
R8.4.7d,Wrong Loggergoal,25 TP,same
R8.4.7m,Wrong Marker,25 TP,same
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
R9.2.2,Landowner Permission,up to 250 TP,same
R9.3.1,Disregard launch master,up to 200 TP,same
R9.3.2,Quick Release not used,,same
R9.4.1,More than 1 vehicle in launch area,100 TP,same
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
R9.9,Launch period exceeded,50 TP per part minute,same
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
R10.5,Inconsiderate behaviour,up to 1000 CP,same
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
R10.8,Collision,up to 500 CP,same
R10.11,Way of driving,up to 500 CP,same
R11.4,Ground Contact 1,200 TP,same
R11.5l,Ground Contact 2 - Light,100 TP,same
R11.5s,Ground Contact 2 - Solid,500 TP,same
R12.3.5,Late declaration,50 TP per part minute,same
R12.5,Modified or unauthorized markers,up to 250 TP,same
R12.7,GMD not ok,50 TP,same
R12.7a,GMD not ok (+50m),+50m,same
R12.8,MKR not unrolled,50 TP,same
R12.8a,MKR not unrolled (+50m),+50m,same
R12.13.3,Out of scoring period,NR,same
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
R13.3>,Distance Infringement >25%,NR,same
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
R13.3.3,Landing too close to goal or MMA,200 TP,same
R15.1,Declaration invalid / non compliant,,same
R15.5.2,No valid declaration,,same
R15.12.2,Exceeded Timing limit,,same
R15.15,Adjacent segments,,same
RII.18.d,BalloonLive or BLS handling,,same
1 rule_number rule_text suggested_penalty escalation_mode
2 R3.8 Competition number same
3 R3.8 Wrng. Competition number - Warning Warning same
4 R6.3.2 FRF delayed up to 100 TP same
5 R6.3.2 Wrng. FRF delayed - Warning Warning same
6 R7.5 PZ Infringement PZ## up to 1000 CP same
7 R7.8 Inappropriate declaration up to 100 TP same
8 R8.4.2 Task Order up to 1000 TP same
9 R8.4.7d Wrong Loggergoal 25 TP same
10 R8.4.7m Wrong Marker 25 TP same
11 R8.4.8 Two markers inside MMA, scored to LM scored to electronic mark same
12 R8.12 Late Entry 50 TP per part minute (>5 min), 100 TP if <5 min same
13 R9.2.2 Landowner Permission up to 250 TP same
14 R9.3.1 Disregard launch master up to 200 TP same
15 R9.3.2 Quick Release not used same
16 R9.4.1 More than 1 vehicle in launch area 100 TP same
17 R9.4.3 Vehicle entering launch area after yellow flag 100 TP same
18 R9.9 Launch period exceeded 50 TP per part minute same
19 R10.1.3 Balloon Collision up to 1000 CP + RFS same
20 R10.2.1 Dangerous flying up to disqualification + RFS doubled
21 R10.2.2 Exceeding vertical speed limits BSA + RFS same
22 R10.5 Inconsiderate behaviour up to 1000 CP same
23 R10.6 Disturbing livestock or damaging crop up to 1000 CP same
24 R10.8 Collision up to 500 CP same
25 R10.11 Way of driving up to 500 CP same
26 R11.4 Ground Contact 1 200 TP same
27 R11.5l Ground Contact 2 - Light 100 TP same
28 R11.5s Ground Contact 2 - Solid 500 TP same
29 R12.3.5 Late declaration 50 TP per part minute same
30 R12.5 Modified or unauthorized markers up to 250 TP same
31 R12.7 GMD not ok 50 TP same
32 R12.7a GMD not ok (+50m) +50m same
33 R12.8 MKR not unrolled 50 TP same
34 R12.8a MKR not unrolled (+50m) +50m same
35 R12.13.3 Out of scoring period NR same
36 R13.3 Distance Infringement 2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement) same
37 R13.3> Distance Infringement >25% NR same
38 R13.3.1 Take-off too close to goal 2 TP per 0.1% infringement same
39 R13.3.3 Landing too close to goal or MMA 200 TP same
40 R15.1 Declaration invalid / non compliant same
41 R15.5.2 No valid declaration same
42 R15.12.2 Exceeded Timing limit same
43 R15.15 Adjacent segments same
44 RII.18.d BalloonLive or BLS handling same
+44
View File
@@ -0,0 +1,44 @@
rule_number,rule_text,suggested_penalty,escalation_mode
R3.8,Competition number,,same
R3.8 Wrng.,Competition number - Warning,Warning,same
R6.3.2,FRF delayed,up to 100 TP,same
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
R7.5,PZ Infringement PZ##,up to 1000 CP,same
R7.8,Inappropriate declaration,up to 100 TP,same
R8.4.2,Task Order,up to 1000 TP,same
R8.4.7d,Wrong Loggergoal,25 TP,same
R8.4.7m,Wrong Marker,25 TP,same
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
R9.2.2,Landowner Permission,up to 250 TP,same
R9.3.1,Disregard launch master,up to 200 TP,same
R9.3.2,Quick Release not used,,same
R9.4.1,More than 1 vehicle in launch area,100 TP,same
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
R9.9,Launch period exceeded,50 TP per part minute,same
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
R10.5,Inconsiderate behaviour,up to 1000 CP,same
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
R10.8,Collision,up to 500 CP,same
R10.11,Way of driving,up to 500 CP,same
R11.4,Ground Contact 1,200 TP,same
R11.5l,Ground Contact 2 - Light,100 TP,same
R11.5s,Ground Contact 2 - Solid,500 TP,same
R12.3.5,Late declaration,50 TP per part minute,same
R12.5,Modified or unauthorized markers,up to 250 TP,same
R12.7,GMD not ok,50 TP,same
R12.7a,GMD not ok (+50m),+50m,same
R12.8,MKR not unrolled,50 TP,same
R12.8a,MKR not unrolled (+50m),+50m,same
R12.13.3,Out of scoring period,NR,same
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
R13.3>,Distance Infringement >25%,NR,same
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
R13.3.3,Landing too close to goal or MMA,200 TP,same
R15.1,Declaration invalid / non compliant,,same
R15.5.2,No valid declaration,,same
R15.12.2,Exceeded Timing limit,,same
R15.15,Adjacent segments,,same
RII.18.d,BalloonLive or BLS handling,,same
1 rule_number rule_text suggested_penalty escalation_mode
2 R3.8 Competition number same
3 R3.8 Wrng. Competition number - Warning Warning same
4 R6.3.2 FRF delayed up to 100 TP same
5 R6.3.2 Wrng. FRF delayed - Warning Warning same
6 R7.5 PZ Infringement PZ## up to 1000 CP same
7 R7.8 Inappropriate declaration up to 100 TP same
8 R8.4.2 Task Order up to 1000 TP same
9 R8.4.7d Wrong Loggergoal 25 TP same
10 R8.4.7m Wrong Marker 25 TP same
11 R8.4.8 Two markers inside MMA, scored to LM scored to electronic mark same
12 R8.12 Late Entry 50 TP per part minute (>5 min), 100 TP if <5 min same
13 R9.2.2 Landowner Permission up to 250 TP same
14 R9.3.1 Disregard launch master up to 200 TP same
15 R9.3.2 Quick Release not used same
16 R9.4.1 More than 1 vehicle in launch area 100 TP same
17 R9.4.3 Vehicle entering launch area after yellow flag 100 TP same
18 R9.9 Launch period exceeded 50 TP per part minute same
19 R10.1.3 Balloon Collision up to 1000 CP + RFS same
20 R10.2.1 Dangerous flying up to disqualification + RFS doubled
21 R10.2.2 Exceeding vertical speed limits BSA + RFS same
22 R10.5 Inconsiderate behaviour up to 1000 CP same
23 R10.6 Disturbing livestock or damaging crop up to 1000 CP same
24 R10.8 Collision up to 500 CP same
25 R10.11 Way of driving up to 500 CP same
26 R11.4 Ground Contact 1 200 TP same
27 R11.5l Ground Contact 2 - Light 100 TP same
28 R11.5s Ground Contact 2 - Solid 500 TP same
29 R12.3.5 Late declaration 50 TP per part minute same
30 R12.5 Modified or unauthorized markers up to 250 TP same
31 R12.7 GMD not ok 50 TP same
32 R12.7a GMD not ok (+50m) +50m same
33 R12.8 MKR not unrolled 50 TP same
34 R12.8a MKR not unrolled (+50m) +50m same
35 R12.13.3 Out of scoring period NR same
36 R13.3 Distance Infringement 2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement) same
37 R13.3> Distance Infringement >25% NR same
38 R13.3.1 Take-off too close to goal 2 TP per 0.1% infringement same
39 R13.3.3 Landing too close to goal or MMA 200 TP same
40 R15.1 Declaration invalid / non compliant same
41 R15.5.2 No valid declaration same
42 R15.12.2 Exceeded Timing limit same
43 R15.15 Adjacent segments same
44 RII.18.d BalloonLive or BLS handling same
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"golang.org/x/crypto/bcrypt"
)
func registerUserRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/users", requireAdmin(handleListUsers))
mux.HandleFunc("POST /api/users", requireAuth(handleCreateUser))
mux.HandleFunc("DELETE /api/users/{id}", requireAdmin(handleDeleteUser))
mux.HandleFunc("PATCH /api/users/{id}", requireAdmin(handleAdminUpdateUser))
}
func handleListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query("SELECT id,username,display_name,language,is_system_admin FROM users ORDER BY username")
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
defer rows.Close()
out := []User{}
for rows.Next() {
var u User
var admin int
rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin)
u.IsSystemAdmin = admin == 1
out = append(out, u)
}
writeJSON(w, http.StatusOK, out)
}
func handleCreateUser(w http.ResponseWriter, r *http.Request) {
actor := userFromCtx(r)
var req struct {
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Language string `json:"language"`
IsSystemAdmin bool `json:"is_system_admin"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "missing_fields")
return
}
if req.IsSystemAdmin && !actor.IsSystemAdmin {
writeError(w, http.StatusForbidden, "forbidden")
return
}
if !actor.IsSystemAdmin {
var chiefCount int
db.QueryRow("SELECT COUNT(*) FROM competition_users WHERE user_id=? AND role='chief_scorer'", actor.ID).Scan(&chiefCount)
if chiefCount == 0 {
writeError(w, http.StatusForbidden, "forbidden")
return
}
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, "hash_error")
return
}
if req.Language == "" {
req.Language = "en"
}
admin := 0
if req.IsSystemAdmin {
admin = 1
}
res, err := db.Exec(
"INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,?)",
req.Username, string(hash), req.DisplayName, req.Language, admin,
)
if err != nil {
writeError(w, http.StatusConflict, "username_taken")
return
}
id, _ := res.LastInsertId()
u, _ := loadUser(id)
writeJSON(w, http.StatusCreated, u)
}
func handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
actor := userFromCtx(r)
if actor.ID == id {
writeError(w, http.StatusBadRequest, "cannot_delete_self")
return
}
if _, err := db.Exec("DELETE FROM users WHERE id=?", id); err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
w.WriteHeader(http.StatusNoContent)
}
func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
var req struct {
DisplayName *string `json:"display_name"`
Password *string `json:"password"`
IsSystemAdmin *bool `json:"is_system_admin"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
if req.DisplayName != nil {
db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id)
}
if req.Password != nil && *req.Password != "" {
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), id)
}
if req.IsSystemAdmin != nil {
v := 0
if *req.IsSystemAdmin {
v = 1
}
db.Exec("UPDATE users SET is_system_admin=? WHERE id=?", v, id)
}
u, _ := loadUser(id)
writeJSON(w, http.StatusOK, u)
}
+96
View File
@@ -0,0 +1,96 @@
async function api(method, path, body) {
const opts = {
method,
headers: { "Content-Type": "application/json" },
credentials: "include",
};
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(apiURL(path), opts);
if (res.status === 204) return null;
let data = null;
const text = await res.text();
if (text) {
try { data = JSON.parse(text); } catch (e) { data = text; }
}
if (!res.ok) {
const err = new Error((data && data.error) || res.statusText);
err.status = res.status;
err.data = data;
throw err;
}
return data;
}
const API = {
login: (u, p) => api("POST", "/api/login", { username: u, password: p }),
logout: () => api("POST", "/api/logout"),
me: () => api("GET", "/api/me"),
updateMe: (b) => api("PATCH", "/api/me", b),
listUsers: () => api("GET", "/api/users"),
createUser: (b) => api("POST", "/api/users", b),
deleteUser: (id) => api("DELETE", `/api/users/${id}`),
listCompetitions: () => api("GET", "/api/competitions"),
createCompetition: (b) => api("POST", "/api/competitions", b),
getCompetition: (id) => api("GET", `/api/competitions/${id}`),
updateCompetition: (id, b) => api("PATCH", `/api/competitions/${id}`, b),
deleteCompetition: (id) => api("DELETE", `/api/competitions/${id}`),
listMembers: (id) => api("GET", `/api/competitions/${id}/members`),
addMember: (id, b) => api("POST", `/api/competitions/${id}/members`, b),
removeMember: (id, uid) => api("DELETE", `/api/competitions/${id}/members/${uid}`),
listPilots: (id) => api("GET", `/api/competitions/${id}/pilots`),
createPilot: (id, b) => api("POST", `/api/competitions/${id}/pilots`, b),
updatePilot: (id, pid, b) => api("PATCH", `/api/competitions/${id}/pilots/${pid}`, b),
deletePilot: (id, pid) => api("DELETE", `/api/competitions/${id}/pilots/${pid}`),
importPilots: async (id, csv) => {
const res = await fetch(apiURL(`/api/competitions/${id}/pilots/import`), {
method: "POST",
headers: { "Content-Type": "text/csv" },
body: csv,
credentials: "include",
});
if (!res.ok) throw new Error("import_failed");
return res.json();
},
listPenalties: (id) => api("GET", `/api/competitions/${id}/penalties`),
createPenalty: (id, b) => api("POST", `/api/competitions/${id}/penalties`, b),
updatePenalty: (id, pid, b) => api("PATCH", `/api/competitions/${id}/penalties/${pid}`, b),
deletePenalty: (id, pid) => api("DELETE", `/api/competitions/${id}/penalties/${pid}`),
exportPenaltiesURL: (id) => apiURL(`/api/competitions/${id}/penalties.csv`),
listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`),
};
function openCompetitionWS(id, handlers) {
const url = wsURL(`/api/competitions/${id}/ws`);
let ws = null;
let closed = false;
let backoff = 1000;
function connect() {
ws = new WebSocket(url);
ws.onopen = () => { backoff = 1000; if (handlers.onopen) handlers.onopen(); };
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (handlers.onmessage) handlers.onmessage(msg);
} catch (err) {}
};
ws.onclose = () => {
if (handlers.onclose) handlers.onclose();
if (!closed) {
setTimeout(connect, backoff);
backoff = Math.min(backoff * 2, 15000);
}
};
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
connect();
return {
close: () => { closed = true; try { ws && ws.close(); } catch (e) {} }
};
}
+1163
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
const API_BASE = "http://127.0.0.1:8080";
function getApiBase() {
return API_BASE;
}
function apiURL(path) {
const base = getApiBase();
if (!base) return path;
return base + path;
}
function wsURL(path) {
const base = getApiBase();
if (base) {
let u;
try { u = new URL(base); } catch (e) { return null; }
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
u.pathname = u.pathname.replace(/\/+$/, "") + path;
return u.toString();
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}${path}`;
}
+323
View File
@@ -0,0 +1,323 @@
const I18N_AVAILABLE = ["en", "de", "pl", "ru", "fr", "es", "pt"];
const I18N_NAMES = {
en: "English", de: "Deutsch", pl: "Polski", ru: "Русский",
fr: "Français", es: "Español", pt: "Português"
};
const I18N_DATA = {
en: {
login_title: "Sign in",
username: "Username", password: "Password", sign_in: "Sign in",
invalid_credentials: "Invalid username or password",
logout: "Logout", settings: "Settings", language: "Language",
competitions: "Competitions", new_competition: "New competition",
competition_name: "Competition name", create: "Create", cancel: "Cancel",
role: "Role", system_admin: "System Admin", chief_scorer: "Chief Scorer", scorer: "Scorer",
pilots: "Pilots", penalties: "Penalties", members: "Members", rules: "Rules",
settings_tab: "Settings",
number: "Number", last_name: "Last name", first_name: "First name",
country: "Country", balloon_id: "Balloon ID",
add_pilot: "Add pilot", import_csv: "Import CSV", export_csv: "Export CSV",
flight: "Flight", date: "Date", pilot_number: "Pilot No.",
pilot_name: "Pilot name", rule: "Rule", task: "Task",
penalty_values: "Penalties", description: "Description", created_by: "Created by",
transferred: "Transferred", actions: "Actions",
add_penalty: "Add penalty", edit: "Edit", delete: "Delete", save: "Save",
confirm_delete: "Delete this entry?", search_rule: "Search rule by number or text",
suggested_penalty: "Suggested penalty",
escalation: "Escalation behavior",
escalation_same: "Stays the same", escalation_doubled: "Doubled each time",
escalation_escalate: "Escalates: ",
add_member: "Add member", remove: "Remove",
add_user: "Add user", users: "Users",
display_name: "Display name", is_admin: "Admin",
allow_any_scorer_edit: "Allow any scorer to edit penalties",
open: "Open", back: "Back", change_password: "Change password",
new_password: "New password", csv_paste: "Paste CSV (number,last,first,country,balloon)",
no_pilots: "No pilots yet", no_penalties: "No penalties yet",
no_members: "No members yet", no_competitions: "No competitions",
select_pilot: "Select pilot", rule_number_short: "Rule No.",
transferred_only: "Only untransferred",
showing_n_of_m: "Showing {n} of {m}",
online: "Online", offline: "Offline",
forbidden: "Not allowed",
save_settings: "Save settings",
saved: "Saved",
yes: "Yes", no: "No",
backend_url: "Backend URL",
backend_url_hint: "Leave empty to use the same origin (e.g. http://192.168.0.10:8080)",
profile: "Profile",
current_password: "Current password",
leave_blank_keep: "Leave blank to keep current",
username_taken: "Username already taken",
prior_penalties: "Prior penalties for this pilot and rule",
none: "None",
},
de: {
login_title: "Anmelden",
username: "Benutzername", password: "Passwort", sign_in: "Anmelden",
invalid_credentials: "Falscher Benutzername oder Passwort",
logout: "Abmelden", settings: "Einstellungen", language: "Sprache",
competitions: "Wettbewerbe", new_competition: "Neuer Wettbewerb",
competition_name: "Wettbewerbsname", create: "Erstellen", cancel: "Abbrechen",
role: "Rolle", system_admin: "Systemadmin", chief_scorer: "Chief-Scorer", scorer: "Scorer",
pilots: "Piloten", penalties: "Strafen", members: "Mitglieder", rules: "Regeln",
settings_tab: "Einstellungen",
number: "Nummer", last_name: "Nachname", first_name: "Vorname",
country: "Land", balloon_id: "Ballon-Kennung",
add_pilot: "Pilot hinzufügen", import_csv: "CSV importieren", export_csv: "CSV exportieren",
flight: "Fahrt", date: "Datum", pilot_number: "Pilot-Nr.",
pilot_name: "Pilotenname", rule: "Regel", task: "Aufgabe",
penalty_values: "Strafen", description: "Beschreibung", created_by: "Angelegt von",
transferred: "Übertragen", actions: "Aktionen",
add_penalty: "Strafe hinzufügen", edit: "Bearbeiten", delete: "Löschen", save: "Speichern",
confirm_delete: "Eintrag löschen?", search_rule: "Regel nach Nummer oder Text suchen",
suggested_penalty: "Vorgeschlagene Strafe",
escalation: "Verhalten bei Wiederholung",
escalation_same: "Bleibt gleich", escalation_doubled: "Wird jedes Mal verdoppelt",
escalation_escalate: "Höherstufung: ",
add_member: "Mitglied hinzufügen", remove: "Entfernen",
add_user: "Benutzer anlegen", users: "Benutzer",
display_name: "Anzeigename", is_admin: "Admin",
allow_any_scorer_edit: "Alle Scorer dürfen Strafen bearbeiten",
open: "Öffnen", back: "Zurück", change_password: "Passwort ändern",
new_password: "Neues Passwort", csv_paste: "CSV einfügen (Nr,Nachname,Vorname,Land,Ballon)",
no_pilots: "Noch keine Piloten", no_penalties: "Noch keine Strafen",
no_members: "Noch keine Mitglieder", no_competitions: "Keine Wettbewerbe",
select_pilot: "Pilot wählen", rule_number_short: "Regel-Nr.",
transferred_only: "Nur nicht übertragene",
showing_n_of_m: "{n} von {m}",
online: "Online", offline: "Offline",
forbidden: "Keine Berechtigung",
save_settings: "Einstellungen speichern",
saved: "Gespeichert",
yes: "Ja", no: "Nein",
backend_url: "Backend-URL",
backend_url_hint: "Leer lassen für gleichen Ursprung (z.B. http://192.168.0.10:8080)",
profile: "Profil",
current_password: "Aktuelles Passwort",
leave_blank_keep: "Leer lassen um beizubehalten",
username_taken: "Benutzername bereits vergeben",
prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel",
none: "Keine",
},
pl: {
login_title: "Zaloguj się",
username: "Nazwa użytkownika", password: "Hasło", sign_in: "Zaloguj",
invalid_credentials: "Nieprawidłowy login lub hasło",
logout: "Wyloguj", settings: "Ustawienia", language: "Język",
competitions: "Zawody", new_competition: "Nowe zawody",
competition_name: "Nazwa zawodów", create: "Utwórz", cancel: "Anuluj",
role: "Rola", system_admin: "Administrator", chief_scorer: "Chief-Scorer", scorer: "Scorer",
pilots: "Piloci", penalties: "Kary", members: "Członkowie", rules: "Zasady",
settings_tab: "Ustawienia",
number: "Numer", last_name: "Nazwisko", first_name: "Imię",
country: "Kraj", balloon_id: "Oznaczenie balonu",
add_pilot: "Dodaj pilota", import_csv: "Import CSV", export_csv: "Eksport CSV",
flight: "Lot", date: "Data", pilot_number: "Nr pilota",
pilot_name: "Imię i nazwisko", rule: "Zasada", task: "Zadanie",
penalty_values: "Kary", description: "Opis", created_by: "Wprowadził",
transferred: "Przesłano", actions: "Akcje",
add_penalty: "Dodaj karę", edit: "Edytuj", delete: "Usuń", save: "Zapisz",
confirm_delete: "Usunąć ten wpis?", search_rule: "Szukaj zasady po numerze lub tekście",
suggested_penalty: "Sugerowana kara",
escalation: "Zachowanie przy powtórzeniu",
escalation_same: "Bez zmian", escalation_doubled: "Podwajana za każdym razem",
escalation_escalate: "Eskalacja: ",
add_member: "Dodaj członka", remove: "Usuń",
add_user: "Dodaj użytkownika", users: "Użytkownicy",
display_name: "Wyświetlana nazwa", is_admin: "Admin",
allow_any_scorer_edit: "Pozwól dowolnemu scorerowi edytować kary",
open: "Otwórz", back: "Wstecz", change_password: "Zmień hasło",
new_password: "Nowe hasło", csv_paste: "Wklej CSV (nr,nazwisko,imię,kraj,balon)",
no_pilots: "Brak pilotów", no_penalties: "Brak kar",
no_members: "Brak członków", no_competitions: "Brak zawodów",
select_pilot: "Wybierz pilota", rule_number_short: "Nr zasady",
transferred_only: "Tylko nieprzesłane",
showing_n_of_m: "{n} z {m}",
online: "Online", offline: "Offline",
forbidden: "Brak uprawnień",
save_settings: "Zapisz ustawienia",
saved: "Zapisano",
yes: "Tak", no: "Nie",
},
ru: {
login_title: "Вход",
username: "Имя пользователя", password: "Пароль", sign_in: "Войти",
invalid_credentials: "Неверное имя или пароль",
logout: "Выйти", settings: "Настройки", language: "Язык",
competitions: "Соревнования", new_competition: "Новое соревнование",
competition_name: "Название", create: "Создать", cancel: "Отмена",
role: "Роль", system_admin: "Администратор", chief_scorer: "Chief-Scorer", scorer: "Scorer",
pilots: "Пилоты", penalties: "Штрафы", members: "Участники", rules: "Правила",
settings_tab: "Настройки",
number: "Номер", last_name: "Фамилия", first_name: "Имя",
country: "Страна", balloon_id: "Регистрация шара",
add_pilot: "Добавить пилота", import_csv: "Импорт CSV", export_csv: "Экспорт CSV",
flight: "Полёт", date: "Дата", pilot_number: "№ пилота",
pilot_name: "Имя пилота", rule: "Правило", task: "Задание",
penalty_values: "Штрафы", description: "Описание", created_by: "Автор",
transferred: "Передано", actions: "Действия",
add_penalty: "Добавить штраф", edit: "Редактировать", delete: "Удалить", save: "Сохранить",
confirm_delete: "Удалить запись?", search_rule: "Поиск правила по номеру или тексту",
suggested_penalty: "Рекомендованный штраф",
escalation: "Поведение при повторе",
escalation_same: "Без изменений", escalation_doubled: "Удваивается каждый раз",
escalation_escalate: "Эскалация: ",
add_member: "Добавить участника", remove: "Удалить",
add_user: "Создать пользователя", users: "Пользователи",
display_name: "Отображаемое имя", is_admin: "Админ",
allow_any_scorer_edit: "Любой Scorer может редактировать штрафы",
open: "Открыть", back: "Назад", change_password: "Изменить пароль",
new_password: "Новый пароль", csv_paste: "Вставьте CSV (№,фамилия,имя,страна,шар)",
no_pilots: "Нет пилотов", no_penalties: "Нет штрафов",
no_members: "Нет участников", no_competitions: "Нет соревнований",
select_pilot: "Выберите пилота", rule_number_short: "№ правила",
transferred_only: "Только непереданные",
showing_n_of_m: "{n} из {m}",
online: "Онлайн", offline: "Оффлайн",
forbidden: "Нет доступа",
save_settings: "Сохранить настройки",
saved: "Сохранено",
yes: "Да", no: "Нет",
},
fr: {
login_title: "Connexion",
username: "Nom d'utilisateur", password: "Mot de passe", sign_in: "Connexion",
invalid_credentials: "Identifiants invalides",
logout: "Déconnexion", settings: "Paramètres", language: "Langue",
competitions: "Compétitions", new_competition: "Nouvelle compétition",
competition_name: "Nom", create: "Créer", cancel: "Annuler",
role: "Rôle", system_admin: "Administrateur", chief_scorer: "Chief-Scorer", scorer: "Scorer",
pilots: "Pilotes", penalties: "Pénalités", members: "Membres", rules: "Règles",
settings_tab: "Paramètres",
number: "Numéro", last_name: "Nom", first_name: "Prénom",
country: "Pays", balloon_id: "Immat. ballon",
add_pilot: "Ajouter un pilote", import_csv: "Importer CSV", export_csv: "Exporter CSV",
flight: "Vol", date: "Date", pilot_number: "N° pilote",
pilot_name: "Nom du pilote", rule: "Règle", task: "Épreuve",
penalty_values: "Pénalités", description: "Description", created_by: "Créé par",
transferred: "Transféré", actions: "Actions",
add_penalty: "Ajouter pénalité", edit: "Modifier", delete: "Supprimer", save: "Enregistrer",
confirm_delete: "Supprimer cette entrée ?", search_rule: "Rechercher une règle par numéro ou texte",
suggested_penalty: "Pénalité suggérée",
escalation: "Comportement en cas de répétition",
escalation_same: "Reste identique", escalation_doubled: "Doublée à chaque fois",
escalation_escalate: "Escalade : ",
add_member: "Ajouter membre", remove: "Retirer",
add_user: "Créer un utilisateur", users: "Utilisateurs",
display_name: "Nom affiché", is_admin: "Admin",
allow_any_scorer_edit: "Tous les scorers peuvent modifier les pénalités",
open: "Ouvrir", back: "Retour", change_password: "Changer le mot de passe",
new_password: "Nouveau mot de passe", csv_paste: "Coller CSV (n°,nom,prénom,pays,ballon)",
no_pilots: "Aucun pilote", no_penalties: "Aucune pénalité",
no_members: "Aucun membre", no_competitions: "Aucune compétition",
select_pilot: "Choisir un pilote", rule_number_short: "N° règle",
transferred_only: "Non transférés uniquement",
showing_n_of_m: "{n} sur {m}",
online: "En ligne", offline: "Hors ligne",
forbidden: "Non autorisé",
save_settings: "Enregistrer",
saved: "Enregistré",
yes: "Oui", no: "Non",
},
es: {
login_title: "Iniciar sesión",
username: "Usuario", password: "Contraseña", sign_in: "Entrar",
invalid_credentials: "Usuario o contraseña incorrectos",
logout: "Salir", settings: "Ajustes", language: "Idioma",
competitions: "Competiciones", new_competition: "Nueva competición",
competition_name: "Nombre", create: "Crear", cancel: "Cancelar",
role: "Rol", system_admin: "Administrador", chief_scorer: "Chief-Scorer", scorer: "Scorer",
pilots: "Pilotos", penalties: "Penalizaciones", members: "Miembros", rules: "Reglas",
settings_tab: "Ajustes",
number: "Número", last_name: "Apellido", first_name: "Nombre",
country: "País", balloon_id: "Matrícula globo",
add_pilot: "Añadir piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV",
flight: "Vuelo", date: "Fecha", pilot_number: "N.º piloto",
pilot_name: "Nombre piloto", rule: "Regla", task: "Tarea",
penalty_values: "Penalizaciones", description: "Descripción", created_by: "Creado por",
transferred: "Transferido", actions: "Acciones",
add_penalty: "Añadir penalización", edit: "Editar", delete: "Eliminar", save: "Guardar",
confirm_delete: "¿Eliminar este registro?", search_rule: "Buscar regla por número o texto",
suggested_penalty: "Penalización sugerida",
escalation: "Comportamiento al repetirse",
escalation_same: "Sin cambios", escalation_doubled: "Se duplica cada vez",
escalation_escalate: "Escala: ",
add_member: "Añadir miembro", remove: "Quitar",
add_user: "Crear usuario", users: "Usuarios",
display_name: "Nombre mostrado", is_admin: "Admin",
allow_any_scorer_edit: "Cualquier scorer puede editar penalizaciones",
open: "Abrir", back: "Atrás", change_password: "Cambiar contraseña",
new_password: "Nueva contraseña", csv_paste: "Pegar CSV (n.º,apellido,nombre,país,globo)",
no_pilots: "Sin pilotos", no_penalties: "Sin penalizaciones",
no_members: "Sin miembros", no_competitions: "Sin competiciones",
select_pilot: "Elegir piloto", rule_number_short: "N.º regla",
transferred_only: "Solo no transferidas",
showing_n_of_m: "{n} de {m}",
online: "En línea", offline: "Sin conexión",
forbidden: "No permitido",
save_settings: "Guardar ajustes",
saved: "Guardado",
yes: "Sí", no: "No",
},
pt: {
login_title: "Entrar",
username: "Utilizador", password: "Palavra-passe", sign_in: "Entrar",
invalid_credentials: "Utilizador ou palavra-passe inválidos",
logout: "Sair", settings: "Definições", language: "Idioma",
competitions: "Competições", new_competition: "Nova competição",
competition_name: "Nome", create: "Criar", cancel: "Cancelar",
role: "Papel", system_admin: "Administrador", chief_scorer: "Chief-Scorer", scorer: "Scorer",
pilots: "Pilotos", penalties: "Penalizações", members: "Membros", rules: "Regras",
settings_tab: "Definições",
number: "Número", last_name: "Apelido", first_name: "Nome",
country: "País", balloon_id: "Matrícula balão",
add_pilot: "Adicionar piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV",
flight: "Voo", date: "Data", pilot_number: "N.º piloto",
pilot_name: "Nome do piloto", rule: "Regra", task: "Tarefa",
penalty_values: "Penalizações", description: "Descrição", created_by: "Criado por",
transferred: "Transferido", actions: "Ações",
add_penalty: "Adicionar penalização", edit: "Editar", delete: "Eliminar", save: "Guardar",
confirm_delete: "Eliminar este registo?", search_rule: "Procurar regra por número ou texto",
suggested_penalty: "Penalização sugerida",
escalation: "Comportamento em caso de repetição",
escalation_same: "Sem alteração", escalation_doubled: "Duplicada de cada vez",
escalation_escalate: "Escala: ",
add_member: "Adicionar membro", remove: "Remover",
add_user: "Criar utilizador", users: "Utilizadores",
display_name: "Nome mostrado", is_admin: "Admin",
allow_any_scorer_edit: "Qualquer scorer pode editar penalizações",
open: "Abrir", back: "Voltar", change_password: "Alterar palavra-passe",
new_password: "Nova palavra-passe", csv_paste: "Colar CSV (n.º,apelido,nome,país,balão)",
no_pilots: "Sem pilotos", no_penalties: "Sem penalizações",
no_members: "Sem membros", no_competitions: "Sem competições",
select_pilot: "Escolher piloto", rule_number_short: "N.º regra",
transferred_only: "Apenas não transferidas",
showing_n_of_m: "{n} de {m}",
online: "Online", offline: "Offline",
forbidden: "Não autorizado",
save_settings: "Guardar definições",
saved: "Guardado",
yes: "Sim", no: "Não",
},
};
let CURRENT_LANG = "en";
function setLang(lang) {
if (!I18N_DATA[lang]) lang = "en";
CURRENT_LANG = lang;
document.documentElement.lang = lang;
}
function t(key, vars) {
const d = I18N_DATA[CURRENT_LANG] || I18N_DATA.en;
let str = d[key] || I18N_DATA.en[key] || key;
if (vars) {
for (const k in vars) {
str = str.replaceAll("{" + k + "}", vars[k]);
}
}
return str;
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Penalty Tracker</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script src="/config.js"></script>
<script src="/i18n.js"></script>
<script src="/api.js"></script>
<script src="/app.js"></script>
</body>
</html>
+399
View File
@@ -0,0 +1,399 @@
:root {
--accent: #2b6cb0;
--accent-hover: #2c5282;
--bg: #ffffff;
--fg: #111111;
--muted: #6b7280;
--border: #e5e7eb;
--row-hover: #f9fafb;
--danger: #b91c1c;
--radius: 0.375rem;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--fg);
font-size: 14px;
line-height: 1.45;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); text-decoration: underline; }
button, input, select, textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
border-radius: var(--radius);
border: 1px solid var(--border);
background: #fff;
padding: 0.4rem 0.6rem;
outline: none;
}
button {
cursor: pointer;
background: #fff;
transition: background 0.1s ease, border-color 0.1s ease;
}
button:hover { border-color: var(--accent); }
button.primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
button.danger {
background: #fff;
color: var(--danger);
border-color: var(--border);
}
button.danger:hover { border-color: var(--danger); }
button.ghost { border-color: transparent; background: transparent; }
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(43, 108, 176, 0.15);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border);
background: #fff;
position: sticky;
top: 0;
z-index: 10;
}
.topbar .brand {
font-weight: 600;
font-size: 1rem;
color: var(--accent);
}
.topbar .nav {
display: flex;
gap: 0.5rem;
align-items: center;
}
.container {
padding: 1.25rem;
max-width: 1400px;
margin: 0 auto;
}
.card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
background: #fff;
margin-bottom: 1rem;
}
.card h2 {
margin: 0 0 0.75rem 0;
font-size: 1rem;
font-weight: 600;
}
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.col { display: flex; flex-direction: column; gap: 0.5rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
}
.field { display: flex; flex-direction: column; gap: 0.25rem; }
.field label { font-size: 0.8rem; color: var(--muted); }
.login-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.login-box {
width: 100%;
max-width: 360px;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
background: #fff;
}
.login-box h1 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
color: var(--accent);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
font-weight: 600;
background: #fafafa;
position: sticky;
top: 0;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
th .sort-ind {
color: var(--muted);
font-size: 0.7rem;
margin-left: 0.25rem;
}
tbody tr:hover { background: var(--row-hover); }
tbody tr.transferred { background: #f3f6fb; }
.table-wrap {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: auto;
max-height: 70vh;
}
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: var(--radius);
font-size: 0.75rem;
background: #f3f4f6;
color: #374151;
border: 1px solid var(--border);
}
.badge.accent { background: rgba(43, 108, 176, 0.1); color: var(--accent); border-color: rgba(43, 108, 176, 0.3); }
.badge.warn { background: #fff7ed; color: #9a3412; border-color: #fed7aa; }
.muted { color: var(--muted); font-size: 0.85rem; }
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
z-index: 50;
padding: 1rem;
}
.modal {
background: #fff;
border-radius: var(--radius);
padding: 1.25rem;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow: auto;
}
.modal h3 { margin-top: 0; }
.toolbar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
.toolbar .spacer { flex: 1; }
.rule-suggestion {
background: #f9fafb;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
.rule-card {
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 0.625rem 0.875rem;
background: #fff;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.rule-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.rule-num {
color: var(--accent);
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85rem;
background: rgba(43, 108, 176, 0.08);
padding: 0.1rem 0.4rem;
border-radius: var(--radius);
white-space: nowrap;
}
.rule-text { font-size: 0.9rem; line-height: 1.3; }
.kv {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.8rem;
}
.kv .k { color: var(--muted); min-width: 9rem; }
.small { font-size: 0.8rem; }
.tier-row {
display: inline-flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
}
.tier-pill {
background: #fff;
border: 1px solid var(--border);
color: var(--fg);
padding: 0.1rem 0.45rem;
border-radius: var(--radius);
font-size: 0.75rem;
font-weight: 500;
}
.tier-pill:last-child {
border-color: var(--accent);
color: var(--accent);
background: rgba(43, 108, 176, 0.08);
}
.tier-arrow { color: var(--muted); font-size: 0.75rem; }
.rules-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 0.625rem;
}
.prior-box {
margin-top: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
background: #fafbfc;
}
.prior-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--accent);
margin-bottom: 0.4rem;
}
.mini-table { font-size: 12px; }
.mini-table th, .mini-table td {
padding: 0.3rem 0.4rem;
border-bottom: 1px solid var(--border);
background: transparent;
}
.mini-table th { background: transparent; position: static; }
.search-box {
position: relative;
width: 100%;
}
.search-box .results {
position: absolute;
top: 100%; left: 0; right: 0;
border: 1px solid var(--border);
background: #fff;
border-radius: var(--radius);
max-height: 240px;
overflow: auto;
z-index: 5;
margin-top: 0.25rem;
}
.search-box .results .item {
padding: 0.5rem 0.6rem;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.search-box .results .item:last-child { border-bottom: none; }
.search-box .results .item:hover { background: #f3f4f6; }
.search-box .results .item .num { color: var(--accent); font-weight: 600; margin-right: 0.5rem; }
.switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.switch .slider {
position: absolute;
cursor: pointer;
inset: 0;
background-color: #d1d5db;
transition: 0.15s;
border-radius: var(--radius);
}
.switch .slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
top: 3px;
background-color: white;
transition: 0.15s;
border-radius: var(--radius);
}
.switch input:checked + .slider { background-color: var(--accent); }
.switch input:checked + .slider:before { transform: translateX(16px); }
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.tabs button {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 0.5rem 0.75rem;
color: var(--muted);
}
.tabs button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.action-btn {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
background: transparent;
border: 1px solid transparent;
}
.action-btn:hover { border-color: var(--border); }
.connection-status {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #d1d5db;
margin-right: 0.5rem;
}
.connection-status.online { background: #10b981; }
.connection-status.offline { background: #ef4444; }
textarea { resize: vertical; min-height: 60px; }
@media (max-width: 700px) {
.topbar { padding: 0.5rem 0.75rem; }
.container { padding: 0.75rem; }
}
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"sync"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type wsMessage struct {
Type string `json:"type"`
Payload any `json:"payload,omitempty"`
}
type client struct {
conn *websocket.Conn
competitionID int64
send chan []byte
}
type Hub struct {
mu sync.RWMutex
clients map[int64]map[*client]struct{}
}
var hub *Hub
func newHub() *Hub {
return &Hub{clients: map[int64]map[*client]struct{}{}}
}
func (h *Hub) run() {}
func (h *Hub) register(c *client) {
h.mu.Lock()
defer h.mu.Unlock()
set, ok := h.clients[c.competitionID]
if !ok {
set = map[*client]struct{}{}
h.clients[c.competitionID] = set
}
set[c] = struct{}{}
}
func (h *Hub) unregister(c *client) {
h.mu.Lock()
defer h.mu.Unlock()
if set, ok := h.clients[c.competitionID]; ok {
if _, ok := set[c]; ok {
delete(set, c)
close(c.send)
}
if len(set) == 0 {
delete(h.clients, c.competitionID)
}
}
}
func (h *Hub) broadcast(competitionID int64, kind string, payload any) {
msg := wsMessage{Type: kind, Payload: payload}
data, err := json.Marshal(msg)
if err != nil {
return
}
h.mu.RLock()
defer h.mu.RUnlock()
set, ok := h.clients[competitionID]
if !ok {
return
}
for c := range set {
select {
case c.send <- data:
default:
}
}
}
func registerWSRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/competitions/{id}/ws", requireAuth(handleWS))
}
func handleWS(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
if _, ok := canAccessCompetition(u, id); !ok {
writeError(w, http.StatusForbidden, "forbidden")
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
c := &client{conn: conn, competitionID: id, send: make(chan []byte, 16)}
hub.register(c)
go writePump(c)
readPump(c)
}
func readPump(c *client) {
defer func() {
hub.unregister(c)
c.conn.Close()
}()
c.conn.SetReadLimit(1024)
for {
if _, _, err := c.conn.ReadMessage(); err != nil {
return
}
}
}
func writePump(c *client) {
defer c.conn.Close()
for msg := range c.send {
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}