No description
  • Python 78.8%
  • HTML 16.5%
  • CSS 2.8%
  • JavaScript 1.2%
  • Shell 0.5%
  • Other 0.2%
Find a file
dacowars e8b269bb96 Subir el APK con una barra que dice cuanto lleva
Un formulario normal no dice nada mientras manda 150 MB y parece colgado.
Ahora sube con XMLHttpRequest y pinta el porcentaje; sin JavaScript sigue
funcionando como antes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:49:56 +02:00
.claude/worktrees Fuera los enlaces a worktrees que ya no existen 2026-10-07 14:27:17 +02:00
app Subir el APK con una barra que dice cuanto lleva 2026-10-07 16:49:56 +02:00
scripts Video 3D, fase 1: zonas de terreno y relieve compuesto en la raya 2026-09-26 00:32:41 +02:00
tests Subir el APK con una barra que dice cuanto lleva 2026-10-07 16:49:56 +02:00
video3d Video 3D: las cimas del mismo dia en el cierre, y el desnivel sin el ruido del reloj 2026-10-07 14:27:17 +02:00
.dockerignore Video 3D, fase 2: la cola, el generador en su contenedor y el aviso 2026-09-26 01:01:48 +02:00
.DS_Store Tanda C y H0 terminados 2026-09-02 22:39:44 +02:00
.env.example Los tres avisos al telefono: solicitudes, subidas de tu gente y comentarios 2026-09-14 11:41:17 +02:00
.gitattributes H6 terminado: el enlace ya lleva tarjeta, y el perfil cerrado no la tiene 2026-09-05 00:23:12 +02:00
.gitignore El catálogo dice cuál es la gemela, para que la app pueda ofrecer la casilla 2026-09-09 17:57:37 +02:00
alembic.ini Añadir Alembic y migración inicial del esquema 2026-08-30 19:49:44 +02:00
Captura de pantalla 2026-09-05 a las 9.46.05.png El marco del carrusel, segun la foto mas vertical 2026-09-05 17:42:11 +02:00
docker-compose.yml Video 3D, fase 2: la cola, el generador en su contenedor y el aviso 2026-09-26 01:01:48 +02:00
Dockerfile Actualizar aplicación, Docker y documentación 2026-08-30 20:10:22 +02:00
HOJA_DE_RUTA.md API: lo que la web tenia y la app no 2026-10-07 11:22:51 +02:00
LICENSE Actualizar aplicación, Docker y documentación 2026-08-30 20:10:22 +02:00
pytest.ini Tanda C y H0 terminados 2026-09-02 22:39:44 +02:00
RAT.md Video 3D, fase 4: privacidad, RAT y hoja de ruta 2026-09-26 01:17:08 +02:00
README.md Una cumbre de dos provincias es una sola ficha a la que se llega desde las dos 2026-09-14 14:16:49 +02:00
requirements-dev.txt Tanda C y H0 terminados 2026-09-02 22:39:44 +02:00
requirements.txt Tracks GPX, TCX y FIT en las salidas, con su mapa y sus graficas 2026-09-25 12:43:52 +02:00

Techos de España

Cuaderno de cumbres autoalojado: el techo de cada provincia española, su segunda cima y la montaña más destacada de cada una. 152 cimas de partida en 50 provincias más Ceuta y Melilla, ampliables con las que quieras, aquí o en cualquier otro país. Registro de subidas con fecha, compañía, notas y fotos.

  • Progreso público, detalles privados: sin cuenta se ven el marcador, la ficha de cada cima y el progreso de cada uno; las fotos, las notas y las fechas exactas piden sesión. Cada cual puede cerrar su perfil del todo. Ver Qué se ve sin cuenta.
  • Registro abierto: cualquiera con cuenta de Google se da de alta. Se cierra el grifo con una variable de entorno, sin códigos ni aprobaciones que gestionar.
  • Se entra con Google y nada más: aquí no se guarda la contraseña de nadie. La administración conserva usuario y contraseña como puerta de servicio.
  • FastAPI + Jinja2 + HTMX, sin build step ni node_modules.
  • PostgreSQL para los datos y disco para las fotos. El esquema lo gobierna Alembic: las migraciones se aplican solas al arrancar.
  • HTMX, Leaflet y las tipografías van vendorizadas en app/static/: funciona sin salida a internet (solo las teselas del mapa necesitan red).

Puesta en marcha

cp .env.example .env
python3 -c "import secrets;print(secrets.token_urlsafe(48))"   # pega el resultado en SECRET_KEY
$EDITOR .env                                                   # POSTGRES_PASSWORD, y que DATABASE_URL lleve la misma
                                                               # GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, PUBLIC_BASE_URL
                                                               # ADMIN_GOOGLE_EMAIL, ADMIN_USER y ADMIN_PASSWORD
docker compose up -d --build
docker compose logs -f picos

Al arrancar, el contenedor aplica alembic upgrade head y solo después sirve; si la migración falla, no levanta, que es lo que se quiere. Luego se cargan las 152 cimas —solo si la base no tenía ninguna, ver La siembra del catálogo— y se crea tu usuario admin. Después puedes borrar ADMIN_USER y ADMIN_PASSWORD del .env y reiniciar: solo se usan si la tabla de usuarios está vacía.

Credenciales de Google

Sin esto no puede entrar nadie más que la administración, y el log lo avisa al arrancar.

  1. Google Cloud Console → crea un proyecto.

  2. APIs y servicios → Pantalla de consentimiento de OAuth: tipo externo, nombre de la aplicación, correo de soporte. Con los alcances openid, email y profile —los tres únicos que se piden— no hace falta pasar por la revisión de Google, pero mientras la aplicación esté «en pruebas» solo entran los correos que añadas a la lista de usuarios de prueba: publícala cuando abras.

  3. Credenciales → Crear credenciales → ID de cliente de OAuth, tipo aplicación web. En URI de redirección autorizados, una línea por entorno y exactas:

    https://tu-dominio/auth/google/vuelta
    http://localhost:8017/auth/google/vuelta
    

    Si no coincide carácter a carácter con PUBLIC_BASE_URL, Google contesta redirect_uri_mismatch y el motivo sale en el log del contenedor.

  4. Copia el ID y el secreto a GOOGLE_CLIENT_ID y GOOGLE_CLIENT_SECRET.

Esto sí necesita salida a internet. Es la única excepción a que la aplicación funcione sin red (junto con las teselas del mapa): sin llegar a accounts.google.com no entra nadie.

Si vienes de la versión con SQLite

Esta versión corre solo sobre PostgreSQL: no hay camino de código para SQLite ni migración automática desde picos.db. La instancia vieja se queda donde está; esta empieza vacía.

El esquema también cambió por el camino: una salida compartida es ahora una sola entrada con varios participantes en vez de una fila por persona, y las fotos guardan quién las subió y un tamaño intermedio para el carrusel.

docker compose down
mv data/picos.db data/picos.db.viejo   # por si acaso; ya no se usa
docker compose up -d --build

Se crean las tablas con Alembic, se cargan las 152 cimas y se crea de nuevo el usuario admin del .env. A partir de aquí, cada cambio del modelo lleva su migración y no se vuelve a borrar la base para actualizar.

Nginx Proxy Manager

El contenedor no publica puertos: está en la red nginx_default y NPM lo alcanza por nombre. En NPM, nuevo Proxy Host:

Campo Valor
Domain picos.dacowars.duckdns.org
Scheme / Forward Hostname / Port http · picos · 8000
Block Common Exploits sí
Websockets no hace falta
SSL Let's Encrypt + Force SSL + HSTS

Importante: en la pestaña Advanced del host añade esto, o las fotos de más de 1 MB van a fallar con un 413 y vas a perder media hora buscando el bug:

client_max_body_size 20m;

Debe ser igual o mayor que MAX_UPLOAD_MB del .env. Con una foto por petición basta con eso aunque subas cuarenta de golpe: el navegador las manda de una en una, y el límite es por petición, no por tanda. Solo si alguien tiene JavaScript desactivado viajan todas juntas, y entonces sí las limita este número.

Para probar en local sin NPM, descomenta el bloque ports: del compose y entra por http://dacoserver:8017.

Fotos de las cimas

Cada cima puede tener un retrato que sale en su ficha y en el globo del mapa. La imagen se descarga una sola vez y se guarda en el volumen: al enseñarla no se sale a internet, no se filtra a nadie qué está mirando tu gente, y si la fuente original desaparece la foto sigue estando.

