feat: added improvements from django-htmx-template
This commit is contained in:
@@ -35,9 +35,15 @@ jobs:
|
||||
|
||||
- name: Compile translations
|
||||
run: uv run python manage.py compilemessages
|
||||
env:
|
||||
SECRET_KEY: 'super-secret-key'
|
||||
ALLOWED_HOSTS: '*'
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest --cov
|
||||
env:
|
||||
SECRET_KEY: 'super-secret-key'
|
||||
ALLOWED_HOSTS: '*'
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+8
-2
@@ -1,7 +1,9 @@
|
||||
FROM python:3.13-alpine3.23 AS builder
|
||||
ENV UV_SYSTEM_PYTHON=1
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /usr/local/bin/uv
|
||||
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
|
||||
|
||||
@@ -21,8 +23,12 @@ RUN chown -R ${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 \
|
||||
&& su ${user} -c "sh ./scripts/build.sh" \
|
||||
&& SECRET_KEY=build-only ALLOWED_HOSTS='*' su ${user} -c "sh ./scripts/build.sh" \
|
||||
&& apk del .tailwind-runtime
|
||||
|
||||
USER ${user}
|
||||
|
||||
@@ -65,16 +65,20 @@ uv sync --group dev
|
||||
```
|
||||
|
||||
Crea un archivo `.env` en la raíz del proyecto (ver
|
||||
[Variables de entorno](#variables-de-entorno) más abajo). Para desarrollo
|
||||
local basta con:
|
||||
[Variables de entorno](#variables-de-entorno) más abajo). `SECRET_KEY` y
|
||||
`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
|
||||
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
|
||||
email al backend de consola (se imprime en la terminal en vez de mandarse
|
||||
de verdad) y sin credenciales adicionales.
|
||||
Con eso, la app arranca con SQLite, envío de email al backend de consola
|
||||
(se imprime en la terminal en vez de mandarse de verdad) y sin
|
||||
credenciales adicionales.
|
||||
|
||||
```bash
|
||||
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,
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -219,7 +228,10 @@ La imagen Docker ya ejecuta `collectstatic`, `compilemessages` y el
|
||||
CSS precompilado al construirse, y sirve la app con Uvicorn (variables
|
||||
`WSGI_HOST`/`WSGI_WORKERS` para ajustar host/nº de workers). WhiteNoise se
|
||||
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,
|
||||
si pasan, se construye y publica una imagen Docker (ver
|
||||
|
||||
@@ -49,7 +49,7 @@ if DEBUG:
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'crochet.middleware.WhiteNoiseWithMediaMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
@@ -141,6 +141,11 @@ LANGUAGES = [
|
||||
('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'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
@@ -12,11 +12,17 @@ env_file = BASE_DIR / '.env'
|
||||
if os.path.exists(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)
|
||||
|
||||
CORS_ORIGIN_WHITELIST = env.str('CORS_ORIGIN_WHITELIST', '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')
|
||||
|
||||
@@ -24,7 +30,7 @@ REDIS_HOST = env.str('REDIS_HOST', 'redis')
|
||||
REDIS_PORT = env.str('REDIS_PORT', 6379)
|
||||
|
||||
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')
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ urlpatterns = [
|
||||
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('i18n/', include('django.conf.urls.i18n')),
|
||||
]
|
||||
|
||||
# i18n_patterns antepone el prefijo de idioma (/es/..., /en/...) a cada URL,
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: crochet\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"
|
||||
"MIME-Version: 1.0\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:18
|
||||
#: crochet/templates/crochet/base.html:71
|
||||
#: crochet/templates/crochet/base.html:82
|
||||
msgid "Ajustes de la cuenta"
|
||||
msgstr "Account settings"
|
||||
|
||||
@@ -277,15 +277,15 @@ msgstr "This will permanently delete your account and all your patterns."
|
||||
msgid "Cambiar tema"
|
||||
msgstr "Change theme"
|
||||
|
||||
#: crochet/templates/crochet/base.html:58
|
||||
#: crochet/templates/crochet/base.html:62
|
||||
msgid "Cambiar idioma"
|
||||
msgstr "Change language"
|
||||
|
||||
#: crochet/templates/crochet/base.html:75
|
||||
#: crochet/templates/crochet/base.html:86
|
||||
msgid "Cerrar sesión"
|
||||
msgstr "Log out"
|
||||
|
||||
#: crochet/templates/crochet/base.html:81
|
||||
#: crochet/templates/crochet/base.html:92
|
||||
#: crochet/templates/crochet/home.html:25
|
||||
#: crochet/templates/crochet/password_reset_complete.html:18
|
||||
#: crochet/templates/registration/login.html:4
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: crochet\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"
|
||||
"MIME-Version: 1.0\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:18
|
||||
#: crochet/templates/crochet/base.html:71
|
||||
#: crochet/templates/crochet/base.html:82
|
||||
msgid "Ajustes de la cuenta"
|
||||
msgstr ""
|
||||
|
||||
@@ -265,15 +265,15 @@ msgstr ""
|
||||
msgid "Cambiar tema"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/base.html:58
|
||||
#: crochet/templates/crochet/base.html:62
|
||||
msgid "Cambiar idioma"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/base.html:75
|
||||
#: crochet/templates/crochet/base.html:86
|
||||
msgid "Cerrar sesión"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/base.html:81
|
||||
#: crochet/templates/crochet/base.html:92
|
||||
#: crochet/templates/crochet/home.html:25
|
||||
#: crochet/templates/crochet/password_reset_complete.html:18
|
||||
#: crochet/templates/registration/login.html:4
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
<h2 class="card-title text-lg">{% trans 'Nombre para mostrar' %}</h2>
|
||||
{% include 'crochet/_account_display_name_form.html' %}
|
||||
@@ -44,14 +44,14 @@
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<h2 class="card-title text-lg">{% trans 'Email' %}</h2>
|
||||
{% include 'crochet/_account_email_form.html' %}
|
||||
</div>
|
||||
</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">
|
||||
<h2 class="card-title text-lg">{% trans 'Cambiar contraseña' %}</h2>
|
||||
{% include 'crochet/_account_password_form.html' %}
|
||||
|
||||
@@ -51,14 +51,25 @@
|
||||
<!-- 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
|
||||
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>
|
||||
normal (no un <select>), así que navegar con teclado entre
|
||||
ellas no dispara nada hasta que se pulsa/activa una. -->
|
||||
usuario (que no existe para anónimos). POST a la vista
|
||||
"set_language" de Django (ver config/urls.py), no un simple
|
||||
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">
|
||||
<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">
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -68,11 +79,11 @@
|
||||
<div class="dropdown dropdown-end">
|
||||
<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">
|
||||
<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>
|
||||
<form method="post" action="{% url 'crochet:logout' %}">
|
||||
{% 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>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -93,8 +93,14 @@ class CookieBannerTests(TestCase):
|
||||
|
||||
class LanguageSwitcherTests(TestCase):
|
||||
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/')
|
||||
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):
|
||||
# No solo cambia el prefijo (/es/ -> /en/): toda la ruta traducible
|
||||
@@ -109,7 +115,7 @@ class LanguageSwitcherTests(TestCase):
|
||||
|
||||
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):
|
||||
response = self.client.get('/es/')
|
||||
|
||||
Reference in New Issue
Block a user