ENRE_WEB/gestor/reglas.go
SITO 7d5dbeec60 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.
2026-09-23 12:15:33 +02:00

264 lines
7.8 KiB
Go

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
}
const maxDiasEvento = 31
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.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")
}
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)
// 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)
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()))
}
}
// 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. "+
"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
}