Se pone de tres maneras, todas desde Administración:

  1. Pegando una dirección en el formulario de la cima: se descarga al guardar.
  2. Subiendo un archivo desde tu ordenador: se reencoda y se le quitan los metadatos, GPS incluido, igual que a las fotos de las subidas.
  3. Buscándolas todas de una tacada en Fotos de las cimas: recorre las que no tienen, las busca por nombre y provincia en la Wikipedia en español, se descarga la imagen del artículo y guarda autor y licencia, que las de Commons hay que atribuirlas.

El rastreo masivo va en segundo plano (puedes cerrar la página) y despacio a propósito, para no martillear los servidores de Wikimedia. Empareja por nombre, así que acierta a medias: descarta lo que huele a mapa, escudo o bandera y exige que el título del artículo tenga que ver con la cima —antes de eso, Puigpedrós se traía el mapa de la comarca—, pero aun así conviene repasar el resultado y quitar lo que no cuadre. Las diez cumbres compartidas son una sola ficha, así que se buscan una vez y ya está.

Para que el rastreo se lance solo en el primer arranque, pon FETCH_PEAK_IMAGES=true en el .env. Por defecto está apagado: la app tiene que poder levantar sin red.

Cómo se usa

Al entrar, la portada es tu panel: cuántas cimas te quedan, por dónde seguir (pendientes de senderismo), qué zonas están a punto de cerrarse y las últimas subidas tuyas y del grupo. En la barra de arriba, Muro es lo que sube la gente a la que sigues, por páginas y con el botón de aplaudir (ver El muro, y los aplausos). La lista de cimas abre por defecto con los filtros rápidos Lo que me queda · Ya subidas · Todas.

Los grados de dificultad (F, F+, PD, AD, D) son desplegables: pincha en cualquier chip de color y sale la escala entera con el grado de esa cima resaltado. Encima de la lista hay además una leyenda plegable con lo mismo.

Apuntar una subida

En la ficha de la cima. La compañía se busca por nombre —salen unas cuantas cuentas de entrada y el resto se encuentra escribiendo, porque con el servicio abierto la lista completa serían miles de casillas en cada tarjeta—, y lo que sale de ahí es una sola entrada de todos: la misma salida, no una copia por cabeza. Le cuenta como cima hecha a cada participante, aparece en el cuaderno de todos y cualquiera puede echarle fotos. Para quien no tenga cuenta hay un campo de texto aparte, que queda como anotación.

Si alguien se olvidó de marcarte, en su entrada tienes «Yo también subí»: te añades a esa salida en vez de apuntar otra igual, y ya puedes subir tus fotos. Solo se puede estar una vez en la misma cima el mismo día, así que si ya la tenías apuntada por tu cuenta primero hay que borrar la tuya.

Quién puede qué, dentro de una entrada compartida:

Añadir fotos cualquier participante
Borrar una foto quien la subió, quien apuntó la salida, o un admin
Fecha, horas, notas y participantes quien apuntó la salida, o un admin
Salirme cualquier participante: deja de contarle esa cima y la entrada sigue viva para el resto (sus fotos se quedan; se borran una a una si quiere)
Borrar la entrada entera quien la apuntó, o un admin

Si se sale el último participante, la entrada y sus fotos desaparecen. Si se sale quien la apuntó y queda gente, la salida pasa a ser de otro.

Si tienes prisa, el botón «Subida hoy» de la lista la apunta con la fecha de hoy sin salir de ahí, y no duplica si ya la tenías apuntada hoy.

Las fotos de cada subida

Cada entrada enseña sus fotos en un carrusel: una grande que se pasa con las flechas, con el dedo o pinchando las miniaturas de debajo, y al pinchar la foto se abre a pantalla completa (flechas, deslizar, Escape para salir). El marco es 4:3 para todas las entradas y la foto va entera dentro, sin recortes: una vertical de una arista se ve completa.

No hay tope de fotos. Se suben de una en una, cada una en su propia petición y con un contador de progreso, así que ni el proxy ni una conexión mala tumban la tanda entera: si una falla, te lo dice y las demás entran igual. De cada foto se guardan tres tamaños —grande para el visor, mediana para el carrusel y cuadrada para las miniaturas— y a todas se les quitan los metadatos al reencodar, GPS incluido.

Si quieres poner un tope de todas formas, PHOTOS_PER_ASCENT en el .env (0 = sin tope).

Gente y cuentas

Se entra con Google. Quien llega a /entrar ve un botón y nada más: no hay formulario de contraseña para las cuentas normales, y por tanto no hay ninguna contraseña suya guardada aquí. Lo que se guarda de cada persona es su correo y el identificador de su cuenta de Google.

La primera vez, después de Google, lo único que se pide es con qué nombre de usuario quiere aparecer: es lo que sale en /u/su-usuario y en el marcador, y Google no da ninguno. La cuenta no se crea hasta ese momento; mientras elige, la identidad que Google ya verificó espera en una cookie firmada.

No hay códigos de invitación ni aprobaciones: para entrar basta pasarle https://tu-dominio/entrar. Con REGISTRO_ABIERTO=false se cierra el grifo —no se admiten altas nuevas y quien ya tiene cuenta sigue entrando—, que es lo que hay que tocar si algún día hace falta parar una avalancha o un problema de moderación. El estado sale en Administración y en el log al arrancar.

Qué se ve sin cuenta

La regla es progreso público, detalles privados: el recuento es lo que da pique a quien todavía no se ha dado de alta, y el relato de cada salida es de quien la subió.

Se ve sin haber entrado:

  • / — la portada: qué es el reto, cuántas cimas tiene, quién va ganando y qué se ve de cada uno. Con sesión, esa misma dirección es tu cuaderno de siempre. Antes redirigía a la pantalla de entrada, así que pegar el dominio pelado en WhatsApp no enseñaba nada.
  • /aviso-legal, /privacidad y /condiciones — se leen antes de darse de alta, que es cuando sirven de algo.
  • /clubes y la página de cada club público, con su tabla. Los privados contestan 404 igual que si no existieran.
  • /marcador — las escaleras, con nombres y puestos, de 50 en 50. Si has entrado y tu puesto no cae en la página que estás viendo, sale aparte debajo de la tabla.
  • /u/quien-sea — su foto, su presentación, su recuento de cimas, techos y metros, su puesto en el reto y qué zonas tiene cerradas. No su diario. Para quien pueda verlo, el diario va por páginas de 20.
  • /u/quien-sea/foto/{grande|pequena} — su retrato, solo si su perfil se puede ver: la ruta pregunta lo mismo que la página, así que un perfil cerrado contesta 404 también aquí. En el marcador, donde un perfil cerrado sí sale con su nombre y su puesto, se pintan sus iniciales y no su cara.
  • /clubes/un-club/foto/{grande|pequena} — el escudo de un club, con el mismo portero que el club: uno privado contesta 404.
  • /picos/una-cima — la ficha, su foto y cuántas veces se ha subido. No las entradas de la gente. Para quien pueda verlas, van de 20 en 20.
  • /ascensos/{id} — una subida suelta, solo si su autor la ha hecho pública. Entonces se ve entera: foto, fecha, notas y con quién iba. Si no lo es, contesta 404 igual que si no existiera.

Pide sesión todo lo demás: el catálogo /picos, las zonas, el mapa, la administración, y todo lo que cuelgue de un perfil que no sea el perfil —seguir y dejar de seguir van por /u/quien-sea/seguir, y esas sí piden cuenta—. El muro no compara prefijos por eso: nombra ruta a ruta lo que está abierto.

Las fotos de las subidas no se sirven como ficheros: cada petición pasa por puede_ver_subida (ver Dónde viven las fotos). Antes las tapaba el muro enteras, lo que era más simple pero dejaba una promesa rota: una salida marcada como pública se enseñaba con las fotos sin cargar.

Cada cual decide dos cosas, y los valores de partida no abren nada:

Ajuste De partida Qué hace
Perfil abierto Cerrado, su perfil contesta 404 a los demás. Sigue en el marcador con su nombre y su puesto, pero sin enlace: si desapareciera de la escalera, esconderse sería una forma de trampa.
Detalles amigos Quién ve fotos, notas y fechas exactas: las personas a las que ha aceptado. En privada, solo él.
Cada subida amigos Lo mismo, salida a salida: publica, amigos o privada.

