so much
This commit is contained in:
1 parent
136a1c34b3
commit
cb87af4f3b
108 files changed
+49079
-41
No files matched your search
@@ -0,0 +1,69 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (api *Api) RegisterDeviceToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := GetUser(r)
|
||||
|
||||
var input struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := api.readJSON(w, r, &input); err != nil {
|
||||
api.BadRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if input.Token == "" {
|
||||
api.BadRequestResponse(w, r, errors.New("token must be provided"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.App.Models.DeviceTokens.Insert(user.ID, input.Token); err != nil {
|
||||
api.ServerErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *Api) DeleteDeviceToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := GetUser(r)
|
||||
|
||||
var input struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := api.readJSON(w, r, &input); err != nil {
|
||||
api.BadRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if input.Token == "" {
|
||||
api.BadRequestResponse(w, r, errors.New("token must be provided"))
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := api.App.Models.DeviceTokens.GetForUser(user.ID)
|
||||
if err != nil {
|
||||
api.ServerErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
owns := false
|
||||
for _, dt := range tokens {
|
||||
if dt.Token == input.Token {
|
||||
owns = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !owns {
|
||||
api.NotFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.App.Models.DeviceTokens.Delete(input.Token); err != nil {
|
||||
api.ServerErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func (api *Api) ListMPs(w http.ResponseWriter, r *http.Request) {
|
||||
members, err := api.App.Parlament.ListNRMembers("XXVIII")
|
||||
if err != nil {
|
||||
api.ServerErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(members, func(i, j int) bool {
|
||||
if members[i].FractionName != members[j].FractionName {
|
||||
return members[i].FractionName < members[j].FractionName
|
||||
}
|
||||
return members[i].LastName < members[j].LastName
|
||||
})
|
||||
|
||||
if err := api.writeJSON(w, http.StatusOK, envelope{
|
||||
"members": members,
|
||||
"total": len(members),
|
||||
}, nil); err != nil {
|
||||
api.ServerErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
func (api *Api) GetParlVoteDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ps := httprouter.ParamsFromContext(r.Context())
|
||||
routePath := ps.ByName("path") // e.g. "/XXVIII/I/14"
|
||||
routePath = strings.TrimPrefix(routePath, "/")
|
||||
if routePath == "" {
|
||||
api.BadRequestResponse(w, r, errors.New("path is required"))
|
||||
return
|
||||
}
|
||||
|
||||
path := "/gegenstand/" + routePath
|
||||
|
||||
detail, err := api.App.Parlament.GetDocumentVote(path)
|
||||
if err != nil {
|
||||
api.ServerErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if detail == nil {
|
||||
api.NotFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.writeJSON(w, http.StatusOK, envelope{"vote": detail}, nil); err != nil {
|
||||
api.ServerErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"party.at/party/cmd/party/parlament"
|
||||
)
|
||||
|
||||
type apiPartyStat struct {
|
||||
Code string `json:"code"`
|
||||
For int `json:"for"`
|
||||
Against int `json:"against"`
|
||||
}
|
||||
|
||||
type apiParlDocument struct {
|
||||
Date string `json:"date"`
|
||||
Title string `json:"title"`
|
||||
Number string `json:"number"`
|
||||
Type string `json:"type"`
|
||||
Topics []string `json:"topics"`
|
||||
VotesFor []string `json:"votes_for"`
|
||||
VotesAgainst []string `json:"votes_against"`
|
||||
RouteID string `json:"route_id"`
|
||||
}
|
||||
|
||||
func (api *Api) ListParlVotes(w http.ResponseWriter, r *http.Request) {
|
||||
all, _, err := api.App.Parlament.ListDocuments(parlament.DocumentFilter{
|
||||
Chamber: []string{"NR"},
|
||||
Period: []string{parlament.CurrentPeriod},
|
||||
})
|
||||
if err != nil {
|
||||
api.ServerErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
docs := make([]apiParlDocument, 0, len(all))
|
||||
for _, d := range all {
|
||||
docs = append(docs, apiParlDocument{
|
||||
Date: d.Date,
|
||||
Title: d.Title,
|
||||
Number: d.Number,
|
||||
Type: d.Type,
|
||||
Topics: parseStringArray(d.Topics),
|
||||
VotesFor: parseStringArray(d.VotesFor),
|
||||
VotesAgainst: parseStringArray(d.VotesAgainst),
|
||||
RouteID: d.RouteID,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(docs, func(i, j int) bool {
|
||||
di, _ := time.Parse("02.01.2006", docs[i].Date)
|
||||
dj, _ := time.Parse("02.01.2006", docs[j].Date)
|
||||
return di.After(dj)
|
||||
})
|
||||
|
||||
forCount := make(map[string]int)
|
||||
againstCount := make(map[string]int)
|
||||
totalWithVotes := 0
|
||||
for _, d := range docs {
|
||||
if len(d.VotesFor) == 0 && len(d.VotesAgainst) == 0 {
|
||||
continue
|
||||
}
|
||||
totalWithVotes++
|
||||
for _, p := range d.VotesFor {
|
||||
forCount[p]++
|
||||
}
|
||||
for _, p := range d.VotesAgainst {
|
||||
againstCount[p]++
|
||||
}
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for p := range forCount {
|
||||
seen[p] = true
|
||||
}
|
||||
for p := range againstCount {
|
||||
seen[p] = true
|
||||
}
|
||||
partyStats := make([]apiPartyStat, 0, len(seen))
|
||||
for code := range seen {
|
||||
partyStats = append(partyStats, apiPartyStat{
|
||||
Code: code,
|
||||
For: forCount[code],
|
||||
Against: againstCount[code],
|
||||
})
|
||||
}
|
||||
sort.Slice(partyStats, func(i, j int) bool {
|
||||
ti := partyStats[i].For + partyStats[i].Against
|
||||
tj := partyStats[j].For + partyStats[j].Against
|
||||
if ti != tj {
|
||||
return ti > tj
|
||||
}
|
||||
return partyStats[i].Code < partyStats[j].Code
|
||||
})
|
||||
|
||||
if err := api.writeJSON(w, http.StatusOK, envelope{
|
||||
"documents": docs,
|
||||
"total": len(docs),
|
||||
"total_with_votes": totalWithVotes,
|
||||
"party_stats": partyStats,
|
||||
}, nil); err != nil {
|
||||
api.ServerErrorResponse(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseStringArray(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
apns "github.com/sideshow/apns2"
|
||||
"party.at/party/cmd/party/parlament"
|
||||
"party.at/party/internal/data"
|
||||
"party.at/party/internal/jsonlog"
|
||||
@@ -36,6 +38,13 @@ type Config struct {
|
||||
CORS struct {
|
||||
TrustedOrigins []string
|
||||
}
|
||||
|
||||
APNS struct {
|
||||
KeyPath string
|
||||
KeyID string
|
||||
TeamID string
|
||||
BundleID string
|
||||
}
|
||||
}
|
||||
|
||||
type Application struct {
|
||||
@@ -44,6 +53,7 @@ type Application struct {
|
||||
Models data.Models
|
||||
Mailer mailer.Mailer
|
||||
Parlament *parlament.Client
|
||||
APNS *apns.Client
|
||||
Env string
|
||||
|
||||
LimiterEnabled bool
|
||||
|
||||
+23
-21
@@ -16,6 +16,8 @@ import (
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/gorilla/websocket"
|
||||
apns "github.com/sideshow/apns2"
|
||||
"github.com/sideshow/apns2/token"
|
||||
|
||||
"party.at/party/cmd/party/common"
|
||||
"party.at/party/internal/data"
|
||||
@@ -38,7 +40,6 @@ var upgrader = websocket.Upgrader{
|
||||
}
|
||||
|
||||
func main() {
|
||||
test()
|
||||
|
||||
var cfg common.Config
|
||||
|
||||
@@ -65,6 +66,11 @@ func main() {
|
||||
return nil
|
||||
})
|
||||
|
||||
flag.StringVar(&cfg.APNS.KeyPath, "apns-key-path", os.Getenv("APNS_KEY_PATH"), "Path to APNs .p8 auth key file")
|
||||
flag.StringVar(&cfg.APNS.KeyID, "apns-key-id", os.Getenv("APNS_KEY_ID"), "APNs key ID")
|
||||
flag.StringVar(&cfg.APNS.TeamID, "apns-team-id", os.Getenv("APNS_TEAM_ID"), "Apple Developer team ID")
|
||||
flag.StringVar(&cfg.APNS.BundleID, "apns-bundle-id", os.Getenv("APNS_BUNDLE_ID"), "iOS app bundle ID")
|
||||
|
||||
displayVersion := flag.Bool("version", false, "Display version and exit")
|
||||
flag.Parse()
|
||||
|
||||
@@ -89,16 +95,32 @@ func main() {
|
||||
expvar.Publish("database", expvar.Func(func() any { return db.Stats() }))
|
||||
expvar.Publish("timestamp", expvar.Func(func() any { return time.Now().Unix() }))
|
||||
|
||||
var apnsClient *apns.Client
|
||||
if cfg.APNS.KeyPath != "" && cfg.APNS.KeyID != "" && cfg.APNS.TeamID != "" {
|
||||
authKey, err := token.AuthKeyFromFile(cfg.APNS.KeyPath)
|
||||
if err != nil {
|
||||
logger.PrintFatal(fmt.Errorf("apns: %w", err), nil)
|
||||
}
|
||||
apnsClient = apns.NewTokenClient(&token.Token{
|
||||
AuthKey: authKey,
|
||||
KeyID: cfg.APNS.KeyID,
|
||||
TeamID: cfg.APNS.TeamID,
|
||||
}).Production()
|
||||
}
|
||||
|
||||
app := &common.Application{
|
||||
Config: cfg,
|
||||
Logger: logger,
|
||||
Models: data.NewModels(db),
|
||||
Mailer: mailer.New(cfg.SMTP.Host, cfg.SMTP.Port, cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.Sender),
|
||||
Parlament: parlament.NewClient(),
|
||||
APNS: apnsClient,
|
||||
}
|
||||
|
||||
log.Println("Hello, Sailor!")
|
||||
|
||||
startParlWatcher(app)
|
||||
|
||||
if err = serve(app); err != nil {
|
||||
logger.PrintFatal(err, nil)
|
||||
}
|
||||
@@ -126,23 +148,3 @@ func openDB(cfg common.Config) (*sql.DB, error) {
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func test() {
|
||||
client := parlament.NewClient()
|
||||
|
||||
sessions, total, err := client.ListSessions(parlament.SessionFilter{
|
||||
Chamber: []string{"NR"},
|
||||
Period: []string{"XXVIII"},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d sessions\n\n", total)
|
||||
for i, s := range sessions {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
fmt.Printf("%s | %s\n", s.Date, s.Title)
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,17 @@ func colIndex(headers []colMeta) map[string]int {
|
||||
return m
|
||||
}
|
||||
|
||||
func strCol(row []any, idx map[string]int, label string) string {
|
||||
func anyCol(row []any, idx map[string]int, label string) any {
|
||||
i, ok := idx[label]
|
||||
if !ok || i >= len(row) {
|
||||
return nil
|
||||
}
|
||||
return row[i]
|
||||
}
|
||||
|
||||
func strCol(row []any, idx map[string]int, label string) string {
|
||||
i, ok := idx[label]
|
||||
if !ok || i >= len(row) || row[i] == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := row[i].(string); ok {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package parlament
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CurrentPeriod is the active legislative period code.
|
||||
const CurrentPeriod = "XXVIII"
|
||||
|
||||
// DocumentType selects which legislative document category to query.
|
||||
type DocumentType string
|
||||
|
||||
@@ -21,11 +29,14 @@ type Document struct {
|
||||
GPCode string
|
||||
Type string // "Art" column
|
||||
Topics string
|
||||
VoteResult string // "Abstimmung 3. Lesung"
|
||||
VotesFor string // "Dafür" — party names
|
||||
VotesAgainst string // "Dagegen" — party names
|
||||
VoteResult string // "Abstimmung 3. Lesung" — "1" when voted on
|
||||
VotesFor string // "Dafür" — party names (JSON array)
|
||||
VotesAgainst string // "Dagegen" — party names (JSON array)
|
||||
VoteText string // "Abstimmungstext" — compact summary e.g. "Dafür: V, S, N, G, Dagegen: F"
|
||||
// Path is the portal-relative URL, usable with Client.GetDetail.
|
||||
Path string // "HIS_URL" column
|
||||
Path string // "HIS_URL" column e.g. "/gegenstand/XXVIII/I/14"
|
||||
// RouteID is the path segments after /gegenstand/, e.g. "XXVIII/I/14".
|
||||
RouteID string
|
||||
}
|
||||
|
||||
type DocumentFilter struct {
|
||||
@@ -78,12 +89,176 @@ func (c *Client) ListDocuments(f DocumentFilter) ([]Document, int, error) {
|
||||
VoteResult: strCol(row, idx, "Abstimmung 3. Lesung"),
|
||||
VotesFor: strCol(row, idx, "Dafür"),
|
||||
VotesAgainst: strCol(row, idx, "Dagegen"),
|
||||
Path: strCol(row, idx, "HIS_URL"),
|
||||
VoteText: strCol(row, idx, "Abstimmungstext"),
|
||||
Path: strCol(row, idx, "HIS_URL"),
|
||||
RouteID: strings.TrimPrefix(strCol(row, idx, "HIS_URL"), "/gegenstand/"),
|
||||
})
|
||||
}
|
||||
return docs, resp.Count, nil
|
||||
}
|
||||
|
||||
// VoteFaction holds one faction's vote in a plenary division.
|
||||
type VoteFaction struct {
|
||||
Name string
|
||||
Code string
|
||||
Color string
|
||||
Seats int
|
||||
InFavor bool
|
||||
}
|
||||
|
||||
// DocFile is a single downloadable file linked from a document.
|
||||
type DocFile struct {
|
||||
Link string
|
||||
Type string // "PDF" or "HTML"
|
||||
}
|
||||
|
||||
// DocGroup is a named group of files (e.g. "Gesetzestext", "Beschlussformel NR").
|
||||
type DocGroup struct {
|
||||
Title string
|
||||
Files []DocFile
|
||||
}
|
||||
|
||||
// DocumentVoteDetail holds the structured vote result for a single document.
|
||||
type DocumentVoteDetail struct {
|
||||
Title string
|
||||
Description string
|
||||
Date string
|
||||
DocType string
|
||||
Zitation string
|
||||
Passed bool
|
||||
Summary string // e.g. "Dafür: V, S, N, G, Dagegen: F"
|
||||
Comment string
|
||||
Factions []VoteFaction
|
||||
ForSeats int
|
||||
AgainstSeats int
|
||||
ForPercent int // 0–100, for the seat bar width
|
||||
DocGroups []DocGroup
|
||||
Topics []string
|
||||
}
|
||||
|
||||
// GetDocumentVote fetches the vote detail for a document via its portal path
|
||||
// (e.g. "/gegenstand/XXVIII/I/14"). Returns nil, nil when the document has
|
||||
// no vote data.
|
||||
func (c *Client) GetDocumentVote(path string) (*DocumentVoteDetail, error) {
|
||||
raw, err := c.GetDetail(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// content may be a single object or an array; normalise to map.
|
||||
var content map[string]any
|
||||
switch v := raw["content"].(type) {
|
||||
case map[string]any:
|
||||
content = v
|
||||
case []any:
|
||||
if len(v) > 0 {
|
||||
content, _ = v[0].(map[string]any)
|
||||
}
|
||||
}
|
||||
if content == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
voteRaw, _ := content["vote"].(map[string]any)
|
||||
|
||||
detail := &DocumentVoteDetail{
|
||||
Title: strAny(content["title"]),
|
||||
Description: strAny(content["description"]),
|
||||
Date: strAny(content["einlangen"]),
|
||||
DocType: strAny(content["type"]),
|
||||
Zitation: strAny(content["zitation"]),
|
||||
}
|
||||
if voteRaw != nil {
|
||||
detail.Passed = boolAny(voteRaw["infavor"])
|
||||
detail.Summary = strAny(voteRaw["text"])
|
||||
detail.Comment = strAny(voteRaw["comment"])
|
||||
}
|
||||
|
||||
// Parse factions and accumulate seat totals.
|
||||
if results, ok := voteRaw["result"].([]any); ok {
|
||||
for _, r := range results {
|
||||
fr, _ := r.(map[string]any)
|
||||
if fr == nil {
|
||||
continue
|
||||
}
|
||||
seats := 0
|
||||
if n, ok := fr["fraction"].(float64); ok {
|
||||
seats = int(n)
|
||||
}
|
||||
f := VoteFaction{
|
||||
Name: strAny(fr["text"]),
|
||||
Code: strAny(fr["code"]),
|
||||
Color: strAny(fr["color"]),
|
||||
Seats: seats,
|
||||
InFavor: boolAny(fr["infavor"]),
|
||||
}
|
||||
detail.Factions = append(detail.Factions, f)
|
||||
if f.InFavor {
|
||||
detail.ForSeats += seats
|
||||
} else {
|
||||
detail.AgainstSeats += seats
|
||||
}
|
||||
}
|
||||
}
|
||||
if total := detail.ForSeats + detail.AgainstSeats; total > 0 {
|
||||
detail.ForPercent = detail.ForSeats * 100 / total
|
||||
}
|
||||
|
||||
// Parse document groups.
|
||||
if docs, ok := content["documents"].([]any); ok {
|
||||
for _, dg := range docs {
|
||||
grp, _ := dg.(map[string]any)
|
||||
if grp == nil {
|
||||
continue
|
||||
}
|
||||
g := DocGroup{Title: strAny(grp["title"])}
|
||||
if files, ok := grp["documents"].([]any); ok {
|
||||
for _, f := range files {
|
||||
fm, _ := f.(map[string]any)
|
||||
if fm == nil {
|
||||
continue
|
||||
}
|
||||
g.Files = append(g.Files, DocFile{
|
||||
Link: strAny(fm["link"]),
|
||||
Type: strAny(fm["type"]),
|
||||
})
|
||||
}
|
||||
}
|
||||
detail.DocGroups = append(detail.DocGroups, g)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse topic bubbles.
|
||||
if topicsRaw, ok := content["topics"].(map[string]any); ok {
|
||||
if data, ok := topicsRaw["data"].(map[string]any); ok {
|
||||
if bubbles, ok := data["bubbles"].([]any); ok {
|
||||
for _, b := range bubbles {
|
||||
if bm, ok := b.(map[string]any); ok {
|
||||
detail.Topics = append(detail.Topics, strAny(bm["label"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func strAny(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
func boolAny(v any) bool {
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
// Protocol is a stenographic protocol of a plenary session.
|
||||
type Protocol struct {
|
||||
Date string
|
||||
|
||||
@@ -42,13 +42,14 @@ func (c *Client) ListParliamentarians(f ParliamentarianFilter) ([]Parliamentaria
|
||||
return mps, resp.Count, nil
|
||||
}
|
||||
|
||||
// Member is a richer parliamentarian record from dataset 401, which supports
|
||||
// filtering by active status, legislative period, and parliamentary body.
|
||||
// Member is a richer parliamentarian record, which supports filtering by
|
||||
// active status, legislative period, and parliamentary body.
|
||||
type Member struct {
|
||||
Name string // "Nachname Vorname" format
|
||||
FirstName string
|
||||
LastName string
|
||||
Faction string // party abbreviation, e.g. "FPÖ"
|
||||
FractionName string // full parliamentary club name, e.g. "Freiheitlicher Parlamentsklub"
|
||||
Role string // current role, e.g. "Abgeordnete zum Nationalrat"
|
||||
Constituency string // "Wahlkreis"
|
||||
// PhotoURL is the partial image path (prepend baseURL to resolve).
|
||||
@@ -116,3 +117,69 @@ func (c *Client) ListMembers(f MemberFilter) ([]Member, int, error) {
|
||||
}
|
||||
return members, resp.Count, nil
|
||||
}
|
||||
|
||||
// ListNRMembers returns active Nationalrat deputies for the given legislative
|
||||
// period, including each member's full parliamentary Fraktion (club) name.
|
||||
// It fetches from dataset 409 and filters client-side because that dataset's
|
||||
// nested Attribute object is the only source for the proper Fraktion name.
|
||||
func (c *Client) ListNRMembers(period string) ([]Member, error) {
|
||||
resp, err := c.postDataset(409, map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idx := colIndex(resp.Header)
|
||||
var members []Member
|
||||
for _, row := range resp.Rows {
|
||||
attr, _ := anyCol(row, idx, "Attribute").(map[string]any)
|
||||
if attr == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep only active members of the requested period.
|
||||
if active, _ := attr["aktiv"].(bool); !active {
|
||||
continue
|
||||
}
|
||||
gpCodes, _ := attr["gp_code"].([]any)
|
||||
inPeriod := false
|
||||
for _, gp := range gpCodes {
|
||||
if gp == period {
|
||||
inPeriod = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inPeriod {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the NR mandate for this period to get the Fraktion name.
|
||||
faction, fractionName := "", ""
|
||||
mandates, _ := attr["mandate_detail"].([]any)
|
||||
for _, m := range mandates {
|
||||
mandate, _ := m.(map[string]any)
|
||||
if mandate == nil {
|
||||
continue
|
||||
}
|
||||
if mandate["gp_code"] == period && mandate["mand_code"] == "NR" {
|
||||
faction, _ = mandate["wahlpartei_code"].(string)
|
||||
fractionName, _ = mandate["fraktion"].(string)
|
||||
break
|
||||
}
|
||||
}
|
||||
if fractionName == "" {
|
||||
continue // not an NR mandate in this period
|
||||
}
|
||||
|
||||
members = append(members, Member{
|
||||
Name: strCol(row, idx, "Name"),
|
||||
FirstName: strCol(row, idx, "vorname"),
|
||||
LastName: strCol(row, idx, "nachname"),
|
||||
Faction: faction,
|
||||
FractionName: fractionName,
|
||||
Constituency: strCol(row, idx, "Wahlkreis"),
|
||||
Path: strCol(row, idx, "Bio"),
|
||||
Active: true,
|
||||
})
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
apns "github.com/sideshow/apns2"
|
||||
"party.at/party/cmd/party/common"
|
||||
"party.at/party/cmd/party/parlament"
|
||||
)
|
||||
|
||||
const watcherInterval = 15 * time.Minute
|
||||
|
||||
func startParlWatcher(app *common.Application) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
app.Logger.PrintError(fmt.Errorf("%s", err), nil)
|
||||
}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(watcherInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run once immediately so we don't wait 15 min on first boot.
|
||||
pollParlVotes(app)
|
||||
for range ticker.C {
|
||||
pollParlVotes(app)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func pollParlVotes(app *common.Application) {
|
||||
// Use yesterday as the start date to avoid missing votes near midnight.
|
||||
dateFrom := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
|
||||
docs, _, err := app.Parlament.ListDocuments(parlament.DocumentFilter{
|
||||
Chamber: []string{"NR"},
|
||||
Period: []string{parlament.CurrentPeriod},
|
||||
DateFrom: dateFrom,
|
||||
})
|
||||
if err != nil {
|
||||
app.Logger.PrintError(err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep only documents that have vote data.
|
||||
var withVotes []parlament.Document
|
||||
for _, d := range docs {
|
||||
if d.VotesFor != "" || d.VotesAgainst != "" {
|
||||
withVotes = append(withVotes, d)
|
||||
}
|
||||
}
|
||||
if len(withVotes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
numbers := make([]string, len(withVotes))
|
||||
for i, d := range withVotes {
|
||||
numbers[i] = d.Number
|
||||
}
|
||||
|
||||
newNumbers, err := app.Models.ParlWatcher.FilterNew(numbers)
|
||||
if err != nil {
|
||||
app.Logger.PrintError(err, nil)
|
||||
return
|
||||
}
|
||||
if len(newNumbers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
newSet := make(map[string]bool, len(newNumbers))
|
||||
for _, n := range newNumbers {
|
||||
newSet[n] = true
|
||||
}
|
||||
|
||||
var newDocs []parlament.Document
|
||||
for _, d := range withVotes {
|
||||
if newSet[d.Number] {
|
||||
newDocs = append(newDocs, d)
|
||||
}
|
||||
}
|
||||
|
||||
users, err := app.Models.Users.GetAllActivated()
|
||||
if err != nil {
|
||||
app.Logger.PrintError(err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
err := app.Mailer.Send(u.Email, "parl_new_votes.tmpl", map[string]any{
|
||||
"Name": u.Name,
|
||||
"Docs": newDocs,
|
||||
"Count": len(newDocs),
|
||||
})
|
||||
if err != nil {
|
||||
app.Logger.PrintError(err, map[string]string{"recipient": u.Email})
|
||||
}
|
||||
|
||||
if app.APNS != nil {
|
||||
sendAPNSPush(app, u.ID, len(newDocs))
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.Models.ParlWatcher.MarkNotified(newNumbers); err != nil {
|
||||
app.Logger.PrintError(err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
app.Logger.PrintInfo("parl watcher: notified new votes", map[string]string{
|
||||
"count": fmt.Sprintf("%d", len(newDocs)),
|
||||
})
|
||||
}
|
||||
|
||||
func sendAPNSPush(app *common.Application, userID int64, count int) {
|
||||
deviceTokens, err := app.Models.DeviceTokens.GetForUser(userID)
|
||||
if err != nil {
|
||||
app.Logger.PrintError(err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
noun := "Abstimmung"
|
||||
if count != 1 {
|
||||
noun = "Abstimmungen"
|
||||
}
|
||||
alert := fmt.Sprintf("%d neue %s im Nationalrat", count, noun)
|
||||
|
||||
for _, dt := range deviceTokens {
|
||||
notification := &apns.Notification{
|
||||
DeviceToken: dt.Token,
|
||||
Topic: app.Config.APNS.BundleID,
|
||||
Payload: []byte(fmt.Sprintf(`{"aps":{"alert":%q,"sound":"default"}}`, alert)),
|
||||
}
|
||||
resp, err := app.APNS.Push(notification)
|
||||
if err != nil {
|
||||
app.Logger.PrintError(err, map[string]string{"device_token": dt.Token})
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode == 410 {
|
||||
// Device unregistered — clean up the stale token.
|
||||
if delErr := app.Models.DeviceTokens.Delete(dt.Token); delErr != nil {
|
||||
app.Logger.PrintError(delErr, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,13 @@ func routes(app *common.Application) http.Handler {
|
||||
apiRouter.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", api.CreateAuthenticationToken)
|
||||
apiRouter.HandlerFunc(http.MethodDelete, "/v1/tokens/authentication", api.RequireAuthenticatedUser(api.DeleteAuthenticationToken))
|
||||
|
||||
apiRouter.HandlerFunc(http.MethodPost, "/v1/device-tokens", api.RequireAuthenticatedUser(api.RegisterDeviceToken))
|
||||
apiRouter.HandlerFunc(http.MethodDelete, "/v1/device-tokens", api.RequireAuthenticatedUser(api.DeleteDeviceToken))
|
||||
|
||||
apiRouter.HandlerFunc(http.MethodGet, "/v1/mps", api.ListMPs)
|
||||
apiRouter.HandlerFunc(http.MethodGet, "/v1/parlament/votes", api.ListParlVotes)
|
||||
apiRouter.Handler(http.MethodGet, "/v1/parlament/votes/*path", http.HandlerFunc(api.GetParlVoteDetail))
|
||||
|
||||
apiRouter.Handler(http.MethodGet, "/debug/vars", expvar.Handler())
|
||||
|
||||
apiChain := app.Metrics(api.RecoverPanic(app.EnableCORS(api.RateLimit(api.Authenticate(apiRouter)))))
|
||||
@@ -68,6 +75,8 @@ func routes(app *common.Application) http.Handler {
|
||||
webRouter.HandlerFunc(http.MethodPost, "/users/activated", web.ActivateUserAction)
|
||||
webRouter.HandlerFunc(http.MethodDelete, "/users/:id", web.DeleteUserAction)
|
||||
webRouter.HandlerFunc(http.MethodGet, "/mps", web.MembersOfParliamentPage)
|
||||
webRouter.HandlerFunc(http.MethodGet, "/parlament", web.ParlVotesPage)
|
||||
webRouter.Handler(http.MethodGet, "/parlament/*path", http.HandlerFunc(web.ParlamentDispatch))
|
||||
webRouter.HandlerFunc(http.MethodPost, "/login", web.Login)
|
||||
webRouter.HandlerFunc(http.MethodPost, "/dev-login", web.DevLogin)
|
||||
webRouter.HandlerFunc(http.MethodPost, "/logout", web.Logout)
|
||||
|
||||
+7
-10
@@ -14,26 +14,23 @@ type factionGroup struct {
|
||||
}
|
||||
|
||||
func (web *Web) MembersOfParliamentPage(w http.ResponseWriter, r *http.Request) {
|
||||
members, _, err := web.App.Parlament.ListMembers(parlament.MemberFilter{
|
||||
Period: []string{"XXVIII"},
|
||||
Active: true,
|
||||
Type: []string{parlament.MemberTypeNR},
|
||||
})
|
||||
members, err := web.App.Parlament.ListNRMembers("XXVIII")
|
||||
if err != nil {
|
||||
web.App.LogError(r, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Group by faction, preserving insertion order for stable sort later.
|
||||
// Group by FractionName, preserving insertion order for stable sort later.
|
||||
groupMap := make(map[string]*factionGroup)
|
||||
var factionOrder []string
|
||||
for _, m := range members {
|
||||
if _, exists := groupMap[m.Faction]; !exists {
|
||||
groupMap[m.Faction] = &factionGroup{Name: m.Faction}
|
||||
factionOrder = append(factionOrder, m.Faction)
|
||||
key := m.FractionName
|
||||
if _, exists := groupMap[key]; !exists {
|
||||
groupMap[key] = &factionGroup{Name: key}
|
||||
factionOrder = append(factionOrder, key)
|
||||
}
|
||||
groupMap[m.Faction].Members = append(groupMap[m.Faction].Members, m)
|
||||
groupMap[key].Members = append(groupMap[key].Members, m)
|
||||
}
|
||||
|
||||
// Sort factions by descending seat count.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"party.at/party/cmd/party/parlament"
|
||||
"party.at/party/internal/data"
|
||||
)
|
||||
|
||||
func (web *Web) ParlamentDispatch(w http.ResponseWriter, r *http.Request) {
|
||||
ps := httprouter.ParamsFromContext(r.Context())
|
||||
if ps.ByName("path") == "/rows" {
|
||||
web.ParlVotesRowsFragment(w, r)
|
||||
return
|
||||
}
|
||||
web.ParlVoteDetailPage(w, r)
|
||||
}
|
||||
|
||||
func (web *Web) ParlVoteDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
ps := httprouter.ParamsFromContext(r.Context())
|
||||
routePath := ps.ByName("path") // e.g. "/XXVIII/I/14"
|
||||
if routePath == "" || routePath == "/" {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
path := "/gegenstand" + routePath
|
||||
|
||||
detail, err := web.App.Parlament.GetDocumentVote(path)
|
||||
if err != nil {
|
||||
web.App.LogError(r, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if detail == nil {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
web.render(w, r, http.StatusOK, "parlvotedetail", struct {
|
||||
AuthenticatedUser *data.User
|
||||
Detail *parlament.DocumentVoteDetail
|
||||
Path string
|
||||
}{
|
||||
AuthenticatedUser: getUser(r),
|
||||
Detail: detail,
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"party.at/party/cmd/party/parlament"
|
||||
"party.at/party/internal/data"
|
||||
)
|
||||
|
||||
type parlVoteView struct {
|
||||
Date string
|
||||
Title string
|
||||
Number string
|
||||
Type string
|
||||
Topics []string
|
||||
VotesFor []string
|
||||
VotesAgainst []string
|
||||
RouteID string
|
||||
}
|
||||
|
||||
type parlTypeGroup struct {
|
||||
Code string
|
||||
Label string
|
||||
Docs []parlVoteView
|
||||
HasVotes bool
|
||||
}
|
||||
|
||||
type partyStat struct {
|
||||
Code string
|
||||
For int
|
||||
Against int
|
||||
}
|
||||
|
||||
|
||||
var typeLabels = map[string]string{
|
||||
"J": "Schriftliche Anfrage",
|
||||
"M": "Mündliche Anfrage",
|
||||
"JPR": "Schriftliche Anfrage an Präsidium",
|
||||
"ABPR": "Anfragebeantwortung Präsidium",
|
||||
"AB": "Anfragebeantwortung",
|
||||
"A": "Selbständiger Antrag",
|
||||
"A(E)": "Selbständiger Entschließungsantrag",
|
||||
"AA": "Abänderungsantrag im Plenum",
|
||||
"AAA": "Abänderungsantrag im Ausschuss",
|
||||
"AEA": "Selbständiger Ausschuss-Entschließungsantrag",
|
||||
"AUA": "Ausschussbegutachtung",
|
||||
"AUB": "Ausschussbericht",
|
||||
"UEA": "Unselbständiger Entschließungsantrag",
|
||||
"EAA": "Entschließungsantrag im Ausschuss",
|
||||
"E": "Entschließung",
|
||||
"RV": "Regierungsvorlage",
|
||||
"RVS": "Regierungsvorlage: Staatsvertrag",
|
||||
"BNR": "Beschluss des Nationalrates",
|
||||
"BS": "Sonstiger Beschluss",
|
||||
"BUA": "Bericht und Antrag",
|
||||
"BRG": "Bericht der Bundesregierung",
|
||||
"BRH": "Bericht des Rechnungshofs",
|
||||
"BRA": "Bundesrechnungsabschluss",
|
||||
"BEU": "Bericht der Bundesregierung zu EU-Themen",
|
||||
"BV": "Bericht der Volksanwaltschaft",
|
||||
"BUV": "Bericht des Unvereinbarkeitsausschusses",
|
||||
"BA": "Vorlage für den Budgetausschuss",
|
||||
"BI": "Bürgerinitiative",
|
||||
"PET": "Petition",
|
||||
"VOLKBG": "Volksbegehren",
|
||||
"IMM": "Immunitätsangelegenheit",
|
||||
"AMIN": "Selbständiger Antrag für eine Ministeranklage",
|
||||
"WA": "Wahlvorgang/Abstimmungsvorgang",
|
||||
"WF": "Wahl von Funktionsträgern",
|
||||
"WV": "Ernennungsvorschlag",
|
||||
"RGER": "Regierungserklärung im Plenum",
|
||||
"KOMM": "Kommuniqué",
|
||||
"KOMMAUS": "Kommuniqué (Ausschuss)",
|
||||
"AS": "Aktuelle Stunde",
|
||||
"ASEU": "Aktuelle Europastunde",
|
||||
"FS": "Fragestunde",
|
||||
"SINF": "Schriftliche Information (§ 31c GOG-NR)",
|
||||
"TRAU": "Trauerkundgebung",
|
||||
"USVL": "Verlangen auf Einsetzung eines Untersuchungsausschusses",
|
||||
"FVUSE": "Feststellung der Einsetzung eines Untersuchungsausschusses",
|
||||
"URH2": "Verlangen auf Gebarungsüberprüfung (Rechnungshofausschuss)",
|
||||
"EUSV": "Berichte über Sitzungen von vorbereitenden EU-Gremien",
|
||||
"EUBM": "Berichte über Sitzungen des Europäischen Rates oder des Rates",
|
||||
"EUPA": "Berichte über EP-Plenarsitzungen",
|
||||
"BEU2": "Bericht der Bundesregierung zu EU-Themen",
|
||||
"GO01": "Antrag auf erste Lesung",
|
||||
"GO04": "Erklärung Präsidentin/Präsident",
|
||||
"GO05": "Unterbrechung der Sitzung",
|
||||
"GO06": "Wortmeldung zur Geschäftsbehandlung",
|
||||
"GO09": "Ordnungsruf",
|
||||
"GO12": "Einberufung einer Tagung",
|
||||
"GO13": "Beendigung einer Tagung",
|
||||
"GO14": "Antrag auf Fristsetzung für Bericht",
|
||||
"GO18": "Gestaltung und Dauer der Redezeit",
|
||||
"GO20": "Vorschlag Präsidium auf Durchführung einer Debatte",
|
||||
"GO32": "Zuschrift",
|
||||
"GO35": "Unterrichtung",
|
||||
"GO53": "Antrag auf Rückverweisung",
|
||||
"GO67": "Tatsächliche Berichtigung",
|
||||
"GO71": "Aktionismus",
|
||||
"GO75": "Antrag auf Nichtkenntnisnahme einer Anfragebeantwortung",
|
||||
"GO99": "Sonstige Geschäftsordnungsangelegenheit",
|
||||
}
|
||||
|
||||
func parseJSONStringArray(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (web *Web) ParlVotesPage(w http.ResponseWriter, r *http.Request) {
|
||||
web.render(w, r, http.StatusOK, "parlvotes", struct {
|
||||
AuthenticatedUser *data.User
|
||||
Period string
|
||||
}{
|
||||
AuthenticatedUser: getUser(r),
|
||||
Period: parlament.CurrentPeriod,
|
||||
})
|
||||
}
|
||||
|
||||
func (web *Web) ParlVotesRowsFragment(w http.ResponseWriter, r *http.Request) {
|
||||
all, _, err := web.App.Parlament.ListDocuments(parlament.DocumentFilter{
|
||||
Chamber: []string{"NR"},
|
||||
Period: []string{parlament.CurrentPeriod},
|
||||
})
|
||||
if err != nil {
|
||||
web.App.LogError(r, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build views, sorted by date descending.
|
||||
views := make([]parlVoteView, 0, len(all))
|
||||
for _, d := range all {
|
||||
views = append(views, parlVoteView{
|
||||
Date: d.Date,
|
||||
Title: d.Title,
|
||||
Number: d.Number,
|
||||
Type: d.Type,
|
||||
Topics: parseJSONStringArray(d.Topics),
|
||||
VotesFor: parseJSONStringArray(d.VotesFor),
|
||||
VotesAgainst: parseJSONStringArray(d.VotesAgainst),
|
||||
RouteID: d.RouteID,
|
||||
})
|
||||
}
|
||||
sort.Slice(views, func(i, j int) bool {
|
||||
di, _ := time.Parse("02.01.2006", views[i].Date)
|
||||
dj, _ := time.Parse("02.01.2006", views[j].Date)
|
||||
return di.After(dj)
|
||||
})
|
||||
|
||||
// Group by type, preserving insertion order for stable ordering.
|
||||
groupMap := make(map[string]*parlTypeGroup)
|
||||
var order []string
|
||||
for _, v := range views {
|
||||
if _, exists := groupMap[v.Type]; !exists {
|
||||
label := typeLabels[v.Type]
|
||||
if label == "" {
|
||||
label = v.Type
|
||||
}
|
||||
groupMap[v.Type] = &parlTypeGroup{Code: v.Type, Label: label}
|
||||
order = append(order, v.Type)
|
||||
}
|
||||
groupMap[v.Type].Docs = append(groupMap[v.Type].Docs, v)
|
||||
if len(v.VotesFor) > 0 || len(v.VotesAgainst) > 0 {
|
||||
groupMap[v.Type].HasVotes = true
|
||||
}
|
||||
}
|
||||
|
||||
// Sort groups by size descending.
|
||||
sort.Slice(order, func(i, j int) bool {
|
||||
return len(groupMap[order[i]].Docs) > len(groupMap[order[j]].Docs)
|
||||
})
|
||||
|
||||
groups := make([]parlTypeGroup, 0, len(order))
|
||||
for _, code := range order {
|
||||
groups = append(groups, *groupMap[code])
|
||||
}
|
||||
|
||||
// Build vote summary.
|
||||
forCount := make(map[string]int)
|
||||
againstCount := make(map[string]int)
|
||||
totalVotes := 0
|
||||
for _, v := range views {
|
||||
if len(v.VotesFor) == 0 && len(v.VotesAgainst) == 0 {
|
||||
continue
|
||||
}
|
||||
totalVotes++
|
||||
for _, p := range v.VotesFor {
|
||||
forCount[p]++
|
||||
}
|
||||
for _, p := range v.VotesAgainst {
|
||||
againstCount[p]++
|
||||
}
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var partyStats []partyStat
|
||||
for p := range forCount {
|
||||
seen[p] = true
|
||||
}
|
||||
for p := range againstCount {
|
||||
seen[p] = true
|
||||
}
|
||||
for code := range seen {
|
||||
partyStats = append(partyStats, partyStat{
|
||||
Code: code,
|
||||
For: forCount[code],
|
||||
Against: againstCount[code],
|
||||
})
|
||||
}
|
||||
sort.Slice(partyStats, func(i, j int) bool {
|
||||
ti := partyStats[i].For + partyStats[i].Against
|
||||
tj := partyStats[j].For + partyStats[j].Against
|
||||
if ti != tj {
|
||||
return ti > tj
|
||||
}
|
||||
return partyStats[i].Code < partyStats[j].Code
|
||||
})
|
||||
|
||||
web.renderFragment(w, r, http.StatusOK, "parlvotesrows", struct {
|
||||
Groups []parlTypeGroup
|
||||
TotalVotes int
|
||||
Parties []partyStat
|
||||
}{
|
||||
Groups: groups,
|
||||
TotalVotes: totalVotes,
|
||||
Parties: partyStats,
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,41 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (web *Web) renderFragment(w http.ResponseWriter, r *http.Request, status int, name string, data interface{}) {
|
||||
funcs := template.FuncMap{
|
||||
"hasPermission": func(code string) bool {
|
||||
return getPermissions(r).Include(code)
|
||||
},
|
||||
"partyColor": func(code string) string {
|
||||
switch code {
|
||||
case "V":
|
||||
return "#009FE3" // ÖVP turquoise
|
||||
case "S":
|
||||
return "#E30613" // SPÖ red
|
||||
case "F":
|
||||
return "#0057A8" // FPÖ blue
|
||||
case "N":
|
||||
return "#E84188" // NEOS pink
|
||||
case "G":
|
||||
return "#78A22E" // Grüne green
|
||||
default:
|
||||
return "#6b7280"
|
||||
}
|
||||
},
|
||||
}
|
||||
ts, err := template.New(name).Funcs(funcs).ParseFiles("web/html/" + name + ".frag.tmpl")
|
||||
if err != nil {
|
||||
web.App.LogError(r, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err = ts.ExecuteTemplate(w, name, data); err != nil {
|
||||
web.App.LogError(r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (web *Web) render(w http.ResponseWriter, r *http.Request, status int, page string, data interface{}) {
|
||||
funcs := template.FuncMap{
|
||||
"dict": func(pairs ...any) (map[string]any, error) {
|
||||
|
||||
Reference in new issue
Block a user