vienalatina/apps/board/templates/base.html
Claude 5ab9e3cab5
Phase C: private messages, with blocking and photos
An inbox between two members — conversations, per-person unread marks,
photos, blocking. Not live chat: that needs a connection held open per
signed-in member, which the sync workers cannot do.

Membership of the conversation is the whole access rule and is checked on
every hit, answering 404 rather than 403 so a member cannot tell a
conversation that is not theirs from one that does not exist. A picture
in a private message is checked the same way: on the board being signed
in is enough, here it is nowhere near.

Blocking is symmetric. One row stops both directions, and you can only
lift your own. A block that silenced only the blocked person would leave
the blocker writing freely, which is a megaphone rather than a safety
feature. Enforced in the handlers, with a test that posts from a page
held open from before the block.

Erasing a member deletes their private messages, both sides, and their
pictures off disk. A thread outlives its author because other people
replied; a two-party exchange has no remainder, and keeping half of
erased correspondence is what erasure exists to prevent. The guard added
in c9c549e did its job: it failed the moment the new tables landed and
named all four columns.

The part that needed care: schema.sql is all CREATE TABLE IF NOT EXISTS,
so it can add a table and nothing else. Every change so far happened to
be a new table. Letting an attachment belong to a message is not — and
SQLite cannot do it in place, because the table carries a CHECK
constraint and there is no DROP CONSTRAINT. Verified before building on
it: ALTER TABLE ADD COLUMN succeeds and the next insert is refused.

So migrations.py, numbered steps recorded in PRAGMA user_version, run
after the schema so a fresh database finds its work already done. Step 1
rebuilds attachments the documented way. Tested against a database built
in the old shape with rows in it, because a migration tested only on a
fresh database is tested against the one case it was never needed for —
including that the rebuilt CHECK is as strict as the one it replaced.

229 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-28 16:10:52 +00:00

67 lines
2.3 KiB
HTML

{# Chrome deliberately mirrors the public site's header and footer, so moving
between vienalatina.com and the members area does not feel like leaving. #}
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}Comunidad{% endblock %} — Viena Latina</title>
<link rel="stylesheet" href="{{ url_for('static', filename='board.css') }}">
<script src="{{ url_for('static', filename='board.js') }}" defer></script>
</head>
<body>
<header class="site-bar">
<div class="site-bar__inner">
<a class="site-bar__brand" href="/"><span class="site-bar__brand-text">Viena Latina</span></a>
{% if g.member %}
<div class="site-bar__account">
<span class="who">{{ g.member.display_name }}</span>
<form method="post" action="{{ url_for('auth.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="linkish" type="submit">Salir</button>
</form>
</div>
{% endif %}
</div>
{% if g.member %}
<nav class="site-bar__pages" aria-label="Secciones">
<ul class="page-nav">
<li><a href="{{ url_for('board.threads') }}">Mensajes</a></li>
{% if g.member.role in ('owner', 'admin') %}
<li>
{# "Mensajes" is the public board, so this cannot be called that.
The badge is only drawn when there is something to see. #}
<a href="{{ url_for('messages.inbox') }}">Privados{% set pending = unread_private() %}{% if pending %} <span class="tag">{{ pending }}</span>{% endif %}</a>
</li>
<li><a href="{{ url_for('content.index') }}">Contenido</a></li>
{% endif %}
<li><a href="{{ url_for('members.index') }}">Miembros</a></li>
</ul>
</nav>
{% endif %}
</header>
<div class="page-shell">
<main class="feed">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<p class="flash flash--{{ category }}">{{ message }}</p>
{% endfor %}
{% endwith %}
{% block main %}{% endblock %}
</main>
<footer class="site-foot">
<a href="/">Ir al sitio público</a>
{% if g.member %} · <a href="{{ url_for('members.export') }}">Descargar mis datos</a>{% endif %}
<div class="site-foot__legal">Viena Latina © {{ current_year }} · Área privada</div>
</footer>
</div>
</body>
</html>