«Amigos» son las personas a las que has aceptado, y solo esas: ver Quién ve tu cuaderno. No basta con tener cuenta, ni con haber subido juntos, ni —el día que existan— con estar en el mismo club. Toda la definición vive en una función (services.es_amigo), así que no hay ninguna decisión de visibilidad repartida por ahí.

Los dos primeros se tocan en Privacidad, en el menú de tu cuenta (/ajustes): dos preguntas, cada una con lo que implica escrito al lado, y se aplica al guardar. El tercero va salida a salida, en Editar dentro de cada entrada de tu cuaderno; cuando una no está en lo de siempre te lo dice una etiqueta en la propia entrada.

Tu ficha: foto, nombre y presentación

En Tu perfil (/ajustes/perfil, en el menú de tu cuenta) se pone la cara, se cambia el nombre con el que sales y se escriben dos líneas de presentación. Pantalla aparte de la de privacidad a propósito: allí se decide quién ve lo tuyo y aquí qué hay.

  • La foto se recorta cuadrada, se guarda en dos tamaños —512 y 128— y sale sin un solo metadato, coordenadas incluidas, exactamente igual que las de las subidas. Vale JPEG, PNG, WebP y la HEIC del iPhone. Sin foto se siguen pintando tus iniciales, que es lo que había desde el principio: nadie tiene que poner una cara para salir en el marcador.
  • La presentación son 300 caracteres y son pocos a propósito: es un campo público, y cuanto más grande el hueco, más datos de los que uno no pensaba dar acaban dentro.
  • El nombre se cambia; la dirección no. /u/quien-seas es donde vives y es lo que la gente tiene pegado por ahí; el nombre es solo cómo te presentas.

Los clubes tienen lo mismo y funciona igual: su administración edita nombre, descripción, escudo y visibilidad en Editar la ficha (/clubes/un-club/editar), y el slug tampoco cambia aunque cambie el nombre, por el mismo motivo. Cerrar un club abierto no expulsa a nadie: lo que se cierra es la puerta.

Quién ve tu cuaderno

Se sigue a la gente con solicitud y aceptación, como en Strava. Desde el perfil de cualquiera hay un botón de Seguir: le llega una solicitud y no ve nada tuyo hasta que la acepte. Mientras esté pendiente, sigue viendo lo que ve un desconocido: el recuento, las zonas y el puesto.

Va en un solo sentido. Que aceptes a alguien no te deja ver su cuaderno a ti: para eso tienes que pedírselo tú desde su perfil y que te diga que sí.

Todo se gobierna en Tu gente (/seguidores), en el menú de tu cuenta, con tres listas:

  • Esperan tu respuesta, con Aceptar y Rechazar. Cuando hay alguna, sale un punto en tu avatar en la cabecera: es el único aviso que existe, porque no hay correo.
  • Ven tu cuaderno: los aceptados, cada uno con un botón para echarlo. Aceptar no es para siempre.
  • Sigues a: a quién has pedido y a quién sigues ya, con el botón de retirar o dejar de seguir.

Dos cosas que conviene saber. Rechazar no deja rastro, así que quien te lo pidió puede volver a pedírtelo; no hay lista de bloqueados. Y lo que elijas en Privacidad manda por encima de esto: con el diario en «solo para ti», ni tus seguidores lo ven.

Los números —cuánta gente te ve el cuaderno y a cuánta sigues— salen en tu perfil y los ve cualquiera, igual que el recuento de cimas. Las listas no: quiénes son solo lo ves tú.

El muro, y los aplausos

El muro (/actividad, en la barra de arriba) es lo que ha subido la gente a la que sigues, de lo más reciente a lo más antiguo y por páginas. No salen tus subidas —para eso está tu cuaderno— ni las de quien todavía no te ha aceptado, y una salida que su autor puso en «solo la gente que subió» no sale ni aceptando.

Se distingue de la sección Del resto del grupo de tu portada, que sigue donde estaba: aquella enseña lo último de todo el mundo, también de gente a la que no sigues si lo pusieron público, y es como se descubre a alguien. El muro es lo mismo recortado a tu gente.

Los aplausos son la única reacción que hay, y son a propósito una sola: un botón que dice «te he visto» es lo que le da algo que hacer aquí a quien esta semana no ha subido nada. Se aplaude desde cualquier tarjeta de salida —en el muro, en tu portada, en la ficha de una cima— y se puede retirar. Lo tuyo no se aplaude.

En la tarjeta va el número; los nombres de quién aplaudió solo salen en la página de esa salida (Ver sola). Es la misma frontera que el perfil traza entre «12 le ven el cuaderno» y la lista de quiénes son: un recuento es de la salida, una lista de nombres ya es de otra gente.

Aplaudir dos veces cuenta una: la clave de la tabla es el par (salida, persona), así que ni el botón pulsado dos veces ni una petición reintentada dejan dos aplausos.

El chat de cada salida

Cada subida tiene su conversación, en su propia página (Ver sola, o el número de comentarios de la tarjeta). Escribe cualquiera que pueda ver esa salida: no hace falta que su autor te haya aceptado, porque quien puede verla ya pasó por el mismo portero que decide todo lo demás.

Los comentarios piden cuenta, aunque la salida sea pública. Es la única cosa de una salida pública que no se enseña a quien pasa por la calle, y es a propósito: la subida la publica su autor con su aviso delante, pero el comentario lo escribe otra persona que no eligió que su nombre y lo que dijo acabaran en un buscador. Sin sesión se ve la salida entera y una puerta.

  • Hasta 1.000 caracteres, texto plano. Lo que pase de ahí se recorta.
  • No se edita. Un comentario cambiado después de que alguien le conteste convierte una conversación en otra cosa. Se borra y se escribe otra vez.
  • Borra quien lo escribió, quien apuntó la salida y la administración, la misma regla que ya tenían las fotos: de lo que se dice en tu salida respondes también tú.
  • Lo ajeno se puede denunciar, con el mismo formulario que una foto o una subida. La denuncia lleva al comentario dentro de su conversación, que es la única forma de valorarlo.
  • Se lee por páginas de 30. Una salida normal no llega ni a diez, así que el paginador no aparece casi nunca.

No hay aviso de que te han comentado, ni punto en la cabecera ni correo. Es una decisión, no un olvido: el aviso se hará con notificaciones desde la app del móvil, que es donde tiene sentido. Hasta entonces, el número sale en la tarjeta de tu propia salida.

Al actualizar a esta versión, la migración 0008 deja ya aceptados, en los dos sentidos, a quienes habían compartido al menos una salida. Es exactamente la gente que ya se veía el cuaderno, así que nadie pierde acceso a lo que tenía; de ahí en adelante, todo lo demás hay que pedirlo.

Clubes

Un club es una tabla más pequeña: la misma escalera del reto, pero solo entre los suyos. Donde en el marcador general vas 400.º, en el club vas cuarto, y ahí es donde de verdad se pica uno.

Estar en el mismo club no da acceso a nada. Las fotos, las notas y las fechas de cada uno las siguen viendo solo las personas a las que esa persona haya aceptado en Tu gente, una a una. Un club comparte la tabla, no el cuaderno. Es deliberado y hay un test que lo sujeta.

  • Público: sale en /clubes, su página la ve cualquiera —con cuenta o sin ella— y la gente puede pedir entrar. Decide la administración del club.
  • Privado: no sale en ninguna lista y quien no está dentro recibe un «no existe ese club», no un «no puedes entrar». Se entra solo por invitación.

Las dos puertas son la misma fila: solicitud si la pide la persona, invitación si la manda el club. Si se cruzan —te invitan y a la vez lo pides— entras sin más trámite, porque las dos partes ya han dicho que sí.

Dentro de la página del club está su tabla, que se cruza con las escaleras de siempre: el mes, el año y la de subidas verificadas. Los administradores pueden invitar por nombre de usuario, aceptar o rechazar solicitudes, echar a alguien, nombrar más administración y borrar el club. Dos guardarraíles: el último administrador no se va dejando el club sin nadie que lo gobierne, y borrar un club no toca ninguna subida ni ninguna cuenta — es una forma de mirar el marcador, no el dueño de nada.

Denunciar algo, y moderarlo

Cualquiera con cuenta puede denunciar una subida, una foto o una cuenta. El banderín sale en cada foto y al pie de cada entrada del cuaderno, y en el perfil de otra persona hay un enlace. Nunca sobre lo tuyo: para eso está el botón de borrar.

Los motivos son cinco, y uno importa más que los otros: «no subió esa cima». Es lo único que hoy defiende el marcador de una cumbre inventada en las 92 cimas que todavía no tienen coordenadas y donde la insignia del GPS no puede funcionar.

Un par de decisiones que conviene saber:

  • Una denuncia por persona y objeto. Volver a denunciar lo mismo no crea otra fila: recicla la que había y la reabre, con la nota de la vez anterior a la vista.
  • Rechazar no deja rastro, así que quien te denunció puede volver a hacerlo. No hay lista de bloqueados.
  • No se avisa del resultado. No hay correo, y preferimos no prometerlo.

En Administración → Denuncias está la cola, con las pendientes primero. Ahí no se borra nada: la pantalla te lleva a lo denunciado y guarda qué decidiste, con quién y cuándo. Borrar una subida, una foto o una cuenta se sigue haciendo donde se hacía, porque tener dos sitios que borran es tener dos guardarraíles que mantener al día. Si lo denunciado ya no existe, la cola lo dice en vez de callarse: suele significar que se atendió por otro camino.

Llevarte tus datos, e irte

En la misma pantalla de Privacidad, abajo, están las dos cosas que cada cual decide sobre sí mismo sin pedirle permiso a nadie.

Descargar mis datos (/ajustes/datos) da un ZIP con todo lo tuyo:

Fichero Qué lleva
subidas.csv El cuaderno entero, una salida por fila y en orden de diario: fecha, cima, zona, quién iba, notas, duración, visibilidad y qué la respalda. Incluye las compartidas que apuntó otra persona.
cumbres.gpx Las cimas distintas a las que has subido, como puntos. Se abre en Wikiloc, OsmAnd, BaseCamp o cualquier GPS. Las que el catálogo todavía no tiene ubicadas se quedan fuera, y el LEEME.txt dice cuántas son.
cuenta.csv Tus datos de cuenta, tu presentación y tus ajustes de privacidad.
retrato.jpg Tu foto de perfil, si la pusiste: tal como se guardó, cuadrada y sin metadatos. Va suelta y no en fotos/, que son las de las salidas: aquí no es el recuerdo de una cumbre, es un dato de la cuenta.
fotos/ Las fotos que subiste tú, en grande, con el nombre puesto por fecha y cima. Las que subió otra persona a una salida vuestra son suyas y no van.
LEEME.txt Qué es cada cosa, y por qué no hay ni una coordenada tuya: no se han quitado del ZIP, es que no existen (ver Fotos).

Borrar mi cuenta (/ajustes/borrar) es la misma operación que ya tenía la administración, gobernada por su dueño. La pantalla enseña primero cuántas subidas, cimas y fotos hay en juego, ofrece el ZIP —hazlo antes: el borrado no se deshace— y luego pide elegir:

  • Dejarlo, sin mi nombre: tus salidas siguen en las fichas de las cimas firmadas como «Cuenta eliminada», con sus fotos. Nadie puede saber que eran tuyas y el recuento de cada cima sigue siendo verdad.
  • Borrarlo todo: se van tus salidas y tus fotos, también del disco. Las compartidas siguen para quien las hizo contigo, sin ti dentro.

Hay que escribir el nombre de usuario para confirmar, y el último administrador no puede irse dejando la instancia sin nadie que la gobierne.

La API, y las llaves que la abren

Hay una API JSON en /api/v1, con su documentación en /api/v1/docs. Lee tu cuenta y tu progreso, el catálogo del reto, la ficha de una cima, el perfil de alguien, buscar gente, el marcador con sus escaleras y el muro de actividad; y deja apuntar una cumbre, colgarle fotos, aplaudir y comentar, que es lo que consumirá la aplicación del móvil.

GET /api/v1/gente?q= busca por nombre visible y por nombre de usuario, y deja fuera los perfiles cerrados: es la misma decisión que hace que /api/v1/u/{username} conteste 404 —y no 403— a un perfil cerrado, porque una lista que lo nombrara daría por otra puerta el dato que ese 404 guarda. No es lo mismo que el buscador de compañeros de una subida, que sí los enseña —marcar a alguien reservada que iba contigo tiene que poder hacerse—.

Para entrar no vale la cookie de la web: hace falta una llave, que se crea en Ajustes → Aplicaciones conectadas. Se enseña una sola vez —en la base solo queda un resumen suyo, así que no hay forma de volver a leerla— y se manda en cada petición:

curl -H "Authorization: Bearer picos_xxxxxxxx" http://localhost:8000/api/v1/yo

Por qué una llave y no la sesión de siempre: la cookie de este proyecto no se puede revocar. No hay tabla de sesiones —se comprueba la firma y ya—, así que la única forma de invalidar una es cambiar SECRET_KEY, y eso echa a todo el mundo a la vez. En un navegador se aguanta; en un móvil que guarda la credencial durante meses, no. Con llaves, pierdes el teléfono, cortas ese teléfono, y los demás dispositivos no se enteran.

Una llave no abre más que tu sesión, y eso es lo único importante de aquí: no enseña el cuaderno de quien no te haya aceptado, ni los perfiles cerrados —que contestan 404, igual que un nombre inventado—, ni una salida marcada como privada. Y no devuelve las coordenadas de ninguna persona, porque no existen en la base: de una foto se lee el sitio, se convierte en una distancia a la cima y se pierde en la misma función. Las que sí van son las de las cimas, que son catálogo público.

Cada cuenta puede tener hasta TOKENS_POR_CUENTA llaves vivas (diez por defecto). Revocar no borra la fila: le pone fecha, para que quede cuándo se cortó.

Conectar la app del móvil sin copiar la llave a mano

Pedirle a alguien que acaba de instalarse una app que se cree una llave en la web y la pegue es perderlo en la primera pantalla. Desde H10-A hay un camino para eso, y su regla es que ni el código ni la llave pasan nunca por una URL:

  1. La app sortea un verificador de 32 bytes, se lo guarda y abre el navegador del sistema en /movil/vincular?reto=<sha256 del verificador>&nombre=<el modelo del teléfono>&vuelta=<su enlace de vuelta>.
  2. Esa página pide sesión como todo lo demás: sin ella se pasa por Google y, si la cuenta no existía, por /bienvenida a elegir nombre de usuario. Se vuelve aquí sola.
  3. Se enseña qué teléfono lo pide y con qué cuenta. La vinculación se crea en el POST del botón, nunca en el GET: un GET que reparte credenciales lo dispara cualquier enlace pegado en un chat.
  4. Se devuelve a la app con un identificador opaco —picosmovil://vinculado? estado=…—, que no abre nada.
  5. La app llama a POST /api/v1/vinculacion/canje con {estado, verificador} y recibe la llave en el cuerpo de la respuesta.

Es el mismo truco que OAuth llama PKCE. Cualquier otra aplicación del teléfono puede haber registrado el mismo esquema y quedarse con la vuelta: no se lleva nada, porque el verificador no ha salido de la app. La vinculación caduca en VINCULACION_MINUTOS y no vale dos veces.

POST /api/v1/vinculacion/canje es la única ruta de la API que no pide llave —es la que las reparte—, así que cuelga fuera del router que lleva el portero. Es un agujero abierto a mano y hay un test que se pone rojo el día que aparezca un segundo.

Desde la app se sale con DELETE /api/v1/vinculacion, que corta la llave con la que se está llamando y ninguna otra. Sin eso, salir de la app dejaría viva una credencial de un teléfono que cree que se ha ido.

Apuntar desde la cumbre, sin cobertura

Apuntar y colgar fotos están pensados para hacerlo desde arriba y sin red: el cliente guarda la subida, la pone en una cola y la manda cuando vuelve la cobertura. Para que eso no acabe en cumbres duplicadas, cada envío lleva un client_uuid que genera el cliente antes de mandar nada y que no cambia entre reintentos:

curl -X POST http://localhost:8000/api/v1/ascensos \
     -H "Authorization: Bearer picos_xxxxxxxx" \
     -H "Content-Type: application/json" \
     -d '{"cima":"torre-de-cerredo","fecha":"2026-07-14",
          "client_uuid":"3f1a9c40-1111-4c2a-9b8e-9f0c2d3e4a5b",
          "notas":"Nevada hasta el collado","duracion_min":330}'

