Cuenta de externas, arreglos de seguridad, títulos arriba y despliegue
- Rol «externas» para la asamblea de externas: pide sin cupo, siempre con el colectivo de fuera y su contacto, y sus solicitudes salen en naranja hasta que la asamblea de la Enre las confirma. - /pedir/ explica en una línea que los colectivos externos piden fecha en la asamblea de externas, los lunes de 19:00 a 20:00. - Seguridad: argon2 limitado a dos cálculos a la vez y el intento se cuenta antes de calcular (60 accesos simultáneos llevaban el proceso a 3,8 GB); bloqueo por cuenta a 20 fallos para que no se pueda dejar fuera a la asamblea; avisos por sesión en vez de por URL; sig sin «..»; eventos de 31 días como mucho; parámetros de argon2 acotados. - Títulos pegados arriba en todas las páginas; la franja de la enredadera recupera ese alto para seguir llenando la pantalla. - despliegue/: instalar.sh, cuenta.sh, servicio de systemd con el proceso encerrado, fragmento de nginx que además deja de servir .git y las notas internas, y configuración de ejemplo. Ninguna contraseña en el repositorio.
This commit is contained in:
parent
ce3127baff
commit
7d5dbeec60
24 changed files with 606 additions and 96 deletions
|
|
@ -26,6 +26,19 @@ const (
|
|||
argonSal = 16
|
||||
)
|
||||
|
||||
// argonHuecos limita cuántos cálculos de argon2 corren a la vez. Cada uno
|
||||
// reserva argonMemoria (64 MiB): sin este tope, sesenta intentos de acceso
|
||||
// simultáneos llevaban el proceso a casi 4 GB y podían tumbar la máquina, que
|
||||
// comparte con gitea y el resto de servicios de la Enre. Los que llegan de más
|
||||
// esperan su turno en vez de reservar memoria.
|
||||
var argonHuecos = make(chan struct{}, 2)
|
||||
|
||||
func argonID(clave, sal []byte, t, m uint32, p uint8, largo uint32) []byte {
|
||||
argonHuecos <- struct{}{}
|
||||
defer func() { <-argonHuecos }()
|
||||
return argon2.IDKey(clave, sal, t, m, p, largo)
|
||||
}
|
||||
|
||||
func aleatorio(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
|
@ -40,7 +53,7 @@ func nuevoID() string { return hex.EncodeToString(aleatorio(16)) }
|
|||
// 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)
|
||||
h := argonID([]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),
|
||||
|
|
@ -72,7 +85,13 @@ func VerificarClave(codificado, clave string) bool {
|
|||
if err != nil {
|
||||
return false
|
||||
}
|
||||
calculado := argon2.IDKey([]byte(clave), sal, uint32(iteraciones), uint32(memoria),
|
||||
// Los parámetros salen del propio hash, que escribimos nosotros; aun así se
|
||||
// acotan, para que un registro manipulado no pida gigas de memoria.
|
||||
if memoria <= 0 || memoria > argonMemoria || iteraciones <= 0 || iteraciones > 10 ||
|
||||
hilos <= 0 || hilos > 16 || len(esperado) == 0 || len(esperado) > 64 {
|
||||
return false
|
||||
}
|
||||
calculado := argonID([]byte(clave), sal, uint32(iteraciones), uint32(memoria),
|
||||
uint8(hilos), uint32(len(esperado)))
|
||||
return subtle.ConstantTimeCompare(calculado, esperado) == 1
|
||||
}
|
||||
|
|
@ -187,6 +206,27 @@ func (s *Sesiones) TomarClave(id string) (string, string) {
|
|||
return usuario, clave
|
||||
}
|
||||
|
||||
func (s *Sesiones) PonerAviso(id, aviso, fallo string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if ses, ok := s.m[id]; ok {
|
||||
ses.Aviso, ses.Error = aviso, fallo
|
||||
}
|
||||
}
|
||||
|
||||
// TomarAviso devuelve el aviso pendiente y lo borra: se enseña una vez.
|
||||
func (s *Sesiones) TomarAviso(id string) (string, string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ses, ok := s.m[id]
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
aviso, fallo := ses.Aviso, ses.Error
|
||||
ses.Aviso, ses.Error = "", ""
|
||||
return aviso, fallo
|
||||
}
|
||||
|
||||
func (s *Sesiones) Borrar(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -234,29 +274,22 @@ func NuevoCerrojo(maxIntentos int, ventana, espera time.Duration) *Cerrojo {
|
|||
}
|
||||
}
|
||||
|
||||
func (c *Cerrojo) Bloqueado(claves ...string) (bool, time.Duration) {
|
||||
// Intentar comprueba y apunta el intento en la misma operación, antes de
|
||||
// calcular la clave. Si se apuntara después, como hacía Fallo, cien intentos
|
||||
// simultáneos pasaban todos la comprobación antes de que se apuntara el
|
||||
// primero.
|
||||
func (c *Cerrojo) Intentar(clave 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
|
||||
}
|
||||
recientes := c.podar(clave, ahora)
|
||||
if len(recientes) >= c.maxIntentos {
|
||||
if falta := c.espera - ahora.Sub(recientes[len(recientes)-1]); falta > 0 {
|
||||
return false, 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)
|
||||
}
|
||||
c.fallos[clave] = append(recientes, ahora)
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func (c *Cerrojo) Limpiar(claves ...string) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ func ConfigPorDefecto() Config {
|
|||
Escucha: "127.0.0.1:8090",
|
||||
BaseURL: "https://www.laenre.net",
|
||||
DirDatos: "/var/lib/enrecal",
|
||||
DirPlantillas: "/var/www/laenre/gestor/plantillas",
|
||||
DirPlantillas: "/opt/enrecal/plantillas",
|
||||
DirEstatico: "/var/www/laenre",
|
||||
LogAuth: "/var/log/enrecal/auth.log",
|
||||
CookieSegura: true,
|
||||
|
|
@ -89,6 +89,11 @@ func (c Config) Validar() error {
|
|||
const (
|
||||
inactividadSesion = 8 * time.Hour
|
||||
maxIntentos = 5
|
||||
// Por cuenta el tope es más alto que por IP: si fuera 5, cualquiera podría
|
||||
// dejar a la asamblea sin entrar fallando 5 veces cada cuarto de hora. Con
|
||||
// argon2 y claves de 12 caracteres, 20 intentos cada 10 minutos no bastan
|
||||
// para adivinar nada.
|
||||
maxIntentosCuenta = 20
|
||||
ventanaIntentos = 10 * time.Minute
|
||||
esperaBloqueo = 15 * time.Minute
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ type App struct {
|
|||
cfg Config
|
||||
alm Almacen
|
||||
ses *Sesiones
|
||||
cer *Cerrojo
|
||||
cer *Cerrojo // por IP
|
||||
cerU *Cerrojo // por cuenta
|
||||
auth *AuthLog
|
||||
|
||||
// mu serializa las escrituras de eventos: comprobar las reglas y guardar
|
||||
|
|
@ -51,7 +52,7 @@ func main() {
|
|||
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")
|
||||
rol = flag.String("rol", string(RolInterno), "rol de la cuenta: enre, interno o externas")
|
||||
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)")
|
||||
)
|
||||
|
|
@ -156,6 +157,7 @@ func NuevaApp(cfg Config) (*App, error) {
|
|||
alm: reg,
|
||||
ses: NuevasSesiones(inactividadSesion),
|
||||
cer: NuevoCerrojo(maxIntentos, ventanaIntentos, esperaBloqueo),
|
||||
cerU: NuevoCerrojo(maxIntentosCuenta, ventanaIntentos, esperaBloqueo),
|
||||
auth: al,
|
||||
plantillas: plantillas,
|
||||
}, nil
|
||||
|
|
@ -177,8 +179,17 @@ func (a *App) nombres() map[string]string {
|
|||
return out
|
||||
}
|
||||
|
||||
func (a *App) roles() map[string]Rol {
|
||||
out := map[string]Rol{}
|
||||
for _, c := range a.alm.Cuentas() {
|
||||
out[c.Usuario] = c.Rol
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) vista() Vista {
|
||||
return Vista{
|
||||
roles: a.roles(),
|
||||
reglas: a.cfg.Reglas,
|
||||
exc: a.alm.Excepciones(),
|
||||
eventos: a.alm.Eventos(),
|
||||
|
|
@ -224,8 +235,8 @@ func (a *App) AltaCuenta(usuario, nombre string, rol Rol, fija string, definitiv
|
|||
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 !rol.Valido() {
|
||||
return fmt.Errorf("el rol solo puede ser %q, %q o %q", RolEnre, RolInterno, RolExternas)
|
||||
}
|
||||
if _, existe := a.alm.BuscarCuenta(usuario); existe {
|
||||
return fmt.Errorf("la cuenta %q ya existe; para cambiarle la clave usa la orden clave", usuario)
|
||||
|
|
|
|||
|
|
@ -67,9 +67,15 @@ type Rol string
|
|||
|
||||
const (
|
||||
RolEnre Rol = "enre" // la asamblea: confirma y rechaza
|
||||
RolInterno Rol = "interno" // colectivo de la casa: solicita y amadrina
|
||||
RolInterno Rol = "interno" // colectivo de la Enre: solicita y amadrina
|
||||
// RolExternas es la cuenta de la asamblea de externas: apunta los eventos
|
||||
// de colectivos de fuera que se hablan allí, sin cupo, y la asamblea de la
|
||||
// Enre los confirma.
|
||||
RolExternas Rol = "externas"
|
||||
)
|
||||
|
||||
func (r Rol) Valido() bool { return r == RolEnre || r == RolInterno || r == RolExternas }
|
||||
|
||||
type Cuenta struct {
|
||||
Usuario string `json:"usuario"`
|
||||
Nombre string `json:"nombre"` // nombre visible del colectivo
|
||||
|
|
@ -168,4 +174,10 @@ type Sesion struct {
|
|||
// por la URL y acabe en el historial o en el log de nginx.
|
||||
ClaveDe string
|
||||
ClaveNueva string
|
||||
|
||||
// Aviso o error de la siguiente página. Van por la sesión y no por la URL:
|
||||
// con «?a=» cualquiera podía fabricar un enlace de laenre.net que
|
||||
// enseñara el texto que quisiera.
|
||||
Aviso string
|
||||
Error string
|
||||
}
|
||||
|
|
|
|||
122
gestor/pedir.go
122
gestor/pedir.go
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -134,19 +135,20 @@ func ipCliente(r *http.Request) string {
|
|||
// Base es lo que necesita el armazón de la página: el título, quién ha entrado
|
||||
// y los avisos.
|
||||
type Base struct {
|
||||
Titulo string
|
||||
Sub string
|
||||
Actual string // qué entrada del menú de la sección va marcada
|
||||
Dentro bool
|
||||
Usuario string
|
||||
Nombre string
|
||||
EsEnre bool
|
||||
CSRF string
|
||||
Aviso string
|
||||
Error string
|
||||
Reglas Reglas
|
||||
Hasta string
|
||||
Sig string // adónde volver después de entrar, ya comprobado
|
||||
Titulo string
|
||||
Sub string
|
||||
Actual string // qué entrada del menú de la sección va marcada
|
||||
Dentro bool
|
||||
Usuario string
|
||||
Nombre string
|
||||
EsEnre bool
|
||||
EsExternas bool
|
||||
CSRF string
|
||||
Aviso string
|
||||
Error string
|
||||
Reglas Reglas
|
||||
Hasta string
|
||||
Sig string // adónde volver después de entrar, ya comprobado
|
||||
}
|
||||
|
||||
// destinoSeguro deja pasar solo rutas de la propia sección: nada de otro
|
||||
|
|
@ -159,6 +161,14 @@ func destinoSeguro(sig string) string {
|
|||
strings.HasPrefix(sig, "//") {
|
||||
return ""
|
||||
}
|
||||
// Nada de «/pedir/../»: la ruta tiene que estar ya limpia.
|
||||
limpia := path.Clean(u.Path)
|
||||
if strings.HasSuffix(u.Path, "/") && limpia != "/" {
|
||||
limpia += "/"
|
||||
}
|
||||
if limpia != u.Path || !strings.HasPrefix(limpia, "/pedir/") {
|
||||
return ""
|
||||
}
|
||||
return u.RequestURI()
|
||||
}
|
||||
|
||||
|
|
@ -181,6 +191,7 @@ func (a *App) base(titulo, sub, actual string, c Cuenta, ses *Sesion) Base {
|
|||
b.Usuario = c.Usuario
|
||||
b.Nombre = c.Nombre
|
||||
b.EsEnre = c.Rol == RolEnre
|
||||
b.EsExternas = c.Rol == RolExternas
|
||||
b.CSRF = ses.CSRF
|
||||
}
|
||||
return b
|
||||
|
|
@ -275,6 +286,12 @@ func (a *App) csrfValido(ses *Sesion, r *http.Request) bool {
|
|||
return iguales(r.PostFormValue("csrf"), ses.CSRF)
|
||||
}
|
||||
|
||||
// avisar deja un aviso para la página siguiente y redirige a ella.
|
||||
func (a *App) avisar(w http.ResponseWriter, r *http.Request, ses *Sesion, ruta, aviso string) {
|
||||
a.ses.PonerAviso(ses.ID, aviso, "")
|
||||
http.Redirect(w, r, ruta, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ portada
|
||||
|
||||
func (a *App) verPortada(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -302,9 +319,9 @@ func (a *App) verPortada(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
datos.Sig = sig
|
||||
datos.Aviso = r.URL.Query().Get("a")
|
||||
if sig != "" {
|
||||
datos.Aviso = "Para pedir esa fecha, entra con las credenciales de tu colectivo."
|
||||
datos.Aviso = "Para pedir esa fecha, entra con las credenciales de tu colectivo. " +
|
||||
"Colectivos externos: se pide en la asamblea de externas, los lunes de 19:00 a 20:00."
|
||||
}
|
||||
a.pintar(w, "portada.tmpl.html", datos)
|
||||
}
|
||||
|
|
@ -325,9 +342,19 @@ func (a *App) entrar(w http.ResponseWriter, r *http.Request) {
|
|||
usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario")))
|
||||
clave := r.PostFormValue("clave")
|
||||
ip := ipCliente(r)
|
||||
cerrojos := []string{"ip:" + ip, "usuario:" + usuario}
|
||||
// Un usuario con forma imposible cae siempre en el mismo cajón: si no,
|
||||
// cada nombre inventado (hasta 64 KB) sería una entrada más en memoria.
|
||||
llaveU := usuario
|
||||
if ValidarUsuario(usuario) != nil {
|
||||
llaveU = "?"
|
||||
}
|
||||
|
||||
if bloqueado, falta := a.cer.Bloqueado(cerrojos...); bloqueado {
|
||||
// El intento se apunta ANTES de calcular la clave: ver Cerrojo.Intentar.
|
||||
pasa, falta := a.cer.Intentar(ip)
|
||||
if pasa {
|
||||
pasa, falta = a.cerU.Intentar(llaveU)
|
||||
}
|
||||
if !pasa {
|
||||
a.auth.Escribir("AUTH_BLOCK", ip, usuario, "demasiados_intentos")
|
||||
a.errorAcceso(w, r, fmt.Sprintf("Demasiados intentos. Prueba otra vez en %d minutos.",
|
||||
int(falta.Minutes())+1))
|
||||
|
|
@ -339,19 +366,18 @@ func (a *App) entrar(w http.ResponseWriter, r *http.Request) {
|
|||
// Se gasta el mismo tiempo que con una cuenta real para que no se pueda
|
||||
// averiguar que usuarios existen.
|
||||
VerificarClave(hashInutil, clave)
|
||||
a.cer.Fallo(cerrojos...)
|
||||
a.auth.Escribir("AUTH_FAIL", ip, usuario, "cuenta_desconocida")
|
||||
a.errorAcceso(w, r, "Usuario o contraseña incorrectos.")
|
||||
return
|
||||
}
|
||||
if !VerificarClave(c.Hash, clave) {
|
||||
a.cer.Fallo(cerrojos...)
|
||||
a.auth.Escribir("AUTH_FAIL", ip, usuario, "clave_incorrecta")
|
||||
a.errorAcceso(w, r, "Usuario o contraseña incorrectos.")
|
||||
return
|
||||
}
|
||||
|
||||
a.cer.Limpiar(cerrojos...)
|
||||
a.cer.Limpiar(ip)
|
||||
a.cerU.Limpiar(llaveU)
|
||||
a.auth.Escribir("AUTH_OK", ip, usuario, "-")
|
||||
ses := a.ses.Crear(c.Usuario)
|
||||
a.ponerCookie(w, ses.ID, 0)
|
||||
|
|
@ -415,6 +441,7 @@ type FilaMia struct {
|
|||
Etiqueta string
|
||||
Caduca string
|
||||
Liberable bool
|
||||
Externas bool // la pidió la asamblea de externas: va en naranja
|
||||
}
|
||||
|
||||
func (a *App) filaMia(e Evento) FilaMia {
|
||||
|
|
@ -422,6 +449,10 @@ func (a *App) filaMia(e Evento) FilaMia {
|
|||
Evento: e, Dias: e.NumDias(), Legible: rangoLegible(e),
|
||||
Etiqueta: e.Estado.Etiqueta(), Liberable: e.Estado.Ocupa(),
|
||||
}
|
||||
if c, ok := a.alm.BuscarCuenta(e.Solicitante); ok && c.Rol == RolExternas {
|
||||
f.Externas = true
|
||||
f.Etiqueta = etiquetaEstado(e.Estado, true)
|
||||
}
|
||||
if e.Estado == Solicitado {
|
||||
limite := e.Desde.Mas(-a.cfg.Reglas.DiasMinimoConfirmar)
|
||||
porTiempo := DiaDe(e.Creado.AddDate(0, 0, a.cfg.Reglas.DiasMaximoPendiente))
|
||||
|
|
@ -459,7 +490,7 @@ func (a *App) verMisFechas(w http.ResponseWriter, r *http.Request, c Cuenta, ses
|
|||
Restantes: max0(a.cfg.Reglas.CupoDias - len(usados)),
|
||||
DiasUsados: listaDias(usados),
|
||||
}
|
||||
datos.Aviso = r.URL.Query().Get("a")
|
||||
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
|
||||
a.pintar(w, "mis-fechas.tmpl.html", datos)
|
||||
}
|
||||
|
||||
|
|
@ -537,7 +568,7 @@ func (a *App) hacerNueva(w http.ResponseWriter, r *http.Request, c Cuenta, ses *
|
|||
if err == nil {
|
||||
estado := Solicitado
|
||||
if c.Rol == RolEnre {
|
||||
estado = Confirmado // la asamblea de la casa no se pide permiso a si misma
|
||||
estado = Confirmado // la asamblea de la Enre no se pide permiso a si misma
|
||||
}
|
||||
e = Evento{
|
||||
ID: nuevoID(), Titulo: s.Titulo, Solicitante: c.Usuario,
|
||||
|
|
@ -558,8 +589,7 @@ func (a *App) hacerNueva(w http.ResponseWriter, r *http.Request, c Cuenta, ses *
|
|||
return
|
||||
}
|
||||
log.Printf("%s pide %s (%s) como %s", c.Usuario, rangoLegible(e), e.Titulo, e.Estado)
|
||||
http.Redirect(w, r, "/pedir/mis-fechas?a="+url.QueryEscape("Fecha apuntada: "+rangoLegible(e)),
|
||||
http.StatusSeeOther)
|
||||
a.avisar(w, r, ses, "/pedir/mis-fechas", "Fecha apuntada: "+rangoLegible(e))
|
||||
}
|
||||
|
||||
func (a *App) hacerLiberar(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
|
||||
|
|
@ -592,8 +622,7 @@ func (a *App) hacerLiberar(w http.ResponseWriter, r *http.Request, c Cuenta, ses
|
|||
if r.PostFormValue("volver") == "todas" && c.Rol == RolEnre {
|
||||
destino = "/pedir/todas"
|
||||
}
|
||||
http.Redirect(w, r, destino+"?a="+url.QueryEscape("Fecha liberada: "+rangoLegible(e)),
|
||||
http.StatusSeeOther)
|
||||
a.avisar(w, r, ses, destino, "Fecha liberada: "+rangoLegible(e))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- todas
|
||||
|
|
@ -619,10 +648,14 @@ func (a *App) verTodas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Se
|
|||
if quien == "" {
|
||||
quien = e.Solicitante
|
||||
}
|
||||
if e.Externo() {
|
||||
fila := a.filaMia(e)
|
||||
switch {
|
||||
case fila.Externas:
|
||||
quien = e.Amadrinado + " · asamblea de externas"
|
||||
case e.Externo():
|
||||
quien = fmt.Sprintf("%s · amadrina %s", e.Amadrinado, quien)
|
||||
}
|
||||
filas = append(filas, FilaTodas{FilaMia: a.filaMia(e), Quien: quien})
|
||||
filas = append(filas, FilaTodas{FilaMia: fila, Quien: quien})
|
||||
}
|
||||
sort.Slice(filas, func(i, j int) bool { return filas[i].Evento.Desde < filas[j].Evento.Desde })
|
||||
|
||||
|
|
@ -633,7 +666,7 @@ func (a *App) verTodas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Se
|
|||
Base: a.base("TODAS LAS FECHAS", "Lo que viene, de todos los colectivos", "todas", c, ses),
|
||||
Filas: filas,
|
||||
}
|
||||
datos.Aviso = r.URL.Query().Get("a")
|
||||
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
|
||||
a.pintar(w, "todas.tmpl.html", datos)
|
||||
}
|
||||
|
||||
|
|
@ -710,8 +743,7 @@ func (a *App) hacerEditar(w http.ResponseWriter, r *http.Request, c Cuenta, ses
|
|||
a.formularioEditar(w, c, ses, nuevo, primeraMayuscula(err.Error()))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/pedir/todas?a="+url.QueryEscape("Evento modificado: "+nuevo.Titulo),
|
||||
http.StatusSeeOther)
|
||||
a.avisar(w, r, ses, "/pedir/todas", "Evento modificado: "+nuevo.Titulo)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pendientes
|
||||
|
|
@ -759,7 +791,7 @@ func (a *App) verPendientes(w http.ResponseWriter, r *http.Request, c Cuenta, se
|
|||
Base
|
||||
Filas []FilaPendiente
|
||||
}{Base: a.base("PENDIENTES", "Lo que espera a la asamblea", "pendientes", c, ses), Filas: filas}
|
||||
datos.Aviso = r.URL.Query().Get("a")
|
||||
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
|
||||
a.pintar(w, "pendientes.tmpl.html", datos)
|
||||
}
|
||||
|
||||
|
|
@ -796,9 +828,8 @@ func (a *App) hacerResolver(w http.ResponseWriter, r *http.Request, c Cuenta, se
|
|||
return
|
||||
}
|
||||
log.Printf("%s %s %q de %s", c.Usuario, e.Estado, e.Titulo, e.Solicitante)
|
||||
http.Redirect(w, r, "/pedir/pendientes?a="+url.QueryEscape(
|
||||
fmt.Sprintf("«%s» queda %s", e.Titulo, strings.ToLower(e.Estado.Etiqueta()))),
|
||||
http.StatusSeeOther)
|
||||
a.avisar(w, r, ses, "/pedir/pendientes",
|
||||
fmt.Sprintf("«%s» queda %s", e.Titulo, strings.ToLower(e.Estado.Etiqueta())))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- findes
|
||||
|
|
@ -832,7 +863,7 @@ func (a *App) verFindes(w http.ResponseWriter, r *http.Request, c Cuenta, ses *S
|
|||
Base
|
||||
Filas []FilaFinde
|
||||
}{Base: a.base("TURNO DE LOS FINDES", "Tranquilo o con ruido", "findes", c, ses), Filas: filas}
|
||||
datos.Aviso = r.URL.Query().Get("a")
|
||||
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
|
||||
a.pintar(w, "findes.tmpl.html", datos)
|
||||
}
|
||||
|
||||
|
|
@ -857,7 +888,7 @@ func (a *App) hacerFinde(w http.ResponseWriter, r *http.Request, c Cuenta, ses *
|
|||
if turno == "" {
|
||||
aviso = fmt.Sprintf("El finde del %s vuelve a la alternancia automática", v.Legible())
|
||||
}
|
||||
http.Redirect(w, r, "/pedir/findes?a="+url.QueryEscape(aviso), http.StatusSeeOther)
|
||||
a.avisar(w, r, ses, "/pedir/findes", aviso)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- cuentas
|
||||
|
|
@ -873,8 +904,7 @@ func (a *App) verCuentas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *
|
|||
Cuentas: a.alm.Cuentas(),
|
||||
}
|
||||
datos.DeQuien, datos.Clave = a.ses.TomarClave(ses.ID)
|
||||
datos.Aviso = r.URL.Query().Get("a")
|
||||
datos.Error = r.URL.Query().Get("e")
|
||||
datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID)
|
||||
a.pintar(w, "cuentas.tmpl.html", datos)
|
||||
}
|
||||
|
||||
|
|
@ -882,8 +912,11 @@ func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses
|
|||
accion := r.PostFormValue("accion")
|
||||
usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario")))
|
||||
|
||||
volver := func(params string) { http.Redirect(w, r, "/pedir/cuentas"+params, http.StatusSeeOther) }
|
||||
problema := func(err error) { volver("?e=" + url.QueryEscape(primeraMayuscula(err.Error()))) }
|
||||
volver := func(aviso string) { a.avisar(w, r, ses, "/pedir/cuentas", aviso) }
|
||||
problema := func(err error) {
|
||||
a.ses.PonerAviso(ses.ID, "", primeraMayuscula(err.Error()))
|
||||
http.Redirect(w, r, "/pedir/cuentas", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
switch accion {
|
||||
case "crear":
|
||||
|
|
@ -897,7 +930,7 @@ func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses
|
|||
problema(fmt.Errorf("falta el nombre del colectivo"))
|
||||
return
|
||||
}
|
||||
if rol != RolEnre && rol != RolInterno {
|
||||
if !rol.Valido() {
|
||||
problema(fmt.Errorf("rol desconocido"))
|
||||
return
|
||||
}
|
||||
|
|
@ -952,7 +985,7 @@ func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses
|
|||
if otra.Activa {
|
||||
hecho = "activada"
|
||||
}
|
||||
volver("?a=" + url.QueryEscape(fmt.Sprintf("Cuenta «%s» %s", usuario, hecho)))
|
||||
volver(fmt.Sprintf("Cuenta «%s» %s", usuario, hecho))
|
||||
|
||||
default:
|
||||
a.fallo(w, r, http.StatusBadRequest, "Acción desconocida.")
|
||||
|
|
@ -1018,8 +1051,7 @@ func (a *App) hacerClave(w http.ResponseWriter, r *http.Request, c Cuenta, ses *
|
|||
http.Redirect(w, r, sig, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/pedir/mis-fechas?a="+url.QueryEscape("Contraseña cambiada"),
|
||||
http.StatusSeeOther)
|
||||
a.avisar(w, r, nuevaSesion, "/pedir/mis-fechas", "Contraseña cambiada")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- varios
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDestinoSeguro(t *testing.T) {
|
||||
casos := map[string]string{
|
||||
|
|
@ -21,3 +24,45 @@ func TestDestinoSeguro(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestinoSeguroSinPuntos(t *testing.T) {
|
||||
for _, sig := range []string{"/pedir/../index.html", "/pedir/./nueva", "/pedir//nueva", "/pedir/x/../../evil"} {
|
||||
if got := destinoSeguro(sig); got != "" {
|
||||
t.Errorf("destinoSeguro(%q) = %q, quiero vacío", sig, got)
|
||||
}
|
||||
}
|
||||
if got := destinoSeguro("/pedir/"); got != "/pedir/" {
|
||||
t.Errorf("destinoSeguro(/pedir/) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCerrojoApuntaAntesDeCalcular(t *testing.T) {
|
||||
c := NuevoCerrojo(5, time.Minute, time.Minute)
|
||||
pasan := 0
|
||||
for i := 0; i < 50; i++ {
|
||||
if ok, _ := c.Intentar("ip:1.2.3.4"); ok {
|
||||
pasan++
|
||||
}
|
||||
}
|
||||
if pasan != 5 {
|
||||
t.Fatalf("pasaron %d intentos, quiero 5", pasan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgonParametrosAcotados(t *testing.T) {
|
||||
// Un hash manipulado que pide 4 GiB de memoria no se calcula.
|
||||
malo := "$argon2id$v=19$m=4194304,t=3,p=4$c2FsdHNhbHRzYWx0$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaGhhc2g"
|
||||
if VerificarClave(malo, "loquesea") {
|
||||
t.Fatal("no deberia verificar")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigEjemploValida(t *testing.T) {
|
||||
c, err := CargarConfig("../despliegue/config.ejemplo.json")
|
||||
if err != nil {
|
||||
t.Fatalf("despliegue/config.ejemplo.json no carga: %v", err)
|
||||
}
|
||||
if !c.CookieSegura || c.Local {
|
||||
t.Fatal("el ejemplo de producción tiene que llevar cookie_segura y no local")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
{{- if .Eventos}}
|
||||
<div class="cal-pills">
|
||||
{{- range .Eventos}}
|
||||
<div class="cal-ev cal-ev-{{.Estado}}">
|
||||
<div class="cal-ev cal-ev-{{.Estado}}{{if .Externas}} cal-ev-externas{{end}}">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
</section>
|
||||
|
||||
<section class="pantalla" aria-label="Dar de alta un colectivo">
|
||||
<h2>Dar de alta un colectivo de la casa</h2>
|
||||
<h2>Dar de alta un colectivo de la Enre</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">
|
||||
|
|
@ -73,14 +73,16 @@
|
|||
<label for="nuevo-rol">Rol</label>
|
||||
<select id="nuevo-rol" name="rol">
|
||||
<option value="interno">interno: pide fechas y amadrina</option>
|
||||
<option value="externas">externas: la asamblea de externas, pide sin cupo</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.
|
||||
Los colectivos de fuera no tienen cuenta: sus eventos los apunta la cuenta de externas
|
||||
en la asamblea de externas, o un colectivo de la Enre que los amadrina. Cuantas menos
|
||||
contraseñas haya guardadas, menos hay que perder.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
{{template "arriba" .}}
|
||||
|
||||
<section class="pantalla" aria-label="Vuestro cupo">
|
||||
{{- if .EsExternas}}
|
||||
<h2>Asamblea de externas</h2>
|
||||
<p class="pie">
|
||||
Aquí se apuntan los eventos de colectivos de fuera que se hablan en la asamblea de
|
||||
externas. No hay cupo. Quedan en naranja hasta que la asamblea de la Enre los confirme.
|
||||
Se pueden pedir fechas hasta el {{.Hasta}}.
|
||||
</p>
|
||||
{{- else}}
|
||||
<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>
|
||||
{{- end}}
|
||||
<p><a class="pedir-boton" href="/pedir/nueva">Pedir una fecha</a></p>
|
||||
</section>
|
||||
|
||||
|
|
@ -35,7 +44,7 @@
|
|||
{{.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><span class="estado estado-{{.Evento.Estado}}{{if .Externas}} estado-externas{{end}}">{{.Etiqueta}}</span></td>
|
||||
<td>{{if .Caduca}}{{.Caduca}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{- if .Liberable}}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@
|
|||
<h2>Pedir una fecha</h2>
|
||||
<ul class="pedir-reglas">
|
||||
<li>Entre el <strong>{{.Primero.Legible}}</strong> y el <strong>{{.Ultimo.Legible}}</strong>.</li>
|
||||
{{- if .EsExternas}}
|
||||
<li>Cada evento es de un colectivo de fuera: poned cuál y un contacto suyo.</li>
|
||||
<li>La asamblea de externas no tiene cupo. La fecha queda en naranja hasta que la asamblea de la Enre la confirme.</li>
|
||||
{{- else}}
|
||||
<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>
|
||||
{{- end}}
|
||||
<li>Las fechas se piden para el finde: viernes, sábado o domingo.</li>
|
||||
</ul>
|
||||
|
||||
<form class="pedir-form" method="post" action="/pedir/nueva">
|
||||
|
|
@ -31,9 +36,14 @@
|
|||
<input id="hora" name="hora" type="time" value="{{.Sol.Hora}}">
|
||||
</p>
|
||||
<p>
|
||||
{{- if .EsExternas}}
|
||||
<label for="amadrinado">Colectivo de fuera</label>
|
||||
<input id="amadrinado" name="amadrinado" type="text" required maxlength="80" value="{{.Sol.Amadrinado}}">
|
||||
{{- else}}
|
||||
<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>
|
||||
{{- end}}
|
||||
</p>
|
||||
<p>
|
||||
<label for="contacto">Contacto de ese colectivo</label>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
{{template "arriba" .}}
|
||||
{{- if not .Dentro}}
|
||||
|
||||
<p class="pedir-externas">Colectivos externos: las fechas se piden en persona en la <strong>asamblea de externas, lunes de 19:00 a 20:00</strong>.</p>
|
||||
{{- end}}
|
||||
|
||||
<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.
|
||||
Los colectivos de la Enre pinchan 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
|
||||
asamblea las confirme; en <strong>naranja</strong>, las que ha apuntado la asamblea de
|
||||
externas. 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>.
|
||||
|
|
@ -13,6 +18,7 @@
|
|||
|
||||
<div class="leyenda-pedir">
|
||||
<span class="cal-ev cal-ev-solicitado leyenda-muestra">Solicitado</span>
|
||||
<span class="cal-ev cal-ev-solicitado cal-ev-externas leyenda-muestra">Solicitado externas</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>
|
||||
|
|
@ -28,16 +34,11 @@
|
|||
|
||||
{{- if not .Dentro}}
|
||||
<section class="pantalla" id="entrar" aria-label="Cómo se pide un evento">
|
||||
<h2>Cómo se pide</h2>
|
||||
<h2>Entrar</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.
|
||||
Solo para los <strong>colectivos de la Enre</strong>, que tienen credenciales, y para
|
||||
la asamblea de externas. Si sois de fuera, no hay cuenta que pedir: la fecha se
|
||||
pide en la <strong>asamblea de externas, los lunes de 19:00 a 20:00</strong>.
|
||||
</p>
|
||||
|
||||
<form class="pedir-form" method="post" action="/pedir/entrar" autocomplete="off">
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
<td>
|
||||
{{.Evento.Titulo}}<br><span class="pista">{{.Quien}}</span>
|
||||
</td>
|
||||
<td><span class="estado estado-{{.Evento.Estado}}">{{.Etiqueta}}</span></td>
|
||||
<td><span class="estado estado-{{.Evento.Estado}}{{if .Externas}} estado-externas{{end}}">{{.Etiqueta}}</span></td>
|
||||
<td>
|
||||
<div class="acciones">
|
||||
<a class="pedir-boton-flojo" href="/pedir/editar?id={{.Evento.ID}}">Modificar</a>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ type Reglas struct {
|
|||
TurnoAncla Turno `json:"turno_ancla"` // que turno le toca a ese finde
|
||||
}
|
||||
|
||||
const maxDiasEvento = 31
|
||||
|
||||
func ReglasPorDefecto() Reglas {
|
||||
return Reglas{
|
||||
MesesVentana: 3,
|
||||
|
|
@ -104,6 +106,9 @@ func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error {
|
|||
if len([]rune(s.Titulo)) > 80 {
|
||||
return fmt.Errorf("el nombre del evento no puede pasar de 80 caracteres")
|
||||
}
|
||||
if s.Rol == RolExternas && strings.TrimSpace(s.Amadrinado) == "" {
|
||||
return fmt.Errorf("falta de qué colectivo de fuera es el evento")
|
||||
}
|
||||
if s.Amadrinado != "" && strings.TrimSpace(s.Contacto) == "" {
|
||||
return fmt.Errorf("si amadrinas a un colectivo de fuera hace falta un contacto suyo")
|
||||
}
|
||||
|
|
@ -123,7 +128,12 @@ func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error {
|
|||
}
|
||||
|
||||
dias := Rango(s.Desde, s.Hasta)
|
||||
libre := s.Rol == RolEnre // la asamblea de la casa no tiene cupo ni ventana
|
||||
// Tope para todos, también la asamblea: Rango corta en 400 días y un evento
|
||||
// más largo quedaría pintado en el calendario pero sin bloquear sus fechas.
|
||||
if len(dias) > maxDiasEvento {
|
||||
return fmt.Errorf("un evento no puede durar más de %d días", maxDiasEvento)
|
||||
}
|
||||
libre := s.Rol == RolEnre // la asamblea de la Enre no tiene cupo ni ventana
|
||||
|
||||
if !libre {
|
||||
primero, ultimo := rg.Ventana(hoy)
|
||||
|
|
@ -157,7 +167,9 @@ func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error {
|
|||
}
|
||||
}
|
||||
|
||||
if !libre {
|
||||
// La asamblea de externas apunta eventos de muchos colectivos distintos:
|
||||
// el cupo es por colectivo y no tiene sentido sumarlos todos en su cuenta.
|
||||
if !libre && s.Rol != RolExternas {
|
||||
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. "+
|
||||
|
|
|
|||
|
|
@ -248,3 +248,29 @@ func TestModificarNoChocaConsigoMismo(t *testing.T) {
|
|||
t.Fatalf("mover «a» encima de «b» deberia fallar por solape, dio %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternasSinCupoPeroConColectivo(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
eventos := []Evento{
|
||||
evento("a", "externas", "2026-10-02", "2026-10-04", Solicitado),
|
||||
evento("b", "externas", "2026-10-09", "2026-10-11", Solicitado),
|
||||
}
|
||||
|
||||
s := solicitud("externas", "2026-10-16", "2026-10-16")
|
||||
s.Rol = RolExternas
|
||||
if err := rg.Validar(s, eventos, hoy); err == nil || !strings.Contains(err.Error(), "colectivo de fuera") {
|
||||
t.Fatalf("sin decir de qué colectivo es deberia fallar, dio %v", err)
|
||||
}
|
||||
|
||||
// Con seis días ya apuntados, un séptimo vale: externas no tiene cupo.
|
||||
s.Amadrinado, s.Contacto = "Colectivo Sin Techo", "sintecho@example.org"
|
||||
if err := rg.Validar(s, eventos, hoy); err != nil {
|
||||
t.Fatalf("externas no deberia tener cupo: %v", err)
|
||||
}
|
||||
|
||||
// Pero sigue sin poder pedir entre semana.
|
||||
s.Desde, s.Hasta = "2026-10-14", "2026-10-14"
|
||||
if err := rg.Validar(s, eventos, hoy); err == nil {
|
||||
t.Fatal("externas no deberia poder pedir un miércoles")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ type FilaEvento struct {
|
|||
Texto string
|
||||
Quien string
|
||||
Estado Estado
|
||||
Externas bool // la pidió la asamblea de externas
|
||||
Etiqueta string
|
||||
Sigue bool // dia intermedio o final de un evento de varios dias
|
||||
}
|
||||
|
|
@ -41,6 +42,7 @@ type Mes struct {
|
|||
// Aqui no hay horarios semanales: esos siguen en calendario.html, que se dibuja
|
||||
// con su propio guion y no se toca.
|
||||
type Vista struct {
|
||||
roles map[string]Rol
|
||||
reglas Reglas
|
||||
exc map[Dia]Turno
|
||||
eventos []Evento
|
||||
|
|
@ -58,7 +60,11 @@ func (v Vista) eventosDe(d Dia) []FilaEvento {
|
|||
if quien == "" {
|
||||
quien = e.Solicitante
|
||||
}
|
||||
if e.Externo() {
|
||||
externas := v.roles[e.Solicitante] == RolExternas
|
||||
switch {
|
||||
case externas:
|
||||
quien = e.Amadrinado + " · asamblea de externas"
|
||||
case e.Externo():
|
||||
quien = fmt.Sprintf("%s · amadrina %s", e.Amadrinado, quien)
|
||||
}
|
||||
out = append(out, FilaEvento{
|
||||
|
|
@ -66,7 +72,8 @@ func (v Vista) eventosDe(d Dia) []FilaEvento {
|
|||
Texto: e.Titulo,
|
||||
Quien: quien,
|
||||
Estado: e.Estado,
|
||||
Etiqueta: e.Estado.Etiqueta(),
|
||||
Externas: externas,
|
||||
Etiqueta: etiquetaEstado(e.Estado, externas),
|
||||
Sigue: d != e.Desde,
|
||||
})
|
||||
}
|
||||
|
|
@ -143,6 +150,15 @@ func (v Vista) Proximas() []Evento {
|
|||
return out
|
||||
}
|
||||
|
||||
// etiquetaEstado distingue lo que ha pedido la asamblea de externas mientras
|
||||
// espera: una vez confirmado es un evento más.
|
||||
func etiquetaEstado(e Estado, externas bool) string {
|
||||
if externas && e == Solicitado {
|
||||
return "SOLICITADO EXTERNAS"
|
||||
}
|
||||
return e.Etiqueta()
|
||||
}
|
||||
|
||||
func diasDelMes(anio, mes int) int {
|
||||
return fechaMes(anio, mes).AddDate(0, 1, -1).Day()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue