Sección «Pedir evento» con gestor de reservas y calendario legible en móvil

- /pedir/: calendario de fechas pedidas (negro) y confirmadas (verde),
  findes tranqui y con ruido, solo findes por defecto con opción de ver la
  semana entera. Se pincha un día libre y, sin sesión, se pide entrar y se
  vuelve al formulario con la fecha puesta.
- Gestor en Go (gestor/): cuentas por colectivo con argon2id, sesiones y
  CSRF, cupo y ventana de la asamblea, caducidades, registro append-only.
- La cuenta de la asamblea confirma, rechaza, cambia turnos de finde y en
  «Todas las fechas» quita o modifica cualquier evento.
- Orden «cuenta» y «clave» con -clave y -definitiva para fijar contraseñas
  desde el servidor; ninguna contraseña va en el repositorio.
- Botón «Pedir evento» arriba de la barra lateral en todas las páginas.
- Calendario semanal en móvil: lista de días con nombre y hora de cada cita.
- Fuentes servidas desde fuentes/: fuera Google Fonts en todas las páginas.
This commit is contained in:
SITO 2026-09-23 11:32:25 +02:00
parent c3b51be992
commit ce3127baff
38 changed files with 4029 additions and 52 deletions

238
gestor/almacen.go Normal file
View file

@ -0,0 +1,238 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// Apunte es una linea del registro. El registro solo crece: cada cambio se
// escribe al final y nunca se reescribe nada. Al arrancar se relee entero para
// reconstruir el estado en memoria, asi que el fichero es a la vez la base de
// datos y el acta de la asamblea.
type Apunte struct {
Ts time.Time `json:"ts"`
Tipo string `json:"tipo"`
Por string `json:"por,omitempty"`
Cuenta *Cuenta `json:"cuenta,omitempty"`
Evento *Evento `json:"evento,omitempty"`
Excepcion *Excepcion `json:"excepcion,omitempty"`
}
const (
ApunteCuenta = "cuenta"
ApunteEvento = "evento"
ApunteExcepcion = "excepcion"
)
// Almacen aisla el resto del programa de como se guardan los datos. Hoy es un
// registro de texto; si algun dia hace falta SQL, se cambia solo esta pieza.
type Almacen interface {
Cuentas() []Cuenta
BuscarCuenta(usuario string) (Cuenta, bool)
GuardarCuenta(por string, c Cuenta) error
Eventos() []Evento
BuscarEvento(id string) (Evento, bool)
GuardarEvento(por string, e Evento) error
Excepciones() map[Dia]Turno
GuardarExcepcion(por string, x Excepcion) error
}
type Registro struct {
mu sync.RWMutex
f *os.File
ruta string
cuentas map[string]Cuenta
eventos map[string]Evento
excepciones map[Dia]Turno
}
func AbrirRegistro(dir string) (*Registro, error) {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
ruta := filepath.Join(dir, "registro.jsonl")
r := &Registro{
ruta: ruta,
cuentas: map[string]Cuenta{},
eventos: map[string]Evento{},
excepciones: map[Dia]Turno{},
}
if err := r.releer(); err != nil {
return nil, err
}
f, err := os.OpenFile(ruta, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return nil, err
}
r.f = f
return r, nil
}
func (r *Registro) releer() error {
f, err := os.Open(r.ruta)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
n := 0
for sc.Scan() {
n++
linea := sc.Bytes()
if len(linea) == 0 {
continue
}
var a Apunte
if err := json.Unmarshal(linea, &a); err != nil {
return fmt.Errorf("%s linea %d: %w", r.ruta, n, err)
}
r.aplicar(a)
}
return sc.Err()
}
func (r *Registro) aplicar(a Apunte) {
switch a.Tipo {
case ApunteCuenta:
if a.Cuenta != nil {
r.cuentas[a.Cuenta.Usuario] = *a.Cuenta
}
case ApunteEvento:
if a.Evento == nil {
return
}
if _, err := a.Evento.Desde.Time(); err != nil {
log.Printf("evento %q ignorado: %v", a.Evento.ID, err)
return
}
if _, err := a.Evento.Hasta.Time(); err != nil {
log.Printf("evento %q ignorado: %v", a.Evento.ID, err)
return
}
r.eventos[a.Evento.ID] = *a.Evento
case ApunteExcepcion:
if a.Excepcion != nil {
if _, err := a.Excepcion.Viernes.Time(); err != nil {
log.Printf("excepcion de finde ignorada: %v", err)
return
}
if a.Excepcion.Turno == "" {
// Turno vacio significa volver a la alternancia automatica.
delete(r.excepciones, a.Excepcion.Viernes)
} else {
r.excepciones[a.Excepcion.Viernes] = a.Excepcion.Turno
}
}
}
}
// anotar escribe el apunte al final del fichero y lo sincroniza a disco antes
// de dar la operacion por buena. Con el candado ya tomado por quien llama.
func (r *Registro) anotar(a Apunte) error {
a.Ts = time.Now()
b, err := json.Marshal(a)
if err != nil {
return err
}
b = append(b, '\n')
if _, err := r.f.Write(b); err != nil {
return err
}
if err := r.f.Sync(); err != nil {
return err
}
r.aplicar(a)
return nil
}
func (r *Registro) Cerrar() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.f == nil {
return nil
}
return r.f.Close()
}
func (r *Registro) Cuentas() []Cuenta {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Cuenta, 0, len(r.cuentas))
for _, c := range r.cuentas {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Usuario < out[j].Usuario })
return out
}
func (r *Registro) BuscarCuenta(usuario string) (Cuenta, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
c, ok := r.cuentas[usuario]
return c, ok
}
func (r *Registro) GuardarCuenta(por string, c Cuenta) error {
r.mu.Lock()
defer r.mu.Unlock()
return r.anotar(Apunte{Tipo: ApunteCuenta, Por: por, Cuenta: &c})
}
func (r *Registro) Eventos() []Evento {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Evento, 0, len(r.eventos))
for _, e := range r.eventos {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Desde != out[j].Desde {
return out[i].Desde < out[j].Desde
}
return out[i].ID < out[j].ID
})
return out
}
func (r *Registro) BuscarEvento(id string) (Evento, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.eventos[id]
return e, ok
}
func (r *Registro) GuardarEvento(por string, e Evento) error {
r.mu.Lock()
defer r.mu.Unlock()
return r.anotar(Apunte{Tipo: ApunteEvento, Por: por, Evento: &e})
}
func (r *Registro) Excepciones() map[Dia]Turno {
r.mu.RLock()
defer r.mu.RUnlock()
out := make(map[Dia]Turno, len(r.excepciones))
for k, v := range r.excepciones {
out[k] = v
}
return out
}
func (r *Registro) GuardarExcepcion(por string, x Excepcion) error {
r.mu.Lock()
defer r.mu.Unlock()
return r.anotar(Apunte{Tipo: ApunteExcepcion, Por: por, Excepcion: &x})
}

355
gestor/auth.go Normal file
View file

@ -0,0 +1,355 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"strings"
"sync"
"time"
"unicode"
"golang.org/x/crypto/argon2"
)
// Parametros de argon2id. 64 MiB por intento es suficiente para que probar
// claves a lo bruto salga caro y poco para el servidor, que atiende a treinta
// cuentas.
const (
argonMemoria = 64 * 1024
argonIteraciones = 3
argonHilos = 4
argonLargo = 32
argonSal = 16
)
func aleatorio(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("sin fuente de aleatoriedad: " + err.Error())
}
return b
}
func nuevoID() string { return hex.EncodeToString(aleatorio(16)) }
// HashClave devuelve la clave cifrada en el formato codificado de argon2, que
// lleva dentro los parametros con los que se genero.
func HashClave(clave string) string {
sal := aleatorio(argonSal)
h := argon2.IDKey([]byte(clave), sal, argonIteraciones, argonMemoria, argonHilos, argonLargo)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemoria, argonIteraciones, argonHilos,
base64.RawStdEncoding.EncodeToString(sal),
base64.RawStdEncoding.EncodeToString(h))
}
// hashInutil se usa para gastar el mismo tiempo cuando la cuenta no existe, de
// forma que no se pueda averiguar que usuarios hay midiendo la respuesta.
var hashInutil = HashClave("cuenta que no existe")
func VerificarClave(codificado, clave string) bool {
partes := strings.Split(codificado, "$")
if len(partes) != 6 || partes[1] != "argon2id" {
return false
}
var version, memoria, iteraciones int
var hilos int
if _, err := fmt.Sscanf(partes[2], "v=%d", &version); err != nil {
return false
}
if _, err := fmt.Sscanf(partes[3], "m=%d,t=%d,p=%d", &memoria, &iteraciones, &hilos); err != nil {
return false
}
sal, err := base64.RawStdEncoding.DecodeString(partes[4])
if err != nil {
return false
}
esperado, err := base64.RawStdEncoding.DecodeString(partes[5])
if err != nil {
return false
}
calculado := argon2.IDKey([]byte(clave), sal, uint32(iteraciones), uint32(memoria),
uint8(hilos), uint32(len(esperado)))
return subtle.ConstantTimeCompare(calculado, esperado) == 1
}
// ValidarClave es la politica de contrasenas: larga antes que retorcida, que es
// lo que de verdad aguanta un ataque por diccionario.
func ValidarClave(c string) error {
if len([]rune(c)) < 12 {
return fmt.Errorf("la contraseña tiene que tener al menos 12 caracteres")
}
if len(c) > 256 {
return fmt.Errorf("la contraseña no puede pasar de 256 caracteres")
}
distintos := map[rune]bool{}
for _, r := range c {
distintos[r] = true
}
if len(distintos) < 5 {
return fmt.Errorf("la contraseña repite demasiado los mismos caracteres")
}
return nil
}
// ClaveTemporal genera la clave de un solo uso que se entrega en mano al dar de
// alta un colectivo. Sin caracteres que se confundan al copiarla a papel.
func ClaveTemporal() string {
const alfabeto = "abcdefghjkmnpqrstuvwxyz23456789"
b := aleatorio(20)
var sb strings.Builder
for i, v := range b {
if i > 0 && i%5 == 0 {
sb.WriteByte('-')
}
sb.WriteByte(alfabeto[int(v)%len(alfabeto)])
}
return sb.String()
}
func ValidarUsuario(u string) error {
if len(u) < 3 || len(u) > 32 {
return fmt.Errorf("el usuario tiene entre 3 y 32 caracteres")
}
for _, r := range u {
if !unicode.IsLower(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
return fmt.Errorf("el usuario solo admite minúsculas, números, guion y guion bajo")
}
}
return nil
}
// Sesiones viven solo en memoria: se pueden revocar al instante y un reinicio
// del servicio cierra todas las sesiones abiertas, que es lo que interesa.
type Sesiones struct {
mu sync.Mutex
m map[string]*Sesion
maxInactivo time.Duration
}
func NuevasSesiones(maxInactivo time.Duration) *Sesiones {
return &Sesiones{m: map[string]*Sesion{}, maxInactivo: maxInactivo}
}
func (s *Sesiones) Crear(usuario string) *Sesion {
s.mu.Lock()
defer s.mu.Unlock()
ses := &Sesion{
ID: hex.EncodeToString(aleatorio(32)),
Usuario: usuario,
CSRF: hex.EncodeToString(aleatorio(32)),
Creada: time.Now(),
Vista: time.Now(),
}
s.m[ses.ID] = ses
return ses
}
func (s *Sesiones) Buscar(id string) (*Sesion, bool) {
s.mu.Lock()
defer s.mu.Unlock()
ses, ok := s.m[id]
if !ok {
return nil, false
}
if time.Since(ses.Vista) > s.maxInactivo {
delete(s.m, id)
return nil, false
}
ses.Vista = time.Now()
return ses, true
}
// PonerClave guarda la clave temporal recien generada para ensenarla una sola
// vez en la siguiente pagina, sin que pase por la URL.
func (s *Sesiones) PonerClave(id, usuario, clave string) {
s.mu.Lock()
defer s.mu.Unlock()
if ses, ok := s.m[id]; ok {
ses.ClaveDe, ses.ClaveNueva = usuario, clave
}
}
// TomarClave la devuelve y la borra: no se vuelve a mostrar.
func (s *Sesiones) TomarClave(id string) (string, string) {
s.mu.Lock()
defer s.mu.Unlock()
ses, ok := s.m[id]
if !ok {
return "", ""
}
usuario, clave := ses.ClaveDe, ses.ClaveNueva
ses.ClaveDe, ses.ClaveNueva = "", ""
return usuario, clave
}
func (s *Sesiones) Borrar(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.m, id)
}
// BorrarDeUsuario cierra todas las sesiones de una cuenta: al cambiarle la
// clave o al desactivarla.
func (s *Sesiones) BorrarDeUsuario(usuario string) {
s.mu.Lock()
defer s.mu.Unlock()
for id, ses := range s.m {
if ses.Usuario == usuario {
delete(s.m, id)
}
}
}
func (s *Sesiones) Limpiar() {
s.mu.Lock()
defer s.mu.Unlock()
for id, ses := range s.m {
if time.Since(ses.Vista) > s.maxInactivo {
delete(s.m, id)
}
}
}
// Cerrojo cuenta intentos fallidos por IP y por cuenta. Es la primera barrera,
// dentro de la propia aplicacion; fail2ban es la segunda, en el cortafuegos.
type Cerrojo struct {
mu sync.Mutex
fallos map[string][]time.Time
maxIntentos int
ventana time.Duration
espera time.Duration
}
func NuevoCerrojo(maxIntentos int, ventana, espera time.Duration) *Cerrojo {
return &Cerrojo{
fallos: map[string][]time.Time{},
maxIntentos: maxIntentos,
ventana: ventana,
espera: espera,
}
}
func (c *Cerrojo) Bloqueado(claves ...string) (bool, time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
ahora := time.Now()
for _, k := range claves {
recientes := c.podar(k, ahora)
if len(recientes) >= c.maxIntentos {
ultimo := recientes[len(recientes)-1]
if falta := c.espera - ahora.Sub(ultimo); falta > 0 {
return true, falta
}
}
}
return false, 0
}
func (c *Cerrojo) Fallo(claves ...string) {
c.mu.Lock()
defer c.mu.Unlock()
ahora := time.Now()
for _, k := range claves {
c.fallos[k] = append(c.podar(k, ahora), ahora)
}
}
func (c *Cerrojo) Limpiar(claves ...string) {
c.mu.Lock()
defer c.mu.Unlock()
for _, k := range claves {
delete(c.fallos, k)
}
}
func (c *Cerrojo) podar(k string, ahora time.Time) []time.Time {
var out []time.Time
for _, t := range c.fallos[k] {
if ahora.Sub(t) <= c.ventana {
out = append(out, t)
}
}
if len(out) == 0 {
delete(c.fallos, k)
} else {
c.fallos[k] = out
}
return out
}
// AuthLog escribe el registro que lee fail2ban. Formato fijo y de una sola
// linea por intento.
type AuthLog struct {
mu sync.Mutex
f *os.File
}
func AbrirAuthLog(ruta string) (*AuthLog, error) {
if ruta == "" {
return &AuthLog{}, nil
}
f, err := os.OpenFile(ruta, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return nil, err
}
return &AuthLog{f: f}, nil
}
// limpiarCampo evita que un usuario inventado meta saltos de linea o espacios
// en el log: si no, se podria falsificar una linea AUTH_FAIL con la IP de otro
// y hacer que fail2ban banee a quien no toca.
func limpiarCampo(s string) string {
if s == "" {
return "-"
}
var sb strings.Builder
for _, r := range s {
if r > unicode.MaxASCII || unicode.IsSpace(r) || !unicode.IsPrint(r) {
sb.WriteByte('.')
continue
}
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' {
sb.WriteRune(r)
} else {
sb.WriteByte('.')
}
if sb.Len() >= 32 {
break
}
}
if sb.Len() == 0 {
return "-"
}
return sb.String()
}
func (l *AuthLog) Escribir(evento, ip, usuario, motivo string) {
linea := fmt.Sprintf("%s %s ip=%s usuario=%s motivo=%s\n",
time.Now().Format(time.RFC3339), evento,
limpiarCampo(ip), limpiarCampo(usuario), limpiarCampo(motivo))
l.mu.Lock()
defer l.mu.Unlock()
if l.f != nil {
l.f.WriteString(linea)
return
}
os.Stderr.WriteString(linea)
}
// iguales compara dos cadenas sin filtrar por el tiempo cuanto coinciden.
func iguales(a, b string) bool {
return len(a) == len(b) && subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func (l *AuthLog) Cerrar() {
l.mu.Lock()
defer l.mu.Unlock()
if l.f != nil {
l.f.Close()
}
}

94
gestor/config.go Normal file
View file

@ -0,0 +1,94 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"time"
)
type Config struct {
// Red
Escucha string `json:"escucha"` // siempre local: nginx hace de puerta
BaseURL string `json:"base_url"` // https://www.laenre.net, para comprobar el Origen
// Rutas
DirDatos string `json:"dir_datos"` // registro.jsonl
DirPlantillas string `json:"dir_plantillas"` // plantillas de /pedir/
DirEstatico string `json:"dir_estatico"` // raiz de la web, solo en modo local
LogAuth string `json:"log_auth"` // el registro que lee fail2ban
// Comportamiento
CookieSegura bool `json:"cookie_segura"` // false solo para probar sin TLS
Local bool `json:"local"` // sirve tambien la web estatica
MesesVista int `json:"meses_vista"` // meses que pinta /pedir/
Reglas Reglas `json:"reglas"`
}
func ConfigPorDefecto() Config {
return Config{
Escucha: "127.0.0.1:8090",
BaseURL: "https://www.laenre.net",
DirDatos: "/var/lib/enrecal",
DirPlantillas: "/var/www/laenre/gestor/plantillas",
DirEstatico: "/var/www/laenre",
LogAuth: "/var/log/enrecal/auth.log",
CookieSegura: true,
MesesVista: 4,
Reglas: ReglasPorDefecto(),
}
}
func CargarConfig(ruta string) (Config, error) {
c := ConfigPorDefecto()
if ruta == "" {
return c, nil
}
b, err := os.ReadFile(ruta)
if err != nil {
return c, err
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.DisallowUnknownFields()
if err := dec.Decode(&c); err != nil {
return c, fmt.Errorf("%s: %w", ruta, err)
}
return c, c.Validar()
}
func (c Config) Validar() error {
if c.Reglas.CupoDias < 1 {
return fmt.Errorf("cupo_dias tiene que ser 1 o mas")
}
if c.Reglas.MesesVentana < 1 {
return fmt.Errorf("meses_ventana tiene que ser 1 o mas")
}
if c.Reglas.DiasMinimoConfirmar < 0 {
return fmt.Errorf("dias_minimo_confirmar no puede ser negativo")
}
if c.Reglas.DiasMaximoPendiente < 1 {
return fmt.Errorf("dias_maximo_pendiente tiene que ser 1 o mas")
}
if len(c.Reglas.DiasReservables) == 0 {
return fmt.Errorf("dias_reservables no puede estar vacio")
}
if _, err := c.Reglas.AnclaFinde.Time(); err != nil {
return fmt.Errorf("ancla_finde: %w", err)
}
if c.Reglas.TurnoAncla != Tranqui && c.Reglas.TurnoAncla != Ruido {
return fmt.Errorf("turno_ancla solo puede ser %q o %q", Tranqui, Ruido)
}
if c.MesesVista < 1 {
return fmt.Errorf("meses_vista tiene que ser 1 o mas")
}
return nil
}
const (
inactividadSesion = 8 * time.Hour
maxIntentos = 5
ventanaIntentos = 10 * time.Minute
esperaBloqueo = 15 * time.Minute
)

7
gestor/go.mod Normal file
View file

@ -0,0 +1,7 @@
module laenre.net/enrecal
go 1.22
require golang.org/x/crypto v0.26.0
require golang.org/x/sys v0.26.0 // indirect

4
gestor/go.sum Normal file
View file

@ -0,0 +1,4 @@
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

352
gestor/main.go Normal file
View file

@ -0,0 +1,352 @@
// enrecal: reservas de fechas del CSO La Enredadera de Tetuán.
//
// Sirve la sección /pedir/ de laenre.net: el calendario de fechas pedidas y
// confirmadas, el formulario para pedirlas y el panel de la asamblea. Sin una
// línea de JavaScript en el navegador.
package main
import (
"context"
"errors"
"flag"
"fmt"
"html/template"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
)
type App struct {
cfg Config
alm Almacen
ses *Sesiones
cer *Cerrojo
auth *AuthLog
// mu serializa las escrituras de eventos: comprobar las reglas y guardar
// tienen que ser una sola operacion, o dos colectivos podrian colarse en la
// misma fecha a la vez.
mu sync.Mutex
plantillas *template.Template
}
func main() {
log.SetFlags(0)
log.SetPrefix("enrecal: ")
var (
rutaConfig = flag.String("config", "", "fichero de configuracion en JSON")
escucha = flag.String("escucha", "", "direccion donde escuchar, por ejemplo 127.0.0.1:8090")
dirDatos = flag.String("datos", "", "directorio del registro")
dirPlant = flag.String("plantillas", "", "directorio de plantillas")
dirEstat = flag.String("estatico", "", "raiz de la web, para el modo local")
logAuth = flag.String("logauth", "", "registro de accesos; \"-\" para sacarlo por stderr")
insegura = flag.Bool("insegura", false, "cookie sin marca Secure, solo para probar sin TLS")
local = flag.Bool("local", false, "sirve tambien la web estatica, solo para probar sin nginx")
usuario = flag.String("usuario", "", "usuario, para las ordenes cuenta y clave")
nombre = flag.String("nombre", "", "nombre visible del colectivo, para la orden cuenta")
rol = flag.String("rol", string(RolInterno), "rol de la cuenta: enre o interno")
claveFija = flag.String("clave", "", "clave temporal para cuenta y clave; sin ella se genera una al azar")
definitiva = flag.Bool("definitiva", false, "con -clave: la clave no caduca al entrar (minimo 12 caracteres)")
)
flag.Usage = uso
flag.Parse()
cfg, err := CargarConfig(*rutaConfig)
if err != nil {
log.Fatal(err)
}
for destino, valor := range map[*string]string{
&cfg.Escucha: *escucha, &cfg.DirDatos: *dirDatos,
&cfg.DirPlantillas: *dirPlant, &cfg.DirEstatico: *dirEstat,
} {
if valor != "" {
*destino = valor
}
}
switch *logAuth {
case "":
case "-":
cfg.LogAuth = "" // a stderr, para probar en local
default:
cfg.LogAuth = *logAuth
}
if *insegura {
cfg.CookieSegura = false
}
if *local {
cfg.Local = true
}
if err := cfg.Validar(); err != nil {
log.Fatal(err)
}
app, err := NuevaApp(cfg)
if err != nil {
log.Fatal(err)
}
defer app.Cerrar()
orden := flag.Arg(0)
if orden == "" {
orden = "servir"
}
switch orden {
case "servir":
if err := app.Servir(); err != nil {
log.Fatal(err)
}
case "cuenta":
if err := app.AltaCuenta(*usuario, *nombre, Rol(*rol), *claveFija, *definitiva); err != nil {
log.Fatal(err)
}
case "clave":
if err := app.ResetClave(*usuario, *claveFija, *definitiva); err != nil {
log.Fatal(err)
}
case "caducar":
n, err := app.Caducar()
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d solicitudes caducadas\n", n)
default:
uso()
os.Exit(2)
}
}
func uso() {
fmt.Fprint(os.Stderr, `uso: enrecal [opciones] <orden>
ordenes:
servir levanta la seccion /pedir/ (por defecto)
cuenta da de alta una cuenta e imprime su clave temporal (-clave para fijarla)
enrecal cuenta -usuario hacklab -nombre "Hacklab La Raiz" -rol interno
clave resetea la clave de una cuenta e imprime la nueva temporal
caducar pasa a caducadas las solicitudes que toque y sale
opciones:
`)
flag.PrintDefaults()
}
func NuevaApp(cfg Config) (*App, error) {
reg, err := AbrirRegistro(cfg.DirDatos)
if err != nil {
return nil, fmt.Errorf("abriendo el registro: %w", err)
}
al, err := AbrirAuthLog(cfg.LogAuth)
if err != nil {
return nil, fmt.Errorf("abriendo el registro de accesos: %w", err)
}
patron := filepath.Join(cfg.DirPlantillas, "*.tmpl.html")
plantillas, err := template.New("pedir").ParseGlob(patron)
if err != nil {
return nil, fmt.Errorf("plantillas (%s): %w", patron, err)
}
return &App{
cfg: cfg,
alm: reg,
ses: NuevasSesiones(inactividadSesion),
cer: NuevoCerrojo(maxIntentos, ventanaIntentos, esperaBloqueo),
auth: al,
plantillas: plantillas,
}, nil
}
func (a *App) Cerrar() {
if r, ok := a.alm.(*Registro); ok {
r.Cerrar()
}
a.auth.Cerrar()
}
// nombres asocia cada usuario con el nombre visible de su colectivo.
func (a *App) nombres() map[string]string {
out := map[string]string{}
for _, c := range a.alm.Cuentas() {
out[c.Usuario] = c.Nombre
}
return out
}
func (a *App) vista() Vista {
return Vista{
reglas: a.cfg.Reglas,
exc: a.alm.Excepciones(),
eventos: a.alm.Eventos(),
nombres: a.nombres(),
hoy: DiaDe(time.Now()),
}
}
// claveTemporal devuelve la clave pedida o, si no se pide ninguna, una al azar.
// Sea cual sea, la cuenta queda marcada como temporal: al entrar por primera
// vez no deja hacer nada hasta cambiarla por una de 12 caracteres o más.
func claveTemporal(fija string) string {
if fija != "" {
return fija
}
return ClaveTemporal()
}
func avisoClave(clave string, definitiva bool) {
if definitiva {
fmt.Println("clave definitiva: no se pide cambiarla al entrar")
return
}
fmt.Printf("clave temporal: %s\nSe entrega en mano y hay que cambiarla al entrar por primera vez.\n", clave)
}
// claveDefinitiva comprueba que una clave que no se va a cambiar al entrar
// cumple lo mismo que se exige al cambiarla desde la web.
func claveDefinitiva(fija string) error {
if fija == "" {
return errors.New("-definitiva necesita -clave")
}
if len([]rune(fija)) < 12 {
return errors.New("una clave definitiva tiene al menos 12 caracteres")
}
return nil
}
func (a *App) AltaCuenta(usuario, nombre string, rol Rol, fija string, definitiva bool) error {
if err := ValidarUsuario(usuario); err != nil {
return err
}
if nombre == "" {
return errors.New("falta -nombre: el nombre visible del colectivo")
}
if rol != RolEnre && rol != RolInterno {
return fmt.Errorf("el rol solo puede ser %q o %q", RolEnre, RolInterno)
}
if _, existe := a.alm.BuscarCuenta(usuario); existe {
return fmt.Errorf("la cuenta %q ya existe; para cambiarle la clave usa la orden clave", usuario)
}
if definitiva {
if err := claveDefinitiva(fija); err != nil {
return err
}
}
clave := claveTemporal(fija)
c := Cuenta{
Usuario: usuario, Nombre: nombre, Rol: rol,
Hash: HashClave(clave), Temporal: !definitiva, Activa: true, Creada: time.Now(),
}
if err := a.alm.GuardarCuenta("consola", c); err != nil {
return err
}
fmt.Printf("cuenta %q (%s) creada con rol %s\n", usuario, nombre, rol)
avisoClave(clave, definitiva)
return nil
}
func (a *App) ResetClave(usuario, fija string, definitiva bool) error {
c, ok := a.alm.BuscarCuenta(usuario)
if !ok {
return fmt.Errorf("no existe la cuenta %q", usuario)
}
if definitiva {
if err := claveDefinitiva(fija); err != nil {
return err
}
}
clave := claveTemporal(fija)
c.Hash = HashClave(clave)
c.Temporal = !definitiva
if err := a.alm.GuardarCuenta("consola", c); err != nil {
return err
}
a.ses.BorrarDeUsuario(usuario)
fmt.Printf("clave de %q reseteada\n", usuario)
avisoClave(clave, definitiva)
return nil
}
// Caducar pasa a caducadas las solicitudes que se han quedado sin margen o sin
// respuesta, liberando las fechas.
func (a *App) Caducar() (int, error) {
a.mu.Lock()
defer a.mu.Unlock()
ahora := time.Now()
n := 0
for _, e := range a.alm.Eventos() {
motivo := a.cfg.Reglas.Caducidad(e, ahora)
if motivo == "" {
continue
}
e.Estado = Caducado
e.Resuelto = ahora
e.ResueltoPor = "automatico"
e.Motivo = motivo
if err := a.alm.GuardarEvento("automatico", e); err != nil {
return n, err
}
log.Printf("caducada %q de %s: %s", e.Titulo, e.Solicitante, motivo)
n++
}
return n, nil
}
func (a *App) Servir() error {
if _, err := a.Caducar(); err != nil {
return err
}
srv := &http.Server{
Addr: a.cfg.Escucha,
Handler: a.rutas(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 * 1024,
}
paro := make(chan os.Signal, 1)
signal.Notify(paro, syscall.SIGINT, syscall.SIGTERM)
go a.tareasPeriodicas(paro)
errores := make(chan error, 1)
go func() {
log.Printf("escuchando en %s", a.cfg.Escucha)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errores <- err
}
}()
select {
case err := <-errores:
return err
case <-paro:
log.Print("parando")
ctx, cancelar := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelar()
return srv.Shutdown(ctx)
}
}
// tareasPeriodicas repasa cada hora las solicitudes caducables y limpia las
// sesiones abandonadas.
func (a *App) tareasPeriodicas(paro <-chan os.Signal) {
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
select {
case <-t.C:
if _, err := a.Caducar(); err != nil {
log.Printf("repaso de caducidades: %v", err)
}
a.ses.Limpiar()
case <-paro:
return
}
}
}