La primera vez contesta 201. Cualquier reintento con el mismo client_uuid contesta 200 con "ya_estaba": true y no crea nada, así que un cliente puede reintentar sin llevar la cuenta de lo que llegó. La foto va aparte, una por petición y con su propio identificador —es el envío grande y el que más veces se corta a mitad—:

curl -X POST http://localhost:8000/api/v1/ascensos/42/fotos \
     -H "Authorization: Bearer picos_xxxxxxxx" \
     -F client_uuid=7c2b8a10-2222-4d3b-8c7f-1a2b3c4d5e6f \
     -F foto=@cumbre.jpg

Y ahí es donde la subida gana su insignia, por la misma función que en la web: si la foto trae coordenadas y se hizo a menos de GPS_MAX_M de la cima, pasa a gps. La respuesta trae la distancia; el sitio no se guarda en ninguna parte.

Detrás de todo esto hay un índice único por cuenta ((author_id, client_uuid)), y no solo la comprobación del código: dos reintentos que lleguen a la vez pasan los dos por el SELECT sin encontrar nada, y esa carrera solo se puede parar en la base.

El muro sale por GET /api/v1/actividad, paginado igual que la web, y cada salida trae el número de aplausos y de comentarios, y si el aplauso lo has dado tú —nunca la lista de quiénes—. Aplaudir es PUT /api/v1/ascensos/{id}/aplauso y retirarlo el DELETE de la misma dirección. La conversación va en GET/POST /api/v1/ascensos/{id}/comentarios y se borra con DELETE /api/v1/comentarios/{id}; escribir un comentario no es idempotente y no puede serlo —decir dos veces lo mismo son dos frases—, así que un cliente con cola solo debe reintentarlo cuando sepa que la primera petición no llegó. Es la única escritura que no necesita client_uuid: la clave de la tabla es el par (salida, persona), así que reintentarlo mil veces deja un aplauso y no hay nada que deduplicar.

Lo que no hace la API: marcar a compañeros de salida —eso escribe en el cuaderno de otra persona y se hace desde la web, que es donde se ve a quién estás marcando—, editar o borrar subidas, pedir o aceptar seguimientos, y cambiar una sesión de la web por una llave.

La puerta de servicio

Debajo del botón de Google hay un desplegable, Entrar como administración, con usuario y contraseña. Solo pasan por ahí las cuentas con permisos de administración: una cuenta normal con la contraseña correcta recibe el mismo «usuario o contraseña incorrectos» que quien se la inventa. Existe para no quedarse fuera si Google, sus credenciales o su consentimiento fallan, y tiene límite de intentos: cinco fallos por usuario y IP en un cuarto de hora y esa combinación queda bloqueada.

La otra red de seguridad es ADMIN_GOOGLE_EMAIL: mientras esté puesta, la cuenta de Google con ese correo es administradora, aunque alguien le haya quitado el rol.

Traer cuentas viejas a Google

Las cuentas que ya existían no tienen correo, así que Google no sabe cuáles son. En la tabla de usuarios de Administración hay una columna Entrada: escribe ahí el correo de Google de esa persona y dale a Enlazar. En su siguiente entrada con Google, la aplicación la reconoce por el correo y enlaza la cuenta que ya tenía, con todo su histórico, en vez de crearle una nueva. Al enlazarla se le borra la contraseña vieja, que ya no le servía de nada (la administración conserva la suya).

Un correo solo puede estar en una cuenta, y una cuenta ya enlazada no se la puede quedar otra identidad de Google: eso sería regalarle el histórico de alguien.

En la misma pantalla, para cada cuenta:

  • Hacer o quitar administrador. No puedes cambiarte el rol a ti mismo ni dejar la instancia sin ningún administrador: pídeselo a otro.

  • Eliminar, y al hacerlo eliges qué pasa con su histórico. Antes de decidir te dice cuántas subidas, cimas y fotos hay en juego:

    • Conservar el histórico: la cuenta desaparece y no puede entrar, pero sus subidas y fotos siguen en las fichas de las cimas firmadas como «Cuenta eliminada». Esa cuenta ficticia no puntúa en el marcador ni se ofrece como compañía.
    • Borrar todo lo suyo: se van sus fotos, también del disco, y sale de todas las subidas. Las que eran suyas y de nadie más desaparecen; las compartidas siguen para quien las hizo con él.

    Tampoco puedes borrarte a ti mismo desde aquí.

El marcador

Nunca un solo ranking. Con uno solo gana siempre el mismo y el resto se va, así que hay cinco escaleras y todas salen de la misma consulta con un filtro distinto:

  • Total, Este año y Este mes, en los botones de arriba.
  • Una zona o una comunidad entera, en el selector de al lado.

Y aparte, cruzándose con todas: Todas o Solo verificadas. No es una escalera más, es perpendicular a ellas, así que hay un total verificado, un mes verificado y una Galicia verificada. Ver más abajo qué verifica una subida.

Todo va en la URL, así que una tabla se puede enlazar tal como se está viendo: /marcador?escalera=mes, /marcador?zona=granada, /marcador?zona=comunidad:andalucia, /marcador?prueba=verificado, o varias a la vez. Lo que no exista se ignora en vez de dar un error: una escalera inventada cae en el total y una zona que no está se descarta.

Encima de la tabla hay una vitrina con los tres primeros de este mes, de este año y —si has entrado— de tu comunidad, que es la comunidad donde más cimas llevas (no se pregunta de dónde eres: donde tienes tus cumbres es donde te interesa la tabla). Un podio a cero no se enseña.

Los empates salen uno detrás de otro por orden alfabético, no con el mismo puesto.

Añadir montañas

Desde Administración, de dos maneras — y hay una tercera, más abajo, que es la de la gente que no administra nada:

A mano. Añadir una cima → formulario con nombre, país, zona, región, altitud, tipo, sierra, dificultad, avisos de cuerda y permiso, coordenadas y notas. Si escribes una zona o un país que no existen todavía, se crean solos: así entran cimas de fuera de España. El tipo «Otra cima» es para las que no son techo de nada.

Con un archivo. Importar un archivo → subes un CSV (o pegas el texto) y antes de tocar nada te enseña fila a fila qué se va a crear, qué se va a actualizar y qué filas fallan y por qué. Solo al confirmar se guarda; las filas con error se saltan.

Columna
nombre, provincia, altitud obligatorias
pais por defecto España
region comunidad o región, para agrupar
rango 1 techo · 2 segunda · 3 destacada · 4 otra (también vale la palabra)
sierra, notas texto libre
dificultad F, F+, PD, AD, D
cuerda, permiso, verificada sí / no
lat, lon grados decimales, con punto o con coma
imagen enlace directo a una foto: se descarga al confirmar

El separador (coma, punto y coma o tabulador) y la codificación (UTF-8 o Windows-1252) se detectan solos, el orden de las columnas da igual y las que no reconozca se ignoran avisándote. Hay una plantilla descargable en la propia página.

Una cima se considera la misma si coinciden nombre y zona. Al actualizar solo se tocan las columnas que traiga el archivo: una celda vacía no borra lo que ya hubiera. Una zona que ya existe tampoco cambia de país por lo que diga el CSV — eso solo se corrige editando la cima a mano.

Cualquier cima se puede editar o borrar desde Editar el catálogo, incluidas las 152 de partida. Borrar una cima borra también sus subidas, su retrato y sus fotos, y si la zona se queda sin ninguna cima, desaparece de los filtros.

En cuanto guardas cambios sobre una cima, queda fijada: la siembra no la vuelve a pisar. Eso vale también para en qué retos está: si sacas del reto de partida una cima que se añadió a mano, se queda fuera. Las 152 del catálogo original sí las devuelve la siembra, porque son ese reto.

Las 152 cimas y las 52 zonas vienen escritas en el código (app/data/peaks.py), y hay que meterlas en la base alguna vez. Cuándo lo hace lo decide SEMBRAR_CIMAS en el .env:

SEMBRAR_CIMAS Cuándo siembra
auto (por defecto) Solo si la base no tiene ninguna cima. Sobre una base que ya existe, el arranque no toca el catálogo.
siempre En cada arranque: recrea las que falten y repone altitud, orden, sierra, dificultad y permisos de las del catálogo. Era el comportamiento anterior.
nunca Ni sobre una base vacía. Para montar un catálogo propio desde cero.

