This commit is contained in:
vfs committed 2026-05-10 10:49:20 +02:00
1 parent 136a1c34b3
commit cb87af4f3b
108 files changed
+49079 -41

No files matched your search

+69
View File
@@ -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)
}
+28
View File
@@ -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)
}
}
+35
View File
@@ -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)
}
}
+117
View File
@@ -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
}
+10
View File
@@ -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
View File
@@ -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)
}
}
+9 -1
View File
@@ -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 {
+180 -5
View File
@@ -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
+69 -2
View File
@@ -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
}
+146
View File
@@ -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)
}
}
}
}
+9
View File
@@ -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
View File
@@ -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.
+49
View File
@@ -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,
})
}
+237
View File
@@ -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,
})
}
+35
View File
@@ -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) {