171
gestor/modelo.go Normal file
View file

@ -0,0 +1,171 @@
package main
import (
"fmt"
"time"
)
// Dia es una fecha civil en formato ISO "2006-01-02". Se guarda como texto
// porque asi se ordena y se compara igual que como se lee en el registro.
type Dia string
const formatoDia = "2006-01-02"
func DiaDe(t time.Time) Dia { return Dia(t.Format(formatoDia)) }
func (d Dia) Time() (time.Time, error) {
t, err := time.ParseInLocation(formatoDia, string(d), time.Local)
if err != nil {
return time.Time{}, fmt.Errorf("fecha %q no válida (formato AAAA-MM-DD)", string(d))
}
return t, nil
}
// MustTime solo se usa sobre fechas ya validadas al entrar en el sistema.
func (d Dia) MustTime() time.Time {
t, err := d.Time()
if err != nil {
panic(err)
}
return t
}
func (d Dia) Mas(dias int) Dia { return DiaDe(d.MustTime().AddDate(0, 0, dias)) }
// DiaISO devuelve 1 (lunes) a 7 (domingo).
func (d Dia) DiaISO() int { return int(d.MustTime().Weekday()+6)%7 + 1 }
func (d Dia) Legible() string {
t := d.MustTime()
return fmt.Sprintf("%s %d de %s", diasSemana[d.DiaISO()-1], t.Day(), mesesMin[int(t.Month())-1])
}
var (
diasSemana = []string{"lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"}
meses = []string{"Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio",
"Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"}
mesesMin = []string{"enero", "febrero", "marzo", "abril", "mayo", "junio", "julio",
"agosto", "septiembre", "octubre", "noviembre", "diciembre"}
)
// Rango de dias que ocupa un evento, ambos extremos incluidos.
func Rango(desde, hasta Dia) []Dia {
if hasta < desde {
return nil
}
var out []Dia
for d := desde; d <= hasta; d = d.Mas(1) {
out = append(out, d)
if len(out) > 400 {
break
}
}
return out
}
type Rol string
const (
RolEnre Rol = "enre" // la asamblea: confirma y rechaza
RolInterno Rol = "interno" // colectivo de la casa: solicita y amadrina
)
type Cuenta struct {
Usuario string `json:"usuario"`
Nombre string `json:"nombre"` // nombre visible del colectivo
Rol Rol `json:"rol"`
Hash string `json:"hash"` // argon2id codificado
Temporal bool `json:"temporal,omitempty"` // obliga a cambiar la clave al entrar
Colectivo string `json:"colectivo,omitempty"` // clave en datos/horarios.json, para el color
Activa bool `json:"activa"`
Creada time.Time `json:"creada"`
}
type Estado string
const (
Solicitado Estado = "solicitado"
Confirmado Estado = "confirmado"
Rechazado Estado = "rechazado"
Caducado Estado = "caducado"
Cancelado Estado = "cancelado"
)
// Ocupa indica si el estado reserva la fecha frente a otras solicitudes.
func (e Estado) Ocupa() bool { return e == Solicitado || e == Confirmado }
func (e Estado) Etiqueta() string {
switch e {
case Solicitado:
return "SOLICITADO"
case Confirmado:
return "CONFIRMADO"
case Rechazado:
return "RECHAZADO"
case Caducado:
return "CADUCADO"
case Cancelado:
return "CANCELADO"
}
return string(e)
}
type Evento struct {
ID string `json:"id"`
Titulo string `json:"titulo"`
Solicitante string `json:"solicitante"` // usuario de la cuenta
Amadrinado string `json:"amadrinado,omitempty"` // colectivo externo, vacio si es propio
Contacto string `json:"contacto,omitempty"` // contacto del externo
Desde Dia `json:"desde"`
Hasta Dia `json:"hasta"`
Hora string `json:"hora,omitempty"`
Notas string `json:"notas,omitempty"`
Estado Estado `json:"estado"`
Creado time.Time `json:"creado"`
Resuelto time.Time `json:"resuelto,omitempty"`
ResueltoPor string `json:"resuelto_por,omitempty"`
Motivo string `json:"motivo,omitempty"`
}
func (e *Evento) Dias() []Dia { return Rango(e.Desde, e.Hasta) }
func (e *Evento) NumDias() int { return len(e.Dias()) }
// Externo indica si el evento lo organiza un colectivo de fuera amadrinado.
func (e *Evento) Externo() bool { return e.Amadrinado != "" }
type Turno string
const (
Tranqui Turno = "tranqui"
Ruido Turno = "ruido"
)
func (t Turno) Etiqueta() string {
switch t {
case Tranqui:
return "FINDE TRANQUI"
case Ruido:
return "FINDE CON RUIDO"
}
return ""
}
// Excepcion fija a mano el turno de un finde concreto, identificado por su viernes.
type Excepcion struct {
Viernes Dia `json:"viernes"`
Turno Turno `json:"turno"`
}
type Sesion struct {
ID string
Usuario string
CSRF string
Creada time.Time
Vista time.Time
// Clave temporal recien generada, para ensenarla una sola vez sin que pase
// por la URL y acabe en el historial o en el log de nginx.
ClaveDe string
ClaveNueva string
}