El valor por defecto es auto porque el problema iba en una sola dirección: la siembra corre en cada arranque, así que sobre una base ya poblada solo podía deshacer lo decidido desde el panel, y encima en silencio, un reinicio más tarde. Una cima borrada volvía; una renombrada volvía además de la renombrada, porque al cambiarle el nombre cambia el slug y la siembra ya no la reconocía: te encontrabas dos.

Con auto, para traer las cimas que se añadan al código más adelante hay un botón: Administración → Editar el catálogo → «Volver a sembrar ahora». Hace exactamente lo que hacía el arranque con siempre, pero cuando tú lo dices, y sigue saltándose las cimas añadidas o editadas a mano. La página dice en qué modo estás y qué implica, y el log del arranque lo dice también en cada reinicio (Catálogo sembrado… / Catálogo intacto…).

Cimas que propone la gente

Cualquiera con cuenta puede proponer una montaña que falte, en Proponer una cima (está en su menú, y también al final del listado de cimas). Es el mismo formulario del panel menos dos casillas: el tipo de cima y la altitud verificada, que son cosas que hay que comprobar contra la cartografía y las pones tú al revisarla. Las coordenadas son obligatorias aquí, aunque en el panel no lo sean: son lo que permite comprobar en un minuto que la montaña está donde dice y que no está ya en la lista con otro nombre.

Mientras está sin revisar, la cima no existe: no sale en el listado, ni en el mapa, ni en las zonas, ni en la API, y no se le puede apuntar una subida. Vive en su propia tabla, no en el catálogo con una marca, y eso es a propósito —así ninguna consulta puede olvidarse de esconderla—.

Dos frenos, para que la cola no se coma tu tiempo:

  • Solo propone quien ya tenga alguna cumbre apuntada.
  • Tres propuestas sin resolver por persona (PROPUESTAS_A_LA_VEZ en el .env). En cuanto le resuelves una, puede mandar otra.

En Administración → Cimas propuestas está la cola, con las más antiguas arriba. Revisar y decidir abre el formulario de cima de siempre, relleno con lo propuesto: corriges la zona, pones el tipo y marcas si la altitud está comprobada, y al guardar se crea la cima y la propuesta queda aceptada. Debajo, en el mismo sitio, está el botón de rechazar, que te pide un motivo — lo lee quien la propuso, y con él puede corregirla y volver a mandarla sobre la misma propuesta.

La cima aceptada se crea fuera de todo reto. No es un olvido: una cima más en el reto de partida cambia el denominador del marcador de todo el mundo, así que eso se decide aparte, en Retos. La cola te lo sigue avisando en la pestaña de aceptadas mientras la cima esté sin meter en ninguno. Las cimas que creas tú a mano sí entran solas en el reto de partida, como siempre.

Quien propone ve el estado y el motivo del rechazo en Mis propuestas. No hay correo ni avisos: esa página es la única forma de que se entere, así que el motivo que escribas es lo que va a leer.

La foto es opcional, por archivo o por enlace. Se reencoda y se le quitan todos los metadatos al recibirla, no al revisarla, así que nunca se guarda el GPS de la foto de nadie esperando a que alguien la mire. Al aceptar pasa a ser la foto de la cima; al rechazar, se borra.

Cómo está organizada la lista

Cada provincia tiene tres objetivos, no "las tres más altas". En Cantabria, por ejemplo, la 2ª y la 3ª más altas son torres pegadas a la 1ª con 15 m de prominencia: subirías tres veces lo mismo. Así que:

  1. Techo provincial — el punto más alto, según IGN.
  2. Segunda cima — la siguiente cumbre independiente.
  3. Cima destacada — la más prominente o la más clásica de la provincia.

Las cimas marcadas sin verificar son estimaciones (sobre todo segundas y terceras de provincias llanas, donde las fuentes no coinciden). Corrígelas en Administración → Editar el catálogo contra Iberpix: lo que edites ahí queda fijo, la siembra no vuelve a pisar las filas ya editadas.

Cumbres de dos provincias

10 cimas son techo o cima destacada de dos provincias a la vez (Moncayo, Peñalara, Torre Cerredo, Gorbeia, Peña Trevinca, Calvitero, Cerro Calderón, Pico Faro, Risco de los Claveles, Tossal dels Tres Reis). Son una sola ficha a la que se llega desde las dos zonas: una dirección, un hilo de fotos y la altitud contada una vez en los metros acumulados. En el catálogo salen en las dos provincias, con su rango en cada una —el Cerro Calderón es techo de Valencia y cima destacada de Teruel—, y cerrar cualquiera de las dos sigue exigiéndola.

En una base nueva nacen ya fusionadas. En una que venga de antes son dos fichas hasta que se juntan desde Administración → Fusionar cimas, que enseña qué subidas se van a mover y cuáles se van a juntar antes de tocar nada:

  • Las salidas del mismo día y del mismo autor se funden en una sola, con todas sus fotos y sus comentarios, sin repetir aplausos y quedándose con el mejor escalón de prueba de las dos.
  • La dirección de la ficha que desaparece queda desviada con un 301, así que un enlace compartido hace meses sigue llevando a la montaña.
  • Lo que no cambia: el recuento de cimas de nadie y ningún puesto del marcador. Cada provincia sigue teniendo su casilla. Lo único que baja son los metros de quien tuviera las dos fichas apuntadas, que estaban contados dos veces.
  • Separar una zona le devuelve su ficha propia, pero no parte las salidas que ya se hayan juntado: esas se quedan en la ficha que las tenía.

La misma pantalla sirve para juntar dos cimas duplicadas que no sean de frontera —una propuesta de la comunidad que repetía con otro nombre una que ya estaba, por ejemplo—.

Retos

Las 152 cimas no son "el catálogo": son un reto, el que se llama techos-espana. Todo el progreso —la portada, las zonas, el perfil de cada uno y el marcador— se calcula siempre dentro de un reto. La regla que sostiene esto es una: una cima que no está en ningún reto no cuenta para nada, así que la siembra mete en el de partida todo el catálogo, y las cimas que añadas a mano entran ahí también.

Se gestionan en Administración → Retos, sin tocar código ni migrar nada:

  • Crear uno con su nombre, su dirección (lo que va en ?reto=, que se saca del nombre si lo dejas vacío), su descripción y, si es de temporada, las fechas de inicio y fin. Nace vacío.
  • Meterle cimas una a una: busca por nombre, zona o sierra, filtra por zona o por rango, marca las que quieras y las añade con el peso que elijas. Solo salen las que todavía no están dentro. Si la montaña no existe en el catálogo, primero se añade ahí.
  • Sacar una cima del reto o cambiarle el peso desde su fila. Sacarla no borra ni la cima ni las subidas de nadie: solo deja de contar en ese reto.
  • Retirar un reto sin borrar nada (desmarcar «activo»): desaparece de la vista y el progreso sigue guardado. Borrarlo se lleva sus cimas de dentro del reto, nunca las del catálogo ni las subidas. No se puede borrar el último que quede.

Por debajo, un reto es una fila en challenges y una fila por cima en challenge_peaks con un peso (1 por defecto, por si un reto decide que un techo vale más que una destacada), así que también se puede hacer todo con dos INSERT si te resulta más rápido:

INSERT INTO challenges (slug, name, description, active, created_at)
  VALUES ('pirineo-3000', 'Los 3.000 del Pirineo', 'Los tresmiles pirenaicos.',
          true, CURRENT_TIMESTAMP);
INSERT INTO challenge_peaks (challenge_id, peak_id, weight)
  SELECT c.id, p.id, 1 FROM challenges c, peaks p
  WHERE c.slug = 'pirineo-3000' AND p.altitude >= 3000;

Con más de un reto activo aparece un selector en la portada, las zonas, el perfil y el marcador, y todas esas páginas aceptan ?reto=slug.

El reto de partida es especial: techos-espana recoge todo el catálogo, y las cimas que añadas (a mano o por CSV) entran en él solas. Si lo borras, el arranque lo vuelve a crear con el catálogo entero dentro.

El cuaderno de cada persona (su diario de subidas) se enseña siempre entero: una cima que en este reto no puntúa se subió igual.

