feat: added improvements from django-htmx-template
CI / test (push) Successful in 1m2s
CI / build (push) Successful in 50s

This commit is contained in:
2026-09-21 14:27:17 +02:00
parent e76b4b7a9c
commit 5fe349d617
12 changed files with 106 additions and 34 deletions
+6
View File
@@ -35,9 +35,15 @@ jobs:
- name: Compile translations - name: Compile translations
run: uv run python manage.py compilemessages run: uv run python manage.py compilemessages
env:
SECRET_KEY: 'super-secret-key'
ALLOWED_HOSTS: '*'
- name: Run tests - name: Run tests
run: uv run pytest --cov run: uv run pytest --cov
env:
SECRET_KEY: 'super-secret-key'
ALLOWED_HOSTS: '*'
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+8 -2
View File
@@ -1,7 +1,9 @@
FROM python:3.13-alpine3.23 AS builder FROM python:3.13-alpine3.23 AS builder
ENV UV_SYSTEM_PYTHON=1 ENV UV_SYSTEM_PYTHON=1
COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /usr/local/bin/uv
COPY pyproject.toml . COPY pyproject.toml .
RUN pip install uv && uv pip install -r pyproject.toml && pip uninstall -y uv RUN uv pip install -r pyproject.toml
FROM python:3.13-alpine3.23 FROM python:3.13-alpine3.23
@@ -21,8 +23,12 @@ RUN chown -R ${user}:${user} /code
COPY --chown=${user}:${user} . /code COPY --chown=${user}:${user} . /code
# SECRET_KEY/ALLOWED_HOSTS provisionales para la construcción de la imagen
# (build.sh carga settings, que ya no traen un valor por defecto para
# ninguna de las dos, ver config/settings/env.py). En producción no se
# heredan: solo existen en esta RUN, no quedan en la imagen final.
RUN apk add --no-cache --virtual .tailwind-runtime libstdc++ libgcc \ RUN apk add --no-cache --virtual .tailwind-runtime libstdc++ libgcc \
&& su ${user} -c "sh ./scripts/build.sh" \ && SECRET_KEY=build-only ALLOWED_HOSTS='*' su ${user} -c "sh ./scripts/build.sh" \
&& apk del .tailwind-runtime && apk del .tailwind-runtime
USER ${user} USER ${user}
+20 -8
View File
@@ -65,16 +65,20 @@ uv sync --group dev
``` ```
Crea un archivo `.env` en la raíz del proyecto (ver Crea un archivo `.env` en la raíz del proyecto (ver
[Variables de entorno](#variables-de-entorno) más abajo). Para desarrollo [Variables de entorno](#variables-de-entorno) más abajo). `SECRET_KEY` y
local basta con: `ALLOWED_HOSTS` son las únicas que no tienen un valor por defecto (a
propósito: una clave/lista de hosts insegura conocida de antemano no
protege nada), así que hacen falta incluso en desarrollo local:
```env ```env
DEBUG=True DEBUG=True
SECRET_KEY=cualquier-cadena-larga-y-aleatoria
ALLOWED_HOSTS=*
``` ```
Con eso, la app arranca con SQLite, `SECRET_KEY` de desarrollo, envío de Con eso, la app arranca con SQLite, envío de email al backend de consola
email al backend de consola (se imprime en la terminal en vez de mandarse (se imprime en la terminal en vez de mandarse de verdad) y sin
de verdad) y sin credenciales adicionales. credenciales adicionales.
```bash ```bash
uv run python manage.py migrate uv run python manage.py migrate
@@ -164,12 +168,17 @@ commit.
Todas se leen en `config/settings/env.py`; sin `.env`, o si falta alguna, Todas se leen en `config/settings/env.py`; sin `.env`, o si falta alguna,
se usan los valores por defecto indicados (pensados para desarrollo local). se usan los valores por defecto indicados (pensados para desarrollo local).
`SECRET_KEY` y `ALLOWED_HOSTS` son la excepción: no tienen valor por
defecto, así que el arranque falla con un error claro
(`ImproperlyConfigured`) si faltan, en vez de servir tráfico con una clave
insegura conocida de antemano o con el check de `Host` desactivado de
hecho.
| Variable | Por defecto | Descripción | | Variable | Por defecto | Descripción |
|---|---|---| |---|---|---|
| `SECRET_KEY` | clave insegura de desarrollo | **Cámbiala en producción.** | | `SECRET_KEY` | *(obligatoria)* | Clave única y secreta. **Distinta en cada entorno, nunca la de otro despliegue.** |
| `DEBUG` | `False` | Activa páginas de error detalladas, Django Debug Toolbar y Silk. No debe estar activo en producción. | | `DEBUG` | `False` | Activa páginas de error detalladas, Django Debug Toolbar y Silk. No debe estar activo en producción. |
| `ALLOWED_HOSTS` | `*` | Lista separada por comas. Debe restringirse a los dominios reales en producción. | | `ALLOWED_HOSTS` | *(obligatoria)* | Lista separada por comas. `*` vale para desarrollo local; restringida a los dominios reales en producción. |
| `CSRF_TRUSTED_ORIGINS` | `http://localhost:8000` | Lista separada por comas. Necesario si la app se sirve detrás de un dominio/HTTPS distinto. | | `CSRF_TRUSTED_ORIGINS` | `http://localhost:8000` | Lista separada por comas. Necesario si la app se sirve detrás de un dominio/HTTPS distinto. |
| `DATABASE_URL` | `sqlite:///db.sqlite3` | Formato `django-environ` (p. ej. `postgres://usuario:password@host:5432/nombre_bd`). | | `DATABASE_URL` | `sqlite:///db.sqlite3` | Formato `django-environ` (p. ej. `postgres://usuario:password@host:5432/nombre_bd`). |
| `S3_ENABLED` | `False` | Si es `True`, los archivos subidos (imágenes de portada/patrón) se guardan en S3 en vez de en el filesystem local. | | `S3_ENABLED` | `False` | Si es `True`, los archivos subidos (imágenes de portada/patrón) se guardan en S3 en vez de en el filesystem local. |
@@ -219,7 +228,10 @@ La imagen Docker ya ejecuta `collectstatic`, `compilemessages` y el
CSS precompilado al construirse, y sirve la app con Uvicorn (variables CSS precompilado al construirse, y sirve la app con Uvicorn (variables
`WSGI_HOST`/`WSGI_WORKERS` para ajustar host/nº de workers). WhiteNoise se `WSGI_HOST`/`WSGI_WORKERS` para ajustar host/nº de workers). WhiteNoise se
encarga de servir los estáticos directamente desde la propia app, sin encarga de servir los estáticos directamente desde la propia app, sin
necesidad de un servidor/proxy de estáticos aparte. necesidad de un servidor/proxy de estáticos aparte; también sirve
`/media/` (portadas/imágenes de patrón) cuando `S3_ENABLED=False` (ver
`crochet/middleware.py`), así que tampoco hace falta nada aparte para eso
salvo que uses S3.
Al hacer push a `master` se dispara la integración continua (tests) y, Al hacer push a `master` se dispara la integración continua (tests) y,
si pasan, se construye y publica una imagen Docker (ver si pasan, se construye y publica una imagen Docker (ver
+6 -1
View File
@@ -49,7 +49,7 @@ if DEBUG:
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', 'crochet.middleware.WhiteNoiseWithMediaMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', 'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
@@ -141,6 +141,11 @@ LANGUAGES = [
('en', 'English'), ('en', 'English'),
] ]
# Un año (en vez del default de Django, de sesión: se perdía al cerrar el
# navegador). La deja la vista "set_language" (ver config/urls.py) al elegir
# idioma desde el selector del navbar (ver crochet/base.html).
LANGUAGE_COOKIE_AGE = 60 * 60 * 24 * 365
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
USE_I18N = True USE_I18N = True
+8 -2
View File
@@ -12,11 +12,17 @@ env_file = BASE_DIR / '.env'
if os.path.exists(env_file): if os.path.exists(env_file):
environ.Env.read_env(env_file) environ.Env.read_env(env_file)
SECRET_KEY = env.str('SECRET_KEY', 'this-is-the-default-secret-key') SECRET_KEY = env.str('SECRET_KEY')
DEBUG = env.bool('DEBUG', False) DEBUG = env.bool('DEBUG', False)
CORS_ORIGIN_WHITELIST = env.str('CORS_ORIGIN_WHITELIST', 'http://localhost:8000').split(',') CORS_ORIGIN_WHITELIST = env.str('CORS_ORIGIN_WHITELIST', 'http://localhost:8000').split(',')
CSRF_TRUSTED_ORIGINS = env.str('CSRF_TRUSTED_ORIGINS', 'http://localhost:8000').split(',') CSRF_TRUSTED_ORIGINS = env.str('CSRF_TRUSTED_ORIGINS', 'http://localhost:8000').split(',')
# La cookie CSRF solo hace falta leerla desde JS en sitios que la mandan a
# mano en una cabecera (fetch/XHR); aquí no la lee ningún script (los POST
# de HTMX/fetch usan el token embebido en el HTML vía csrf_token, ver
# account_settings.html/csrf.js), así que no hay motivo para exponerla al
# DOM y sí para quitarle esa superficie a un XSS.
CSRF_COOKIE_HTTPONLY = True
DATABASE_URL = env.db_url('DATABASE_URL', 'sqlite:///db.sqlite3') DATABASE_URL = env.db_url('DATABASE_URL', 'sqlite:///db.sqlite3')
@@ -24,7 +30,7 @@ REDIS_HOST = env.str('REDIS_HOST', 'redis')
REDIS_PORT = env.str('REDIS_PORT', 6379) REDIS_PORT = env.str('REDIS_PORT', 6379)
PAGE_SIZE = env.int('PAGE_SIZE', 20) PAGE_SIZE = env.int('PAGE_SIZE', 20)
ALLOWED_HOSTS = env.str('ALLOWED_HOSTS', '*').split(',') ALLOWED_HOSTS = env.str('ALLOWED_HOSTS').split(',')
CELERY_BROKER_URL = env.str('CELERY_BROKER_URL', 'redis://172.17.0.1:6379/0') CELERY_BROKER_URL = env.str('CELERY_BROKER_URL', 'redis://172.17.0.1:6379/0')
+1
View File
@@ -39,6 +39,7 @@ urlpatterns = [
view=TemplateView.as_view(template_name='manifest.json', content_type='application/manifest+json'), view=TemplateView.as_view(template_name='manifest.json', content_type='application/manifest+json'),
), ),
path('offline/', name='offline', view=TemplateView.as_view(template_name='offline.html')), path('offline/', name='offline', view=TemplateView.as_view(template_name='offline.html')),
path('i18n/', include('django.conf.urls.i18n')),
] ]
# i18n_patterns antepone el prefijo de idioma (/es/..., /en/...) a cada URL, # i18n_patterns antepone el prefijo de idioma (/es/..., /en/...) a cada URL,
+5 -5
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: crochet\n" "Project-Id-Version: crochet\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-16 14:05+0000\n" "POT-Creation-Date: 2026-09-21 12:22+0000\n"
"Language: en\n" "Language: en\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@@ -239,7 +239,7 @@ msgstr "You don't have any patterns yet."
#: crochet/templates/crochet/account_settings.html:4 #: crochet/templates/crochet/account_settings.html:4
#: crochet/templates/crochet/account_settings.html:18 #: crochet/templates/crochet/account_settings.html:18
#: crochet/templates/crochet/base.html:71 #: crochet/templates/crochet/base.html:82
msgid "Ajustes de la cuenta" msgid "Ajustes de la cuenta"
msgstr "Account settings" msgstr "Account settings"
@@ -277,15 +277,15 @@ msgstr "This will permanently delete your account and all your patterns."
msgid "Cambiar tema" msgid "Cambiar tema"
msgstr "Change theme" msgstr "Change theme"
#: crochet/templates/crochet/base.html:58 #: crochet/templates/crochet/base.html:62
msgid "Cambiar idioma" msgid "Cambiar idioma"
msgstr "Change language" msgstr "Change language"
#: crochet/templates/crochet/base.html:75 #: crochet/templates/crochet/base.html:86
msgid "Cerrar sesión" msgid "Cerrar sesión"
msgstr "Log out" msgstr "Log out"
#: crochet/templates/crochet/base.html:81 #: crochet/templates/crochet/base.html:92
#: crochet/templates/crochet/home.html:25 #: crochet/templates/crochet/home.html:25
#: crochet/templates/crochet/password_reset_complete.html:18 #: crochet/templates/crochet/password_reset_complete.html:18
#: crochet/templates/registration/login.html:4 #: crochet/templates/registration/login.html:4
+5 -5
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: crochet\n" "Project-Id-Version: crochet\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-16 14:05+0000\n" "POT-Creation-Date: 2026-09-21 12:22+0000\n"
"Language: es\n" "Language: es\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@@ -229,7 +229,7 @@ msgstr ""
#: crochet/templates/crochet/account_settings.html:4 #: crochet/templates/crochet/account_settings.html:4
#: crochet/templates/crochet/account_settings.html:18 #: crochet/templates/crochet/account_settings.html:18
#: crochet/templates/crochet/base.html:71 #: crochet/templates/crochet/base.html:82
msgid "Ajustes de la cuenta" msgid "Ajustes de la cuenta"
msgstr "" msgstr ""
@@ -265,15 +265,15 @@ msgstr ""
msgid "Cambiar tema" msgid "Cambiar tema"
msgstr "" msgstr ""
#: crochet/templates/crochet/base.html:58 #: crochet/templates/crochet/base.html:62
msgid "Cambiar idioma" msgid "Cambiar idioma"
msgstr "" msgstr ""
#: crochet/templates/crochet/base.html:75 #: crochet/templates/crochet/base.html:86
msgid "Cerrar sesión" msgid "Cerrar sesión"
msgstr "" msgstr ""
#: crochet/templates/crochet/base.html:81 #: crochet/templates/crochet/base.html:92
#: crochet/templates/crochet/home.html:25 #: crochet/templates/crochet/home.html:25
#: crochet/templates/crochet/password_reset_complete.html:18 #: crochet/templates/crochet/password_reset_complete.html:18
#: crochet/templates/registration/login.html:4 #: crochet/templates/registration/login.html:4
+19
View File
@@ -0,0 +1,19 @@
import os
from django.conf import settings
from whitenoise.middleware import WhiteNoiseMiddleware
class WhiteNoiseWithMediaMiddleware(WhiteNoiseMiddleware):
# Además de los estáticos de siempre (ver WhiteNoiseMiddleware), sirve
# también MEDIA_ROOT (portadas/imágenes de patrón) cuando no hay S3
# configurado: sin esto, en un despliegue sin S3_ENABLED no hay nada
# que sirva /media/ (STORAGES['default'] es FileSystemStorage, que solo
# sabe guardar/leer en disco, no exponerlo por HTTP), así que las
# imágenes se guardarían pero no se verían. add_files() en __init__ (no
# en cada petición) para que WhiteNoise las indexe una sola vez, igual
# que hace con STATIC_ROOT.
def __init__(self, get_response):
super().__init__(get_response)
if not settings.S3_ENABLED and os.path.isdir(settings.MEDIA_ROOT):
self.add_files(str(settings.MEDIA_ROOT), prefix=settings.MEDIA_URL)
@@ -35,7 +35,7 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<section data-settings-panel="profile" class="flex flex-col gap-8"> <section data-settings-panel="profile" class="flex flex-col gap-8">
<section class="card bg-base-100 shadow border border-base-300"> <section class="card bg-base-100 shadow border border-base-300 mb-4">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-lg">{% trans 'Nombre para mostrar' %}</h2> <h2 class="card-title text-lg">{% trans 'Nombre para mostrar' %}</h2>
{% include 'crochet/_account_display_name_form.html' %} {% include 'crochet/_account_display_name_form.html' %}
@@ -44,14 +44,14 @@
</section> </section>
<section data-settings-panel="security" class="hidden flex-col gap-8"> <section data-settings-panel="security" class="hidden flex-col gap-8">
<section class="card bg-base-100 shadow border border-base-300"> <section class="card bg-base-100 shadow border border-base-300 mb-4">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-lg">{% trans 'Email' %}</h2> <h2 class="card-title text-lg">{% trans 'Email' %}</h2>
{% include 'crochet/_account_email_form.html' %} {% include 'crochet/_account_email_form.html' %}
</div> </div>
</section> </section>
<section class="card bg-base-100 shadow border border-base-300"> <section class="card bg-base-100 shadow border border-base-300 mb-4">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-lg">{% trans 'Cambiar contraseña' %}</h2> <h2 class="card-title text-lg">{% trans 'Cambiar contraseña' %}</h2>
{% include 'crochet/_account_password_form.html' %} {% include 'crochet/_account_password_form.html' %}
+17 -6
View File
@@ -51,14 +51,25 @@
<!-- El idioma es una preferencia de la página, no de la cuenta: va <!-- El idioma es una preferencia de la página, no de la cuenta: va
siempre en el mismo sitio del navbar, tanto si has iniciado siempre en el mismo sitio del navbar, tanto si has iniciado
sesión como si no, en vez de vivir dentro del dropdown de sesión como si no, en vez de vivir dentro del dropdown de
usuario (que no existe para anónimos). Cada opción es un <a> usuario (que no existe para anónimos). POST a la vista
normal (no un <select>), así que navegar con teclado entre "set_language" de Django (ver config/urls.py), no un simple
ellas no dispara nada hasta que se pulsa/activa una. --> enlace a la URL traducida con el tag "translate_url": eso
cambia el prefijo de idioma de esta URL, pero no deja la
cookie LANGUAGE_COOKIE_NAME, así que LocaleMiddleware volvía a
decidir por Accept-Language en la siguiente visita a "/" en
vez de recordar lo elegido. -->
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<button type="button" tabindex="0" class="btn btn-sm btn-ghost" aria-label="{% trans 'Cambiar idioma' %}">{{ CURRENT_LANGUAGE|language_flag }} {{ CURRENT_LANGUAGE|upper }}</button> <button type="button" tabindex="0" class="btn btn-sm btn-ghost" aria-label="{% trans 'Cambiar idioma' %}">{{ CURRENT_LANGUAGE|language_flag }} {{ CURRENT_LANGUAGE|upper }}</button>
<ul tabindex="0" class="dropdown-content menu menu-sm bg-base-100 border border-base-300 rounded-box z-10 w-40 p-2 shadow"> <ul tabindex="0" class="dropdown-content menu menu-sm bg-base-100 border border-base-300 rounded-box z-10 w-40 p-2 shadow">
{% for lang_code, lang_name in AVAILABLE_LANGUAGES %} {% for lang_code, lang_name in AVAILABLE_LANGUAGES %}
<li><a href="{% translate_url request.path lang_code %}"{% if lang_code == CURRENT_LANGUAGE %} class="menu-active"{% endif %}>{{ lang_code|language_flag }} {{ lang_name }}</a></li> <li>
<form method="post" action="{% url 'set_language' %}">
{% csrf_token %}
<input type="hidden" name="language" value="{{ lang_code }}">
<input type="hidden" name="next" value="{% translate_url request.path lang_code %}">
<button type="submit" class="w-full text-left cursor-pointer my-2">{{ lang_code|language_flag }} {{ lang_name }}</button>
</form>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@@ -68,11 +79,11 @@
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<button type="button" tabindex="0" class="btn btn-sm btn-ghost">{{ user.get_full_name|default:user.username }}</button> <button type="button" tabindex="0" class="btn btn-sm btn-ghost">{{ user.get_full_name|default:user.username }}</button>
<ul tabindex="0" class="dropdown-content menu menu-sm bg-base-100 border border-base-300 rounded-box z-10 w-52 p-2 shadow"> <ul tabindex="0" class="dropdown-content menu menu-sm bg-base-100 border border-base-300 rounded-box z-10 w-52 p-2 shadow">
<li><a href="{% url 'crochet:account_settings' %}">{% trans 'Ajustes de la cuenta' %}</a></li> <li><a class="my-2" href="{% url 'crochet:account_settings' %}">{% trans 'Ajustes de la cuenta' %}</a></li>
<li> <li>
<form method="post" action="{% url 'crochet:logout' %}"> <form method="post" action="{% url 'crochet:logout' %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="w-full text-left">{% trans 'Cerrar sesión' %}</button> <button type="submit" class="w-full text-left my-2">{% trans 'Cerrar sesión' %}</button>
</form> </form>
</li> </li>
</ul> </ul>
+8 -2
View File
@@ -93,8 +93,14 @@ class CookieBannerTests(TestCase):
class LanguageSwitcherTests(TestCase): class LanguageSwitcherTests(TestCase):
def test_home_page_switcher_offers_the_same_page_in_english(self): def test_home_page_switcher_offers_the_same_page_in_english(self):
# POST a la vista "set_language" (ver config/urls.py), no un <a>
# normal: un <a href="{% translate_url %}"> cambia el prefijo de la
# URL pero no deja la cookie LANGUAGE_COOKIE_NAME, así que
# LocaleMiddleware volvía a decidir por Accept-Language en la
# siguiente visita en vez de recordar lo elegido.
response = self.client.get('/es/') response = self.client.get('/es/')
self.assertContains(response, 'href="/en/"') self.assertContains(response, 'action="/i18n/setlang/"')
self.assertContains(response, 'value="/en/"')
def test_translated_route_switcher_preserves_the_current_view(self): def test_translated_route_switcher_preserves_the_current_view(self):
# No solo cambia el prefijo (/es/ -> /en/): toda la ruta traducible # No solo cambia el prefijo (/es/ -> /en/): toda la ruta traducible
@@ -109,7 +115,7 @@ class LanguageSwitcherTests(TestCase):
response = self.client.get(es_url) response = self.client.get(es_url)
self.assertContains(response, f'href="{en_url}"') self.assertContains(response, f'value="{en_url}"')
def test_current_language_is_shown_on_the_trigger_button(self): def test_current_language_is_shown_on_the_trigger_button(self):
response = self.client.get('/es/') response = self.client.get('/es/')