1042
gestor/pedir.go Normal file

File diff suppressed because it is too large Load diff

23
gestor/pedir_test.go Normal file
View file

@ -0,0 +1,23 @@
package main
import "testing"
func TestDestinoSeguro(t *testing.T) {
casos := map[string]string{
"/pedir/nueva?fecha=2026-10-09": "/pedir/nueva?fecha=2026-10-09",
"/pedir/mis-fechas": "/pedir/mis-fechas",
"": "",
"/": "",
"/index.html": "",
"//evil.example/pedir/": "",
"https://evil.example/pedir/": "",
"/\\evil.example/pedir/": "",
"/pedir/\r\nSet-Cookie: x=1": "",
"javascript:alert(1)": "",
}
for entrada, quiero := range casos {
if got := destinoSeguro(entrada); got != quiero {
t.Errorf("destinoSeguro(%q) = %q, quiero %q", entrada, got, quiero)
}
}
}

View file

@ -0,0 +1,92 @@
{{define "arriba"}}<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Titulo}} · La Enredadera de Tetuán</title>
<meta name="description" content="Fechas pedidas y confirmadas en el CSO La Enredadera de Tetuán, y cómo pedir una.">
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
</head>
<body>
<input type="checkbox" id="nav-toggle" class="sr-only">
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir o cerrar el menú">☰</label>
<label for="nav-toggle" class="sb-overlay" aria-hidden="true"></label>
<div class="container">
<aside class="sidebar">
<a class="sb-pedir" href="/pedir/" aria-current="page">Pedir evento</a>
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="96" height="96">
<p class="sb-marca">La Enredadera</p>
<p class="lema">Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav" aria-label="Secciones">
<ul>
<li><a href="/colectivos.html">Colectivos</a></li>
<li><a href="/calendario.html">Calendario</a></li>
<li><a href="/eventos.html">Eventos</a></li>
<li><a href="/subdir/campa.html">Campañas</a></li>
<li><a href="/subdir/sobre_nosotrxs.html">La Enre</a></li>
<li><a href="/subdir/contacto.html">Contacto</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<main class="main">
<header class="cabecera">
<h1>{{.Titulo}}</h1>
{{- if .Sub}}<p class="sub">{{.Sub}}</p>{{end}}
</header>
<a class="volver" href="/index.html">← Volver a la enredadera</a>
{{- if .Dentro}}
<nav class="pedir-menu" aria-label="Reservas">
<a href="/pedir/"{{if eq .Actual "calendario"}} aria-current="page"{{end}}>Calendario</a>
<a href="/pedir/mis-fechas"{{if eq .Actual "mis-fechas"}} aria-current="page"{{end}}>Vuestras fechas</a>
<a href="/pedir/nueva"{{if eq .Actual "nueva"}} aria-current="page"{{end}}>Pedir fecha</a>
{{- if .EsEnre}}
<a href="/pedir/todas"{{if eq .Actual "todas"}} aria-current="page"{{end}}>Todas las fechas</a>
<a href="/pedir/pendientes"{{if eq .Actual "pendientes"}} aria-current="page"{{end}}>Pendientes</a>
<a href="/pedir/findes"{{if eq .Actual "findes"}} aria-current="page"{{end}}>Findes</a>
<a href="/pedir/cuentas"{{if eq .Actual "cuentas"}} aria-current="page"{{end}}>Cuentas</a>
{{- end}}
<a href="/pedir/clave"{{if eq .Actual "clave"}} aria-current="page"{{end}}>Contraseña</a>
<form class="pedir-salir" method="post" action="/pedir/salir">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit">Salir · {{.Nombre}}</button>
</form>
</nav>
{{- end}}
{{- if .Aviso}}
<p class="pedir-aviso" role="status">{{.Aviso}}</p>
{{- end}}
{{- if .Error}}
<p class="pedir-error" role="alert">{{.Error}}</p>
{{- end}}
{{end}}
{{define "abajo"}}
<footer class="pie-pagina">
<p>
Calle de la Coruña nº 5, Madrid ·
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a> ·
<a href="https://masto.es/@csolaenre">@csolaenre</a>
</p>
</footer>
</main>
</div>
</body>
</html>
{{end}}

View file

@ -0,0 +1,42 @@
{{define "mes"}}
<h3 class="cal-mes">{{.Nombre}} {{.Anio}}</h3>
<div class="cal cal-fechas">
<div class="cal-row cal-head">
<div class="cal-cell cal-head-cell"><abbr title="lunes">L</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="martes">M</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="miércoles">X</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="jueves">J</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="viernes">V</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="sábado">S</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="domingo">D</abbr></div>
</div>
{{- range .Semanas}}
<div class="cal-row">
{{- range .Dias}}
{{- if .Vacia}}
<div class="cal-cell cal-empty"></div>
{{- else}}
<div class="cal-cell{{if .Hoy}} cal-today{{end}}{{if .Pasado}} cal-pasado{{end}}{{if .Turno}} cal-{{.Turno}}{{end}}{{if .Libre}} cal-libre{{end}}">
<span class="cal-daynum">{{.Numero}}</span>
{{- if .EtiquetaTurno}}<span class="cal-turno">{{.EtiquetaTurno}}</span>{{end}}
{{- if .Libre}}
<a class="cal-pedir" href="/pedir/nueva?fecha={{.Dia}}" aria-label="Pedir el {{.Dia.Legible}}">Pedir</a>
{{- end}}
{{- if .Eventos}}
<div class="cal-pills">
{{- range .Eventos}}
<div class="cal-ev cal-ev-{{.Estado}}">
<span class="cal-ev-cab">{{if .Hora}}<b class="cal-ev-hora">{{.Hora}}</b>{{end}}<span class="cal-ev-estado">{{.Etiqueta}}</span></span>
<span class="cal-ev-nom">{{.Texto}}{{if .Sigue}} (sigue){{end}}</span>
<span class="cal-ev-quien">{{.Quien}}</span>
</div>
{{- end}}
</div>
{{- end}}
</div>
{{- end}}
{{- end}}
</div>
{{- end}}
</div>
{{end}}

View file