Fotos

  • Se validan por contenido, no por extensión: si Pillow no puede abrirlo, se rechaza.
  • Se aceptan JPEG, PNG, WebP, MPO y HEIC, que es lo que dispara un iPhone por defecto. El HEIC entra por pillow-heif; sin esa dependencia se rechazaría, y con H5 eso importa porque la foto del móvil es justo la que da la insignia. Salga lo que salga a la entrada, lo que se guarda y se sirve es siempre JPEG.
  • Se reencodan a JPEG progresivo en tres tamaños: 2000 px para el visor a pantalla completa, 1400 px para el carrusel y una miniatura cuadrada de 480 px para la tira y el feed. El carrusel pide la mediana, así que ver una entrada no arrastra los megas de las grandes.
  • El reencode descarta todos los metadatos, GPS incluido, y no hay variable que lo desactive. Antes de tirarlos se leen exactamente dos cosas: la fecha del EXIF, que se guarda como fecha de la foto, y las coordenadas, que se usan para verificar la subida y no se guardan en ninguna parte (ver abajo).
  • Límite por archivo en MAX_UPLOAD_MB (15 por defecto). Por subida no hay tope salvo que pongas uno en PHOTOS_PER_ASCENT.

Qué respalda una subida

Un marcador que nadie se cree no vale nada, así que cada subida dice qué la respalda, y eso decide si cuenta en el marcador de verificadas:

Cuándo
declarada No hay ninguna foto. Es lo que queda del histórico o de una foto que falló.
foto Hay al menos una foto de la salida.
gps Una de esas fotos venía con coordenadas a menos de GPS_MAX_M de la cima (500 m por defecto).
  • Se sube de escalón, nunca se baja. Subir después la foto de la cámara buena, que no lleva GPS, no le quita la insignia a una subida ya verificada.
  • Las coordenadas no se guardan. Se leen del EXIF, se convierten en la distancia a la cima y se pierden ahí mismo. Lo único que queda en la base es esa distancia —«la foto se hizo a 200 m de la cima»—, que es lo que hace auditable la insignia sin decir dónde estuvo nadie. No hay ninguna columna en ningún sitio donde quepan unas coordenadas de una persona.
  • Una cima sin coordenadas en el catálogo no verifica, y no es un error. Hoy solo 60 de las 152 las tienen, así que en la mayoría del reto la insignia todavía no se puede ganar. La subida se guarda igual, sin avisos: el hueco es del catálogo.
  • La foto se pide siempre en el formulario. Como con JavaScript las fotos se envían después de guardar la salida —una por petición, para no chocar con el proxy—, el servidor no puede exigirlas al crearla sin fiarse de lo que le diga el navegador: lo que hace es no mentir, y dejarla en declarada mientras no llegue ninguna.

Compartir un enlace

Pegar un enlace de perfil, de cima o del marcador en WhatsApp, Telegram, Twitter o Mastodon enseña una tarjeta con imagen en vez de una URL pelada.

  • Perfil y marcador llevan una imagen de 1200×630 dibujada en el servidor con Pillow (app/tarjeta.py): /u/quien/tarjeta.png y /marcador/tarjeta.png. Los números salen de las mismas funciones que pinta la página, así que la tarjeta no puede decir un puesto distinto del que dice el marcador.
  • La ficha de una cima no dibuja nada: usa su propio retrato, que ya es público. Una cima sin retrato se comparte sin imagen, con su título y su texto.
  • Cada subida tiene su página (/ascensos/{id}) y su tarjeta, con la foto dibujada dentro del PNG. Compartirla es lo que hay debajo del botón Compartir de cada entrada del cuaderno.
  • La tarjeta de un perfil pasa por el mismo portero que la página: si está cerrado, la imagen contesta 404 igual que el perfil. Es una URL pública que enseña progreso, y ahí es donde se filtraría si no se mirara.
  • Ponle valor a PUBLIC_BASE_URL en producción. Open Graph exige URLs absolutas, y sin esa variable se deducen de la petición —que detrás de un proxy puede llegar como http y con el nombre interno del contenedor—.
  • El botón Compartir abre la hoja del sistema en el móvil (navigator.share); en escritorio copia el enlace. Sin JavaScript la página sigue en la barra de direcciones, que es de donde todo el mundo copia.
  • Al cerrar una provincia apuntando una cima sale un aviso con el botón puesto: es el momento en que apetece contarlo.
  • Compartir una salida que no es pública pide abrirla antes, con un aviso que dice qué se verá y —si la salida es de varios— que se abre para todos. Publicar es cosa de quien la apuntó, y se puede volver a cerrar desde Editar.

Dónde viven las fotos

Los retratos de las cimas son catálogo: se sirven como ficheros estáticos desde /media/peaks/ y los ve cualquiera, porque salen en la ficha pública.

Las fotos de las subidas no son ficheros públicos. Van por /fotos/{id}/{grande|media|mini}, y esa ruta pregunta a puede_ver_subida antes de abrir nada: la misma función que decide si la salida se pinta. Una salida pública enseña sus fotos a cualquiera; una de amigos pide cuenta; una privada es solo de quien subió. Se sirven con Cache-Control: private a propósito: una salida puede dejar de ser pública, y un intermediario que se hubiera quedado la foto la seguiría repartiendo.

Las caras y los escudos viven aparte, en uploads/avatars/, y se sirven por /u/quien-sea/foto/{grande|pequena} y /clubes/un-club/foto/{…}. Directorio propio y no mezclados con las fotos de las subidas porque el ZIP de «llévate tus datos» y el borrado de una cuenta barren uploads/ buscando fotos de salidas: mezclarlos obligaría a los dos a distinguir un retrato de una cumbre por el nombre del fichero. Cada ruta pregunta lo mismo que la página de la que cuelga —perfil_visible y puede_ver_club—, y también van con Cache-Control: private; la dirección lleva colgada la versión del fichero (?v=), así que cambiar de foto cambia la URL y nadie se queda con la vieja.

Las tipografías del sitio están dos veces en app/static/fonts/: en .woff2 para el navegador y en .ttf para Pillow, que no sabe abrir woff2. Son el mismo Archivo Narrow, bajo licencia OFL (OFL-Archivo-Narrow.txt). Si faltaran, la tarjeta se dibuja con la fuente que Pillow trae dentro en vez de fallar.

Avisos al teléfono

La app manda tres avisos al móvil y ninguno más: alguien pide seguirte, alguien a quien sigues apunta una cumbre y alguien comenta una salida en la que figuras. Cada uno se apaga por separado desde Ajustes → Avisos de la app, y el ajuste es de la cuenta: apagarlo lo apaga en todos tus teléfonos.

Van por el servicio de push de Expo, que es quien habla con Google y con Apple. Del lado del servidor son dos variables (EXPO_PUSH_URL y el opcional EXPO_ACCESS_TOKEN) y ninguna dependencia nueva: el envío es un POST con urllib.

Lo que un aviso lleva dentro, y lo que no:

  • Lleva quién y dónde: «Ana ha comentado», «Ana ha subido al Moncayo».
  • No lleva nunca el texto de un comentario, ni recortado, ni las notas de una salida, ni ninguna coordenada. Un aviso viaja por tres empresas y acaba en una pantalla bloqueada que puede mirar cualquiera que pase; quien escribió esa frase no eligió eso.

Quién lo recibe es exactamente quien podría verlo en el muro: hay que seguir a alguien de la salida con la solicitud aceptada, y una salida privada no avisa a nadie. Es la misma regla que criterio_de_visibilidad, mirada desde el otro lado, y está sujeta por los tests de tests/test_h13_avisos.py.

Un aviso que no llega no se reintenta: no hay cola ni proceso aparte. Lo que no se puede perder —la salida, el comentario, la solicitud— está en la base antes de que se mande nada. Lo único que sí se atiende de la respuesta de Expo es el DeviceNotRegistered: la app desinstalada o el permiso retirado borran ese teléfono de dispositivos.

Para apagarlos del todo en una instancia, deja EXPO_PUSH_URL vacío.

Copia de seguridad

Todo el estado vive en ./data, pero son dos cosas distintas y se respaldan distinto:

data/
├── postgres/         # los datos de la base: NO se copia en caliente con tar
├── uploads/          # fotos de las subidas + miniaturas
│   └── peaks/        # retratos de las cimas
└── copias/           # aquí deja las copias scripts/copia.sh

scripts/copia.sh hace las dos: vuelca la base con pg_dump —que sí es consistente con el servicio en marcha— y espeja las fotos.

scripts/copia.sh                          # deja la copia en data/copias
COPIAS_DIR=/mnt/backup scripts/copia.sh   # o donde prefieras
COPIAS_DIAS=30 scripts/copia.sh           # cuántos volcados se guardan (14 por defecto)

