- 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.
264 lines
7.8 KiB
Go
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
|
|
}
|