@ -0,0 +1,31 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Cambiar la contraseña">
<h2>Cambiar la contraseña</h2>
{{- if .Temporal}}
<p class="pedir-aviso">
Estáis usando la contraseña temporal que os dieron en la asamblea. Cambiadla ahora:
hasta que no lo hagáis no se puede pedir ninguna fecha.
</p>
{{- end}}
<form class="pedir-form" method="post" action="/pedir/clave" autocomplete="off">
<input type="hidden" name="csrf" value="{{.CSRF}}">
{{- if .Sig}}<input type="hidden" name="sig" value="{{.Sig}}">{{end}}
<p>
<label for="actual">Contraseña actual</label>
<input id="actual" name="actual" type="password" required maxlength="256">
</p>
<p>
<label for="nueva">Contraseña nueva</label>
<input id="nueva" name="nueva" type="password" required minlength="12" maxlength="256">
<span class="pista">Mínimo 12 caracteres. Una frase larga aguanta más que una palabra retorcida.</span>
</p>
<p>
<label for="repetida">Repetid la nueva</label>
<input id="repetida" name="repetida" type="password" required minlength="12" maxlength="256">
</p>
<p><button type="submit">Cambiar la contraseña</button></p>
</form>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,87 @@
{{template "arriba" .}}
{{- if .Clave}}
<section class="pantalla" aria-label="Clave temporal">
<h2>Clave temporal de «{{.DeQuien}}»</h2>
<p class="clave-nueva">{{.Clave}}</p>
<p class="pie">
Apuntadla y entregadla en mano. No se vuelve a mostrar: de la contraseña solo se
guarda su huella, no la contraseña. Quien entre con ella tendrá que cambiarla antes
de poder hacer nada.
</p>
</section>
{{- end}}
<section class="pantalla" aria-label="Cuentas">
<h2>Cuentas</h2>
<div class="tabla-envoltorio">
<table class="pedir-tabla">
<thead>
<tr>
<th scope="col">Usuario</th>
<th scope="col">Colectivo</th>
<th scope="col">Rol</th>
<th scope="col">Estado</th>
<th scope="col">Acciones</th>
</tr>
</thead>
<tbody>
{{- range .Cuentas}}
<tr>
<th scope="row">{{.Usuario}}</th>
<td>{{.Nombre}}</td>
<td>{{.Rol}}</td>
<td>
{{- if .Activa}}activa{{else}}desactivada{{end}}
{{- if .Temporal}} <span class="pista">clave temporal sin cambiar</span>{{end}}
</td>
<td>
<form class="en-linea" method="post" action="/pedir/cuentas">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="usuario" value="{{.Usuario}}">
<button type="submit" name="accion" value="reset" class="pedir-boton-flojo">Resetear clave</button>
{{- if .Activa}}
<button type="submit" name="accion" value="desactivar" class="pedir-boton-flojo">Desactivar</button>
{{- else}}
<button type="submit" name="accion" value="activar" class="pedir-boton-flojo">Activar</button>
{{- end}}
</form>
</td>
</tr>
{{- end}}
</tbody>
</table>
</div>
</section>
<section class="pantalla" aria-label="Dar de alta un colectivo">
<h2>Dar de alta un colectivo de la casa</h2>
<form class="pedir-form" method="post" action="/pedir/cuentas" autocomplete="off">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="accion" value="crear">
<p>
<label for="nuevo-usuario">Usuario</label>
<input id="nuevo-usuario" name="usuario" type="text" required maxlength="32"
autocapitalize="none" spellcheck="false">
<span class="pista">Minúsculas, números, guion y guion bajo.</span>
</p>
<p>
<label for="nuevo-nombre">Nombre del colectivo</label>
<input id="nuevo-nombre" name="nombre" type="text" required maxlength="60">
</p>
<p>
<label for="nuevo-rol">Rol</label>
<select id="nuevo-rol" name="rol">
<option value="interno">interno: pide fechas y amadrina</option>
<option value="enre">enre: además confirma y rechaza</option>
</select>
</p>
<p><button type="submit">Crear la cuenta</button></p>
</form>
<p class="pie">
Los colectivos de fuera no tienen cuenta: sus eventos los pide un colectivo de la casa
que los amadrina. Cuantas menos contraseñas haya guardadas, menos hay que perder.
</p>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,49 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Modificar evento">
<h2>Modificar evento</h2>
<p class="pie">Lo pidió <strong>{{if .Quien}}{{.Quien}}{{else}}{{.Evento.Solicitante}}{{end}}</strong>. La asamblea no tiene cupo ni ventana: solo se comprueba que no pise otro evento.</p>
<form class="pedir-form" method="post" action="/pedir/editar">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="id" value="{{.Evento.ID}}">
<p>
<label for="titulo">Nombre del evento</label>
<input id="titulo" name="titulo" type="text" required maxlength="80" value="{{.Evento.Titulo}}">
</p>
<p>
<label for="estado">Estado</label>
<select id="estado" name="estado">
<option value="solicitado"{{if eq .Evento.Estado "solicitado"}} selected{{end}}>Solicitado</option>
<option value="confirmado"{{if eq .Evento.Estado "confirmado"}} selected{{end}}>Confirmado</option>
</select>
</p>
<p>
<label for="desde">Primer día</label>
<input id="desde" name="desde" type="date" required value="{{.Evento.Desde}}">
</p>
<p>
<label for="hasta">Último día</label>
<input id="hasta" name="hasta" type="date" value="{{.Evento.Hasta}}">
</p>
<p>
<label for="hora">Hora de inicio</label>
<input id="hora" name="hora" type="time" value="{{.Evento.Hora}}">
</p>
<p>
<label for="amadrinado">Colectivo de fuera amadrinado</label>
<input id="amadrinado" name="amadrinado" type="text" maxlength="80" value="{{.Evento.Amadrinado}}">
</p>
<p>
<label for="contacto">Contacto de ese colectivo</label>
<input id="contacto" name="contacto" type="text" maxlength="120" value="{{.Evento.Contacto}}">
</p>
<p>
<label for="notas">Notas</label>
<textarea id="notas" name="notas" rows="4" maxlength="500">{{.Evento.Notas}}</textarea>
</p>
<p><button type="submit">Guardar cambios</button> <a class="pedir-boton-flojo" href="/pedir/todas">Cancelar</a></p>
</form>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,9 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Error">
<h2>No se puede</h2>
<p class="pedir-error" role="alert">{{.Mensaje}}</p>
<p><a class="pedir-boton" href="/pedir/">Volver al calendario</a></p>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,45 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Turno de los findes">
<h2>Turno de los findes</h2>
<p class="pie">
Los findes se alternan solos, semana a semana. Aquí se cambia uno concreto cuando la
asamblea lo decida, por ejemplo para poner dos tranquilos seguidos. El turno se marca
sobre el viernes y vale para todo el finde.
</p>
<div class="tabla-envoltorio">
<table class="pedir-tabla">
<thead>
<tr>
<th scope="col">Finde</th>
<th scope="col">Turno</th>
<th scope="col">Cambiar</th>
</tr>
</thead>
<tbody>
{{- range .Filas}}
<tr>
<th scope="row">{{.Legible}}</th>
<td>
<span class="estado finde-{{.Turno}}">{{.Etiqueta}}</span>
{{- if .AMano}} <span class="pista">puesto a mano</span>{{end}}
</td>
<td>
<form class="en-linea" method="post" action="/pedir/findes">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="viernes" value="{{.Viernes}}">
<button type="submit" name="turno" value="tranqui" class="pedir-boton-flojo">Tranqui</button>
<button type="submit" name="turno" value="ruido" class="pedir-boton-flojo">Con ruido</button>
{{- if .AMano}}
<button type="submit" name="turno" value="" class="pedir-boton-flojo">Automático</button>
{{- end}}
</form>
</td>
</tr>
{{- end}}
</tbody>
</table>
</div>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,64 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Vuestro cupo">
<h2>Vuestro cupo</h2>
<p class="pie">
Os quedan <strong>{{.Restantes}}</strong> de {{.Reglas.CupoDias}} días dentro de los
próximos {{.Reglas.MesesVentana}} meses. Se pueden pedir fechas hasta el {{.Hasta}}.
{{- if .DiasUsados}} Ahora mismo ocupáis {{.DiasUsados}}.{{end}}
</p>
<p><a class="pedir-boton" href="/pedir/nueva">Pedir una fecha</a></p>
</section>
<section class="pantalla" aria-label="Vuestras fechas">
<h2>Vuestras fechas</h2>
{{- if .Mias}}
<div class="tabla-envoltorio">
<table class="pedir-tabla">
<thead>
<tr>
<th scope="col">Fecha</th>
<th scope="col">Evento</th>
<th scope="col">Estado</th>
<th scope="col">Caduca</th>
<th scope="col"><span class="sr-only">Acciones</span></th>
</tr>
</thead>
<tbody>
{{- range .Mias}}
<tr>
<td>
{{.Legible}}{{if gt .Dias 1}} <span class="pista">({{.Dias}} días)</span>{{end}}
{{- if .Evento.Hora}}<br><span class="pista">a las {{.Evento.Hora}}</span>{{end}}
</td>
<td>
{{.Evento.Titulo}}
{{- if .Evento.Externo}}<br><span class="pista">amadrináis a {{.Evento.Amadrinado}}</span>{{end}}
</td>
<td><span class="estado estado-{{.Evento.Estado}}">{{.Etiqueta}}</span></td>
<td>{{if .Caduca}}{{.Caduca}}{{else}}—{{end}}</td>
<td>
{{- if .Liberable}}
<form method="post" action="/pedir/liberar">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.Evento.ID}}">
<button type="submit" class="pedir-boton-flojo">Liberar</button>
</form>
{{- end}}
</td>
</tr>
{{- end}}
</tbody>
</table>
</div>
<p class="pie">
Una solicitud sin confirmar se borra sola a los {{.Reglas.DiasMaximoPendiente}} días,
o cuando falten menos de {{.Reglas.DiasMinimoConfirmar}} para la fecha. La columna
«caduca» dice cuándo, y al caducar la fecha queda libre para el resto.
</p>
{{- else}}
<p class="pie">No tenéis ninguna fecha pedida.</p>
{{- end}}
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,51 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Pedir una fecha">
<h2>Pedir una fecha</h2>
<ul class="pedir-reglas">
<li>Entre el <strong>{{.Primero.Legible}}</strong> y el <strong>{{.Ultimo.Legible}}</strong>.</li>
<li>Como mucho <strong>{{.Reglas.CupoDias}} días por colectivo</strong> en esos {{.Reglas.MesesVentana}} meses. Os quedan {{.Restantes}}.</li>
<li>Las fechas se piden para el finde: viernes, sábado o domingo.</li>
<li>Si el evento es de un colectivo de fuera, decid cuál: vuestro colectivo lo amadrina y responde por él.</li>
</ul>
<form class="pedir-form" method="post" action="/pedir/nueva">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<p>
<label for="titulo">Nombre del evento</label>
<input id="titulo" name="titulo" type="text" required maxlength="80" value="{{.Sol.Titulo}}">
</p>
<p>
<label for="desde">Primer día</label>
<input id="desde" name="desde" type="date" required
min="{{.Primero}}" max="{{.Ultimo}}" value="{{.Sol.Desde}}">
</p>
<p>
<label for="hasta">Último día</label>
<input id="hasta" name="hasta" type="date"
min="{{.Primero}}" max="{{.Ultimo}}" value="{{.Sol.Hasta}}">
<span class="pista">Dejadlo vacío si es un solo día.</span>
</p>
<p>
<label for="hora">Hora de inicio</label>
<input id="hora" name="hora" type="time" value="{{.Sol.Hora}}">
</p>
<p>
<label for="amadrinado">Colectivo de fuera al que amadrináis</label>
<input id="amadrinado" name="amadrinado" type="text" maxlength="80" value="{{.Sol.Amadrinado}}">
<span class="pista">Vacío si el evento es vuestro.</span>
</p>
<p>
<label for="contacto">Contacto de ese colectivo</label>
<input id="contacto" name="contacto" type="text" maxlength="120" value="{{.Sol.Contacto}}">
<span class="pista">Correo o teléfono de quien responde por ellos.</span>
</p>
<p>
<label for="notas">Notas para la asamblea</label>
<textarea id="notas" name="notas" rows="4" maxlength="500">{{.Sol.Notas}}</textarea>
</p>
<p><button type="submit">Pedir la fecha</button></p>
</form>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,35 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Solicitudes pendientes">
<h2>Esperando a la asamblea</h2>
{{- if .Filas}}
{{- range .Filas}}
<article class="solicitud">
<h3>{{.Evento.Titulo}}</h3>
<dl class="solicitud-datos">
<dt>Fecha</dt>
<dd>{{.Legible}}{{if gt .Dias 1}} ({{.Dias}} días){{end}}{{if .Evento.Hora}}, a las {{.Evento.Hora}}{{end}}</dd>
<dt>Pide</dt><dd>{{.Quien}}</dd>
{{- if .Evento.Contacto}}<dt>Contacto</dt><dd>{{.Evento.Contacto}}</dd>{{end}}
{{- if .Turno}}<dt>Ese finde</dt><dd>{{.Turno}}</dd>{{end}}
<dt>Faltan</dt><dd>{{.Faltan}} días</dd>
{{- if .Caduca}}<dt>Caduca</dt><dd>{{.Caduca}}</dd>{{end}}
{{- if .Evento.Notas}}<dt>Notas</dt><dd>{{.Evento.Notas}}</dd>{{end}}
</dl>
<form class="solicitud-resolver" method="post" action="/pedir/resolver">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.Evento.ID}}">
<label for="motivo-{{.Evento.ID}}">Acuerdo de la asamblea</label>
<input id="motivo-{{.Evento.ID}}" name="motivo" type="text" maxlength="200"
placeholder="opcional">
<button type="submit" name="accion" value="confirmar" class="pedir-boton">Confirmar</button>
<button type="submit" name="accion" value="rechazar" class="pedir-boton-flojo">Rechazar</button>
</form>
</article>
{{- end}}
{{- else}}
<p class="pie">No hay nada pendiente.</p>
{{- end}}
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,65 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Fechas pedidas y confirmadas">
<h2>Qué findes están cogidos</h2>
<p class="pie">
Pincha en un día libre del finde para pedirlo.
En <strong>negro</strong>, fechas que un colectivo ha pedido y esperan a que la
asamblea las confirme. En <strong>verde chillón</strong>, fechas ya confirmadas. Los findes se
turnan entre tranquilos y con ruido. Hoy se pueden pedir fechas hasta el
{{.Hasta}}, con un máximo de {{.Reglas.CupoDias}} días por colectivo.
Los horarios fijos de cada semana están en <a href="/calendario.html">Calendario</a>.
</p>
<div class="leyenda-pedir">
<span class="cal-ev cal-ev-solicitado leyenda-muestra">Solicitado</span>
<span class="cal-ev cal-ev-confirmado leyenda-muestra">Confirmado</span>
<span class="leyenda-finde cal-tranqui">Finde tranqui</span>
<span class="leyenda-finde cal-ruido">Finde con ruido</span>
</div>
<input type="checkbox" id="ver-semana" class="ver-semana sr-only">
<label for="ver-semana" class="ver-semana-boton pedir-boton-flojo"><span class="no">Ver la semana entera</span><span class="si">Ver solo los findes</span></label>
{{- range .Meses}}
{{template "mes" .}}
{{- end}}
</section>
{{- if not .Dentro}}
<section class="pantalla" id="entrar" aria-label="Cómo se pide un evento">
<h2>Cómo se pide</h2>
<p class="pie">
Si tu colectivo <strong>no es de la casa</strong>, la fecha se pide en persona: ven a
la <strong>asamblea de externas, los lunes a las 19:00</strong>, en la Enredadera,
calle de la Coruña nº 5. Allí se habla del evento y un colectivo de la casa lo
amadrina y se hace responsable.
</p>
<p class="pie">
Si tu colectivo <strong>ya tiene credenciales</strong>, entra aquí y pide la fecha.
Desde aquí se piden también las de los colectivos de fuera a los que amadrinéis.
</p>
<form class="pedir-form" method="post" action="/pedir/entrar" autocomplete="off">
{{- if .Sig}}<input type="hidden" name="sig" value="{{.Sig}}">{{end}}
<p>
<label for="usuario">Colectivo</label>
<input id="usuario" name="usuario" type="text" required maxlength="32"
autocapitalize="none" spellcheck="false">
</p>
<p>
<label for="clave">Contraseña</label>
<input id="clave" name="clave" type="password" required maxlength="256">
</p>
<p><button type="submit">Entrar</button></p>
</form>
<p class="pie">
No hay recuperación de contraseña por correo, a propósito: así no hay ninguna
dirección guardada ni ningún formulario que atacar. Si la habéis perdido, se resetea
en la asamblea.
</p>
</section>
{{- end}}
{{template "abajo" .}}