El volcado va comprimido y se comprueba antes de darlo por bueno: si no es un gzip válido o le falta el esquema, se queda como .parcial y la copia de ayer no se toca. El espejo de fotos es incremental y no borra, así que una foto que desaparezca del servicio sigue estando en la copia.

Una línea en el crontab del usuario que lleva el compose, a las 3:15:

15 3 * * * cd /ruta/al/repo && ./scripts/copia.sh >> data/copias/copia.log 2>&1

Una copia que no sale de esta máquina no es una copia. Si el disco se va, se va con todo. Después del volcado, a un sitio de fuera:

40 3 * * * rclone sync /ruta/al/repo/data/copias remoto:picos-copias

Restaurar

scripts/restaurar.sh --ultima              # la copia más reciente
scripts/restaurar.sh data/copias/picos-2026-09-02_0315.sql.gz
scripts/restaurar.sh --ultima --sin-fotos  # solo la base

Para la aplicación, carga el volcado con ON_ERROR_STOP=1 —si algo falla, para en vez de dejar media base—, devuelve las fotos, vuelve a levantar el servicio y enseña el recuento de lo restaurado. Pide escribir RESTAURAR antes de pisar nada; con --si no pregunta.

Si la base no la gobierna este compose (una gestionada, o una prueba en local), los dos scripts aceptan PG_LOCAL=1 y usan los clientes de postgres del equipo con PGHOST / PGPORT / PGPASSWORD.

Probado el 2 de septiembre de 2026: copia, borrado del esquema y de las fotos, y restauración desde cero. Vuelven las 52 provincias, las 152 cimas, las subidas, las fotos en disco y la versión de Alembic. Repítelo de vez en cuando.

Tests

Son de humo, no cobertura: avisan de que algo gordo se ha roto al tocar el modelo de datos. Necesitan un PostgreSQL de usar y tirar, porque entre test y test vacían todas las tablas.

pip install -r requirements-dev.txt
createdb picos_test
TEST_DATABASE_URL=postgresql+psycopg://picos:x@localhost:5432/picos_test pytest

Sin TEST_DATABASE_URL, los que tocan la base se saltan con un aviso y el resto sigue corriendo. Los de la entrada con Google no hablan con Google: lo que Google devuelve es un diccionario de datos y eso se fabrica; lo que se prueba es este lado.

Ver si aguanta

scripts/datos_de_prueba.py llena una base con cuentas y subidas de mentira, para medir las páginas con datos de verdad en vez de con veinte filas. Solo contra una base de usar y tirar: mete miles de filas y no sabe deshacerlas, y por eso se niega a escribir en una que ya tenga subidas dentro salvo que se le pase --forzar.

DATABASE_URL=postgresql+psycopg://picos:x@localhost:5432/picos_pruebas \
  python scripts/datos_de_prueba.py --cuentas 300 --subidas 10000

Reparte las subidas desigual a propósito —unos pocos con media lista y una cola larga con cuatro cimas—, porque es la forma que tiene una escalera de verdad y la que hace trabajar al ORDER BY. Con 300 cuentas y 10.000 subidas, /marcador responde con su vitrina en unos 80 ms y las escaleras del mes, del año y de una zona por debajo de 70; con 3.000 cuentas y 60.000 subidas, el marcador va a unos 210 ms.

Estructura

app/
├── main.py            # rutas HTML
├── api.py             # la API JSON (/api/v1), montada como sub-aplicación
├── models.py          # ORM + configuración por entorno
├── auth_google.py     # entrada con Google (OIDC), sin dependencias nuevas
├── services.py        # cuentas, sesiones, llaves, visibilidad, fotos, retos
│                      # y el motor del marcador: progreso, escaleras y puestos
├── notificaciones.py  # los tres avisos al teléfono y los aparatos apuntados
├── tarjeta.py         # las imágenes que salen al compartir un enlace
├── exportacion.py     # el ZIP con los datos de una persona (CSV, GPX y fotos)
├── data/peaks.py      # las 152 cimas de partida y la escala de dificultad
├── migrations/        # Alembic: el esquema de la base, versionado
├── templates/
└── static/            # css, htmx, leaflet y tipografías vendorizadas

scripts/               # copia.sh, restaurar.sh, su trozo común y
                       # datos_de_prueba.py (miles de filas de mentira)
tests/                 # tests de humo de services.py
RAT.md                 # registro de actividades de tratamiento (art. 30 RGPD)

Para añadir cimas lo normal es hacerlo desde Administración (formulario o CSV). Si prefieres que formen parte del catálogo del código, añade la línea en app/data/peaks.py y pulsa Administración → Editar el catálogo → «Volver a sembrar ahora» (con SEMBRAR_CIMAS=siempre basta un docker compose restart picos). La siembra es idempotente, así que no duplica nada.

Pendiente / ideas

  • 92 cimas sin coordenadas (el mapa solo muestra las que las tienen, y el GPX de la exportación tampoco puede llevarlas).
  • App para iOS y Android con la que apuntar la cumbre desde arriba y sin cobertura, usando el GPS del teléfono para verificarla en el sitio. Sustituye a la PWA que había en el plan. No graba ruta ni track: toma una lectura al apuntar, que se convierte en una distancia a la cima y se descarta, igual que las coordenadas de una foto.
  • Logo de club, y enseñar en cada perfil a qué clubes pertenece.
  • Enlace a tracks de Wikiloc por cima.

Antes de abrirlo al público

Hay tres páginas legales en /aviso-legal, /privacidad y /condiciones, y un registro de actividades de tratamiento en RAT.md, que es un documento interno y no se publica. Los datos del responsable salen del entorno, en un solo sitio:

LEGAL_TITULAR=           # nombre y apellidos
LEGAL_NIF=
LEGAL_DIRECCION=         # opcional, pero recomendable
CONTACTO_EMAIL=          # sale en el pie y es la vía para ejercer derechos
HOSTING_PROVEEDOR=       # es un encargado del tratamiento: hay que nombrarlo
HOSTING_PAIS=España      # fuera del EEE hay que rehacer ese apartado del RAT
EDAD_MINIMA=14

Mientras falten LEGAL_TITULAR, LEGAL_NIF, CONTACTO_EMAIL o HOSTING_PROVEEDOR, las tres páginas lo dicen en grande y Administración lo marca en rojo. Es a propósito: un aviso legal publicado con un hueco donde va el nombre aparenta cumplir sin cumplir, y eso es peor que no tener ninguno.

Copia los mismos datos a los [corchetes] de RAT.md, firma el contrato de encargado del tratamiento con el proveedor del VPS, y que lo lea alguien con criterio jurídico: los textos están redactados con el detalle que da conocer el código por dentro, pero no son un dictamen.

Vigilar que sigue en pie

/salud toca la base de datos y devuelve 503 si no contesta. Antes decía «ok» pase lo que pase, que es el peor fallo posible en un monitor: la aplicación responde perfectamente con la base caída, todas las páginas dan un 500 y el vigilante dice que todo va bien.

Apunta ahí cualquier servicio de monitorización —vale uno gratuito— y también lo usa el HEALTHCHECK del contenedor, así que docker ps dice la verdad. No provoca reinicios en bucle: restart: unless-stopped solo reacciona a que el proceso muera, no a que el healthcheck falle.

Licencia

AGPL-3.0-or-later. Copyright © 2026 Daniel Pedrero. El texto íntegro está en LICENSE.

Es software libre: puedes usarlo, estudiarlo, modificarlo y redistribuirlo. La Affero añade una condición sobre la GPL corriente: si lo alojas y otras personas lo usan por la red, tienes que ofrecerles el código que estás ejecutando, con tus modificaciones incluidas. Por eso el pie de página enlaza al repositorio; si montas tu copia, apunta SOURCE_URL en el .env al sitio donde publiques tu versión.

Las fotos de las cimas descargadas de Wikipedia y Wikimedia Commons no están bajo esta licencia: cada una conserva la suya, y por eso se guarda y se muestra el autor junto a la imagen (en la ficha de la cima y en el globo del mapa).

Contribuciones

Las aportaciones se aceptan con DCO (Developer Certificate of Origin): basta con firmar los commits con git commit -s, que añade una línea Signed-off-by: declarando que tienes derecho a aportar ese código bajo la licencia del proyecto. El copyright queda a nombre de cada cual; no se pide cesión.