View file

@ -0,0 +1,53 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Todas las fechas">
<h2>Todas las fechas que vienen</h2>
{{- if .Filas}}
<div class="tabla-envoltorio">
<table class="pedir-tabla">
<thead>
<tr>
<th scope="col">Fecha</th>
<th scope="col">Evento</th>
<th scope="col">Estado</th>
<th scope="col"><span class="sr-only">Acciones</span></th>
</tr>
</thead>
<tbody>
{{- range .Filas}}
<tr>
<td>
{{.Legible}}{{if gt .Dias 1}} <span class="pista">({{.Dias}} días)</span>{{end}}
{{- if .Evento.Hora}}<br><span class="pista">a las {{.Evento.Hora}}</span>{{end}}
</td>
<td>
{{.Evento.Titulo}}<br><span class="pista">{{.Quien}}</span>
</td>
<td><span class="estado estado-{{.Evento.Estado}}">{{.Etiqueta}}</span></td>
<td>
<div class="acciones">
<a class="pedir-boton-flojo" href="/pedir/editar?id={{.Evento.ID}}">Modificar</a>
<form method="post" action="/pedir/liberar">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.Evento.ID}}">
<input type="hidden" name="volver" value="todas">
<button type="submit" class="pedir-boton-flojo">Quitar</button>
</form>
</div>
</td>
</tr>
{{- end}}
</tbody>
</table>
</div>
<p class="pie">
Quitar deja la fecha libre para el resto; el evento queda en el registro como
cancelado. Las solicitudes que esperan confirmación están también en
<a href="/pedir/pendientes">Pendientes</a>.
</p>
{{- else}}
<p class="pie">No hay ningún evento en las próximas fechas.</p>
{{- end}}
</section>
{{template "abajo" .}}

252
gestor/reglas.go Normal file
View file

@ -0,0 +1,252 @@
package main
import (
"fmt"
"sort"
"strings"
"time"
)
// Reglas reune los limites que acordo la asamblea. Los valores vienen de
// /etc/enrecal/config.json para poder ajustarlos sin recompilar.
type Reglas struct {
MesesVentana int `json:"meses_ventana"` // no se puede pedir mas alla de hoy + N meses
CupoDias int `json:"cupo_dias"` // dias por colectivo dentro de la ventana
DiasMinimoConfirmar int `json:"dias_minimo_confirmar"` // por debajo de esto, una solicitud sin confirmar caduca
DiasMaximoPendiente int `json:"dias_maximo_pendiente"` // una solicitud no puede esperar mas que esto
DiasReservables []int `json:"dias_reservables"` // 1=lunes .. 7=domingo
AnclaFinde Dia `json:"ancla_finde"` // un viernes de referencia
TurnoAncla Turno `json:"turno_ancla"` // que turno le toca a ese finde
}
func ReglasPorDefecto() Reglas {
return Reglas{
MesesVentana: 3,
CupoDias: 3,
DiasMinimoConfirmar: 7,
DiasMaximoPendiente: 30,
DiasReservables: []int{5, 6, 7},
AnclaFinde: "2026-09-25",
TurnoAncla: Ruido,
}
}
// Ventana devuelve el primer y el ultimo dia que se pueden pedir hoy.
func (rg Reglas) Ventana(hoy Dia) (Dia, Dia) {
primero := hoy.Mas(rg.DiasMinimoConfirmar)
ultimo := DiaDe(hoy.MustTime().AddDate(0, rg.MesesVentana, 0))
return primero, ultimo
}
func (rg Reglas) reservable(d Dia) bool {
iso := d.DiaISO()
for _, v := range rg.DiasReservables {
if v == iso {
return true
}
}
return false
}
// Solicitud es lo que llega del formulario, ya recortado pero sin validar.
type Solicitud struct {
Solicitante string
Rol Rol
Titulo string
Amadrinado string
Contacto string
Desde Dia
Hasta Dia
Hora string
Notas string
Excluir string // ID del evento que se está modificando: no choca consigo mismo
}
// Ocupacion mapea cada dia ocupado con el titulo del evento que lo ocupa.
func Ocupacion(eventos []Evento, excluir string) map[Dia]Evento {
out := map[Dia]Evento{}
for _, e := range eventos {
if e.ID == excluir || !e.Estado.Ocupa() {
continue
}
for _, d := range e.Dias() {
out[d] = e
}
}
return out
}
// CupoUsado cuenta los dias que un colectivo ya tiene ocupados dentro de la
// ventana de reserva, sumando solicitados y confirmados.
func (rg Reglas) CupoUsado(usuario string, eventos []Evento, hoy Dia, excluir string) []Dia {
_, ultimo := rg.Ventana(hoy)
var dias []Dia
for _, e := range eventos {
if e.ID == excluir || e.Solicitante != usuario || !e.Estado.Ocupa() {
continue
}
for _, d := range e.Dias() {
if d >= hoy && d <= ultimo {
dias = append(dias, d)
}
}
}
sort.Slice(dias, func(i, j int) bool { return dias[i] < dias[j] })
return dias
}
// Validar aplica todas las reglas a una solicitud nueva. Devuelve un error con
// el motivo escrito para mostrarlo tal cual en el formulario.
func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error {
if strings.TrimSpace(s.Titulo) == "" {
return fmt.Errorf("falta el nombre del evento")
}
if len([]rune(s.Titulo)) > 80 {
return fmt.Errorf("el nombre del evento no puede pasar de 80 caracteres")
}
if s.Amadrinado != "" && strings.TrimSpace(s.Contacto) == "" {
return fmt.Errorf("si amadrinas a un colectivo de fuera hace falta un contacto suyo")
}
if _, err := s.Desde.Time(); err != nil {
return err
}
if _, err := s.Hasta.Time(); err != nil {
return err
}
if s.Hasta < s.Desde {
return fmt.Errorf("la fecha de fin es anterior a la de inicio")
}
if s.Hora != "" {
if _, err := time.Parse("15:04", s.Hora); err != nil {
return fmt.Errorf("la hora %q no vale, se escribe como 20:00", s.Hora)
}
}
dias := Rango(s.Desde, s.Hasta)
libre := s.Rol == RolEnre // la asamblea de la casa no tiene cupo ni ventana
if !libre {
primero, ultimo := rg.Ventana(hoy)
if s.Desde < primero {
return fmt.Errorf("hay que pedirlo con al menos %d días de antelación: "+
"la primera fecha libre es el %s. Con menos margen la solicitud caducaría sin que "+
"la asamblea llegue a verla",
rg.DiasMinimoConfirmar, primero.Legible())
}
if s.Hasta > ultimo {
return fmt.Errorf("solo se pueden pedir fechas hasta %s, que son %d meses vista",
ultimo.Legible(), rg.MesesVentana)
}
if len(dias) > rg.CupoDias {
return fmt.Errorf("un evento no puede ocupar más de %d días y estás pidiendo %d",
rg.CupoDias, len(dias))
}
for _, d := range dias {
if !rg.reservable(d) {
return fmt.Errorf("el %s no es día reservable: las fechas se piden para %s. "+
"Para otro día, habladlo en la asamblea", d.Legible(), rg.nombresReservables())
}
}
}
ocupados := Ocupacion(eventos, s.Excluir)
for _, d := range dias {
if e, ok := ocupados[d]; ok {
return fmt.Errorf("el %s ya está cogido por %q (%s)", d.Legible(), e.Titulo,
strings.ToLower(e.Estado.Etiqueta()))
}
}
if !libre {
usados := rg.CupoUsado(s.Solicitante, eventos, hoy, s.Excluir)
if len(usados)+len(dias) > rg.CupoDias {
return fmt.Errorf("te pasáis del cupo: %d días por colectivo en los próximos %d meses. "+
"Ya tenéis %s y pedís %d día(s) más. Cancelad una de esas fechas o esperad a que pasen",
rg.CupoDias, rg.MesesVentana, listaDias(usados), len(dias))
}
}
return nil
}
func (rg Reglas) nombresReservables() string {
var out []string
for _, v := range rg.DiasReservables {
if v >= 1 && v <= 7 {
out = append(out, diasSemana[v-1])
}
}
return enumerar(out)
}
// enumerar une una lista en castellano: "viernes, sábado y domingo".
func enumerar(partes []string) string {
switch len(partes) {
case 0:
return ""
case 1:
return partes[0]
}
return strings.Join(partes[:len(partes)-1], ", ") + " y " + partes[len(partes)-1]
}
func listaDias(dias []Dia) string {
var out []string
for _, d := range dias {
out = append(out, d.Legible())
}
return enumerar(out)
}
// Caducidad devuelve el motivo por el que un evento debe caducar, o "" si
// todavia no toca.
func (rg Reglas) Caducidad(e Evento, ahora time.Time) string {
if e.Estado != Solicitado {
return ""
}
hoy := DiaDe(ahora)
if e.Desde < hoy.Mas(rg.DiasMinimoConfirmar) {
return fmt.Sprintf("sin confirmar a menos de %d días de la fecha", rg.DiasMinimoConfirmar)
}
if ahora.Sub(e.Creado) > time.Duration(rg.DiasMaximoPendiente)*24*time.Hour {
return fmt.Sprintf("más de %d días esperando confirmación", rg.DiasMaximoPendiente)
}
return ""
}
// ViernesDe devuelve el viernes del finde al que pertenece un dia, o "" si el
// dia no cae en finde.
func ViernesDe(d Dia) Dia {
iso := d.DiaISO()
if iso < 5 {
return ""
}
return d.Mas(5 - iso)
}
// TurnoDe resuelve si un finde es tranqui o con ruido: primero mira las
// excepciones puestas a mano y si no alterna semana a semana desde el ancla.
func (rg Reglas) TurnoDe(d Dia, exc map[Dia]Turno) Turno {
viernes := ViernesDe(d)
if viernes == "" {
return ""
}
if t, ok := exc[viernes]; ok {
return t
}
ancla := ViernesDe(rg.AnclaFinde)
if ancla == "" {
ancla = rg.AnclaFinde
}
dif := int(viernes.MustTime().Sub(ancla.MustTime()).Hours() / 24 / 7)
if dif < 0 {
dif = -dif
}
otro := Tranqui
if rg.TurnoAncla == Tranqui {
otro = Ruido
}
if dif%2 == 0 {
return rg.TurnoAncla
}
return otro
}

250
gestor/reglas_test.go Normal file
View file

@ -0,0 +1,250 @@
package main
import (
"strings"
"testing"
)
const hoy = Dia("2026-09-22") // martes
func solicitud(usuario string, desde, hasta Dia) Solicitud {
return Solicitud{
Solicitante: usuario,
Rol: RolInterno,
Titulo: "Concierto de prueba",
Desde: desde,
Hasta: hasta,
Hora: "20:00",
}
}
func evento(id, usuario string, desde, hasta Dia, estado Estado) Evento {
return Evento{
ID: id,
Titulo: "Evento " + id,
Solicitante: usuario,
Desde: desde,
Hasta: hasta,
Estado: estado,
Creado: hoy.MustTime(),
}
}
func TestVentanaTresMeses(t *testing.T) {
rg := ReglasPorDefecto()
// 2026-10-02 es viernes, dentro de los tres meses.
if err := rg.Validar(solicitud("hacklab", "2026-10-02", "2026-10-02"), nil, hoy); err != nil {
t.Fatalf("una fecha dentro de la ventana deberia valer: %v", err)
}
// 2027-01-01 es viernes y cae fuera: hoy + 3 meses es 2026-12-22.
err := rg.Validar(solicitud("hacklab", "2027-01-01", "2027-01-01"), nil, hoy)
if err == nil || !strings.Contains(err.Error(), "3 meses") {
t.Fatalf("una fecha a mas de 3 meses tiene que fallar por la ventana, dio: %v", err)
}
}
func TestAntelacionMinima(t *testing.T) {
rg := ReglasPorDefecto()
// 2026-09-25 es el viernes de esta semana: a 3 dias, caducaria al instante.
err := rg.Validar(solicitud("hacklab", "2026-09-25", "2026-09-25"), nil, hoy)
if err == nil || !strings.Contains(err.Error(), "antelación") {
t.Fatalf("pedir con menos de 7 dias tiene que fallar, dio: %v", err)
}
}
func TestCupoTresDiasEnLaVentana(t *testing.T) {
rg := ReglasPorDefecto()
// Tres dias ya cogidos por el mismo colectivo: uno confirmado, dos solicitados.
eventos := []Evento{
evento("a", "hacklab", "2026-10-02", "2026-10-02", Confirmado),
evento("b", "hacklab", "2026-10-10", "2026-10-11", Solicitado),
}
err := rg.Validar(solicitud("hacklab", "2026-10-16", "2026-10-16"), eventos, hoy)
if err == nil || !strings.Contains(err.Error(), "cupo") {
t.Fatalf("el cuarto dia tiene que fallar por cupo, dio: %v", err)
}
// El cupo es por colectivo: otro colectivo si puede coger ese dia.
if err := rg.Validar(solicitud("feminismos", "2026-10-16", "2026-10-16"), eventos, hoy); err != nil {
t.Fatalf("otro colectivo no deberia arrastrar el cupo del primero: %v", err)
}
// Los dias rechazados o caducados no cuentan.
eventos[1].Estado = Caducado
if err := rg.Validar(solicitud("hacklab", "2026-10-16", "2026-10-17"), eventos, hoy); err != nil {
t.Fatalf("un evento caducado no deberia gastar cupo: %v", err)
}
}
func TestCupoNoCuentaFechasPasadas(t *testing.T) {
rg := ReglasPorDefecto()
viejos := []Evento{evento("v", "hacklab", "2026-08-07", "2026-08-09", Confirmado)}
if err := rg.Validar(solicitud("hacklab", "2026-10-02", "2026-10-04"), viejos, hoy); err != nil {
t.Fatalf("lo que ya paso no gasta cupo: %v", err)
}
}
func TestSinSolapes(t *testing.T) {
rg := ReglasPorDefecto()
eventos := []Evento{evento("a", "hacklab", "2026-10-03", "2026-10-03", Solicitado)}
err := rg.Validar(solicitud("feminismos", "2026-10-02", "2026-10-04"), eventos, hoy)
if err == nil || !strings.Contains(err.Error(), "ya está cogido") {
t.Fatalf("un dia solicitado por otro tiene que bloquear, dio: %v", err)
}
// Al caducar se libera la fecha.
eventos[0].Estado = Caducado
if err := rg.Validar(solicitud("feminismos", "2026-10-02", "2026-10-04"), eventos, hoy); err != nil {
t.Fatalf("una fecha caducada tiene que quedar libre: %v", err)
}
}
func TestSoloFindesSalvoLaEnre(t *testing.T) {
rg := ReglasPorDefecto()
// 2026-10-06 es martes.
err := rg.Validar(solicitud("hacklab", "2026-10-06", "2026-10-06"), nil, hoy)
if err == nil || !strings.Contains(err.Error(), "no es día reservable") {
t.Fatalf("un martes no es reservable para un colectivo, dio: %v", err)
}
s := solicitud("enre", "2026-10-06", "2026-10-06")
s.Rol = RolEnre
if err := rg.Validar(s, nil, hoy); err != nil {
t.Fatalf("la enre si puede poner cualquier dia: %v", err)
}
}
func TestLaEnreNoTieneCupoNiVentana(t *testing.T) {
rg := ReglasPorDefecto()
eventos := []Evento{
evento("a", "enre", "2026-10-02", "2026-10-04", Confirmado),
evento("b", "enre", "2026-10-09", "2026-10-11", Confirmado),
}
s := solicitud("enre", "2027-06-04", "2027-06-06")
s.Rol = RolEnre
if err := rg.Validar(s, eventos, hoy); err != nil {
t.Fatalf("la enre no deberia topar con cupo ni ventana: %v", err)
}
}
func TestAmadrinadoPideContacto(t *testing.T) {
rg := ReglasPorDefecto()
s := solicitud("hacklab", "2026-10-02", "2026-10-02")
s.Amadrinado = "Colectivo de fuera"
err := rg.Validar(s, nil, hoy)
if err == nil || !strings.Contains(err.Error(), "contacto") {
t.Fatalf("amadrinar sin contacto tiene que fallar, dio: %v", err)
}
s.Contacto = "fuera@riseup.net"
if err := rg.Validar(s, nil, hoy); err != nil {
t.Fatalf("con contacto deberia valer: %v", err)
}
}
func TestCaducidad(t *testing.T) {
rg := ReglasPorDefecto()
ahora := hoy.MustTime()
cerca := evento("a", "hacklab", "2026-09-27", "2026-09-27", Solicitado)
if m := rg.Caducidad(cerca, ahora); m == "" || !strings.Contains(m, "menos de 7") {
t.Fatalf("a 5 dias sin confirmar tiene que caducar, dio %q", m)
}
lejos := evento("b", "hacklab", "2026-11-06", "2026-11-06", Solicitado)
if m := rg.Caducidad(lejos, ahora); m != "" {
t.Fatalf("una solicitud reciente y lejana no caduca, dio %q", m)
}
vieja := lejos
vieja.Creado = ahora.AddDate(0, 0, -31)
if m := rg.Caducidad(vieja, ahora); m == "" || !strings.Contains(m, "esperando") {
t.Fatalf("31 dias esperando tiene que caducar, dio %q", m)
}
confirmada := cerca
confirmada.Estado = Confirmado
if m := rg.Caducidad(confirmada, ahora); m != "" {
t.Fatalf("un evento confirmado no caduca nunca, dio %q", m)
}
}
func TestTurnoDeFinde(t *testing.T) {
rg := ReglasPorDefecto() // ancla 2026-09-25, turno ruido
casos := []struct {
dia Dia
quer Turno
}{
{"2026-09-25", Ruido}, // viernes ancla
{"2026-09-26", Ruido}, // sabado del mismo finde
{"2026-09-27", Ruido}, // domingo del mismo finde
{"2026-10-02", Tranqui}, // finde siguiente
{"2026-10-09", Ruido},
{"2026-10-16", Tranqui},
{"2026-09-18", Tranqui}, // hacia atras tambien alterna
}
for _, c := range casos {
if got := rg.TurnoDe(c.dia, nil); got != c.quer {
t.Errorf("%s: turno %q, esperaba %q", c.dia, got, c.quer)
}
}
// Un dia de entre semana no tiene turno.
if got := rg.TurnoDe("2026-10-06", nil); got != "" {
t.Errorf("un martes no tiene turno de finde, dio %q", got)
}
// La excepcion escrita a mano manda sobre la alternancia.
exc := map[Dia]Turno{"2026-10-02": Ruido}
if got := rg.TurnoDe("2026-10-03", exc); got != Ruido {
t.Errorf("la excepcion del viernes tiene que valer para todo el finde, dio %q", got)
}
}
func TestRangoYDiaISO(t *testing.T) {
dias := Rango("2026-10-02", "2026-10-04")
if len(dias) != 3 || dias[0] != "2026-10-02" || dias[2] != "2026-10-04" {
t.Fatalf("rango mal calculado: %v", dias)
}
if Rango("2026-10-04", "2026-10-02") != nil {
t.Fatal("un rango invertido tiene que dar nada")
}
if got := Dia("2026-10-02").DiaISO(); got != 5 {
t.Fatalf("2026-10-02 es viernes, iso 5, dio %d", got)
}
if got := ViernesDe("2026-10-04"); got != "2026-10-02" {
t.Fatalf("el viernes del domingo 4 es el 2, dio %q", got)
}
}
func TestVentanaDevuelveTresMeses(t *testing.T) {
rg := ReglasPorDefecto()
primero, ultimo := rg.Ventana(hoy)
if primero != "2026-09-29" {
t.Errorf("primera fecha pedible %q, esperaba 2026-09-29", primero)
}
if ultimo != "2026-12-22" {
t.Errorf("ultima fecha pedible %q, esperaba 2026-12-22", ultimo)
}
}
func TestModificarNoChocaConsigoMismo(t *testing.T) {
rg := ReglasPorDefecto()
eventos := []Evento{
evento("a", "hacklab", "2026-10-02", "2026-10-03", Confirmado),
evento("b", "vivienda", "2026-10-09", "2026-10-09", Solicitado),
}
// La asamblea mueve la hora del evento «a» sin cambiarle los días.
s := solicitud("hacklab", "2026-10-02", "2026-10-03")
s.Rol, s.Excluir = RolEnre, "a"
if err := rg.Validar(s, eventos, hoy); err != nil {
t.Fatalf("modificar un evento en sus propios días deberia valer: %v", err)
}
// Pero no puede llevarlo encima del evento «b».
s.Desde, s.Hasta = "2026-10-09", "2026-10-09"
if err := rg.Validar(s, eventos, hoy); err == nil || !strings.Contains(err.Error(), "cogido") {
t.Fatalf("mover «a» encima de «b» deberia fallar por solape, dio %v", err)
}
}

152
gestor/vista.go Normal file
View file

@ -0,0 +1,152 @@
package main
import (
"fmt"
"time"
)
// FilaEvento es un evento ya resuelto para pintarlo en una celda del mes.
type FilaEvento struct {
Hora string
Texto string
Quien string
Estado Estado
Etiqueta string
Sigue bool // dia intermedio o final de un evento de varios dias
}
type Celda struct {
Dia Dia
Numero int
Vacia bool
Hoy bool
Pasado bool
Libre bool // se puede pedir: dia reservable, dentro de la ventana y sin ocupar
Turno Turno
EtiquetaTurno string // solo en el viernes, para no repetirla tres veces
Eventos []FilaEvento
}
type Semana struct {
Dias []Celda
}
type Mes struct {
Nombre string
Anio int
Semanas []Semana
}
// Vista arma los meses que se pintan, a partir del estado del registro.
// Aqui no hay horarios semanales: esos siguen en calendario.html, que se dibuja
// con su propio guion y no se toca.
type Vista struct {
reglas Reglas
exc map[Dia]Turno
eventos []Evento
nombres map[string]string
hoy Dia
}
func (v Vista) eventosDe(d Dia) []FilaEvento {
var out []FilaEvento
for _, e := range v.eventos {
if !e.Estado.Ocupa() || d < e.Desde || d > e.Hasta {
continue
}
quien := v.nombres[e.Solicitante]
if quien == "" {
quien = e.Solicitante
}
if e.Externo() {
quien = fmt.Sprintf("%s · amadrina %s", e.Amadrinado, quien)
}
out = append(out, FilaEvento{
Hora: e.Hora,
Texto: e.Titulo,
Quien: quien,
Estado: e.Estado,
Etiqueta: e.Estado.Etiqueta(),
Sigue: d != e.Desde,
})
}
return out
}
func (v Vista) celda(d Dia, numero int) Celda {
turno := v.reglas.TurnoDe(d, v.exc)
etiqueta := ""
if turno != "" && d.DiaISO() == 5 {
etiqueta = turno.Etiqueta()
}
primero, ultimo := v.reglas.Ventana(v.hoy)
eventos := v.eventosDe(d)
return Celda{
Dia: d,
Numero: numero,
Hoy: d == v.hoy,
Pasado: d < v.hoy,
Libre: len(eventos) == 0 && d >= primero && d <= ultimo && v.reglas.reservable(d),
Turno: turno,
EtiquetaTurno: etiqueta,
Eventos: eventos,
}
}
func (v Vista) Mes(anio, mes int) Mes {
primero := fechaMes(anio, mes)
total := diasDelMes(anio, mes)
hueco := (int(primero.Weekday()) + 6) % 7 // celdas vacias antes del dia 1
m := Mes{Nombre: meses[mes-1], Anio: anio}
semana := Semana{}
for i := 0; i < hueco; i++ {
semana.Dias = append(semana.Dias, Celda{Vacia: true})
}
for dia := 1; dia <= total; dia++ {
d := DiaDe(primero.AddDate(0, 0, dia-1))
semana.Dias = append(semana.Dias, v.celda(d, dia))
if len(semana.Dias) == 7 {
m.Semanas = append(m.Semanas, semana)
semana = Semana{}
}
}
if len(semana.Dias) > 0 {
for len(semana.Dias) < 7 {
semana.Dias = append(semana.Dias, Celda{Vacia: true})
}
m.Semanas = append(m.Semanas, semana)
}
return m
}
// Meses devuelve el mes que corre y los siguientes, que son los que se pueden
// pedir.
func (v Vista) Meses(cuantos int) []Mes {
t := v.hoy.MustTime()
var out []Mes
for i := 0; i < cuantos; i++ {
m := t.AddDate(0, i, 0)
out = append(out, v.Mes(m.Year(), int(m.Month())))
}
return out
}
// Proximas lista los eventos que vienen, para la tabla de debajo del mes.
func (v Vista) Proximas() []Evento {
var out []Evento
for _, e := range v.eventos {
if e.Estado.Ocupa() && e.Hasta >= v.hoy {
out = append(out, e)
}
}
return out
}
func diasDelMes(anio, mes int) int {
return fechaMes(anio, mes).AddDate(0, 1, -1).Day()
}
func fechaMes(anio, mes int) time.Time {
return time.Date(anio, time.Month(mes), 1, 0, 0, 0, 0, time.Local)
}