diff --git a/.env.base b/.env.base index 5901f21a..8d0cac13 100644 --- a/.env.base +++ b/.env.base @@ -3,6 +3,7 @@ CONTACT_EMAIL_SENDER= DJANGO_ADMINS=name1 , name2 DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8888,http://127.0.0.1:8888 DJANGO_CUSTOM_ASSETS_DOMAIN= DJANGO_DEBUG=true DJANGO_EMAIL_DEBUG=false diff --git a/Dockerfile b/Dockerfile index 725ca4d4..bb22860f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,11 @@ -FROM python:3.10-bullseye -MAINTAINER Open Knowledge Foundation +FROM python:3.12-slim-bookworm +LABEL org.opencontainers.image.authors="Open Knowledge Foundation" WORKDIR /app RUN apt-get update -y && apt-get upgrade -y -RUN curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash +RUN apt-get install -y curl ca-certificates gnupg +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs RUN apt-get install -y nginx RUN apt-get install -y supervisor @@ -27,10 +29,8 @@ COPY requirements.txt . COPY deployment/gunicorn.config.py . RUN pip install -r requirements.txt -RUN . /root/.nvm/nvm.sh && nvm install 16 -RUN . /root/.nvm/nvm.sh && nvm use 16 -ENV PORT 80 +ENV PORT=80 EXPOSE $PORT COPY docker-entrypoint.d /docker-entrypoint.d diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..87d83d94 --- /dev/null +++ b/Makefile @@ -0,0 +1,92 @@ +# Makefile for the okfn.org Django CMS site. +# `make` (no target) shows this help. + +IMAGE := okfn +NAME := okfn +PORT := 8888 + +# Detect if the container is currently running. +RUNNING := $(shell docker ps --filter name=^/$(NAME)$$ --format '{{.Names}}' 2>/dev/null) + +.PHONY: help build run stop restart bash logs test check lint shell migrate css deps-compile clean + +help: + @echo "Targets:" + @echo " make build Build the Docker image ($(IMAGE))." + @echo " make run Start the container detached on port $(PORT)." + @echo " make stop Stop the running container." + @echo " make restart Stop + run." + @echo " make bash Open an interactive shell inside the container." + @echo " make logs Tail the container logs (Ctrl-C to exit)." + @echo " make test Run Django's test suite inside the container." + @echo " make check Run 'manage.py check'." + @echo " make lint Run flake8 against the project code." + @echo " make shell Open the Django shell (REPL)." + @echo " make migrate Apply pending migrations." + @echo " make css Compile Tailwind/PostCSS to static/css/styles.css." + @echo " make deps-compile Recompile requirements.txt + requirements.dev.txt from .in files." + @echo " make clean Stop the container and prune dangling images." + +build: + docker build -t $(IMAGE) . + +run: + docker run -d --rm --name $(NAME) -p $(PORT):80 $(IMAGE) + @echo "Container '$(NAME)' running on http://localhost:$(PORT)" + +stop: + -docker stop $(NAME) + +restart: stop run + +# Use exec if the container is running, otherwise spin up a one-shot. +bash: +ifeq ($(RUNNING),$(NAME)) + docker exec -it $(NAME) bash +else + docker run --rm -it -w /app --entrypoint bash $(IMAGE) +endif + +logs: + docker logs -f $(NAME) + +test: +ifeq ($(RUNNING),$(NAME)) + docker exec $(NAME) python manage.py test +else + docker run --rm --entrypoint python $(IMAGE) manage.py test +endif + +check: +ifeq ($(RUNNING),$(NAME)) + docker exec $(NAME) python manage.py check --settings=foundation.settings +else + docker run --rm --entrypoint python $(IMAGE) manage.py check --settings=foundation.settings +endif + +# flake8 is in requirements.dev.txt, not in the production image, so install it +# on the fly. The .flake8 config sets max-line-length=120 and ignores W503. +lint: +ifeq ($(RUNNING),$(NAME)) + docker exec $(NAME) sh -c "pip install -q flake8 && flake8 --config=/app/.flake8 ." +else + docker run --rm -w /app --entrypoint sh $(IMAGE) -c "pip install -q flake8 && flake8 --config=/app/.flake8 ." +endif + +shell: + docker exec -it $(NAME) python manage.py shell + +migrate: + docker exec $(NAME) python manage.py migrate + +# Run on the host — needs Node 20 + a local node_modules (`npm install`). +css: + npm run build + +# Run on the host — needs `uv` installed locally. +deps-compile: + uv pip compile requirements.in -o requirements.txt + uv pip compile requirements.dev.in -o requirements.dev.txt + +clean: stop + -docker image prune -f diff --git a/README.md b/README.md index e0b454e5..a891847d 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,32 @@ If we need to make global styling or data model changes, then we need to edit th ## Prerequisites and assumptions -You must have the following installed: +The fastest path is **Docker only** — see "Quick start with Make" below. You don't need Python or Node on the host for that flow. -- Python 3.10 -- Node JS 16 +If you want to run things directly on the host (faster dev loop, but more setup): + +- Python 3.12 +- Node 20 +- [`uv`](https://github.com/astral-sh/uv) — used to compile the `requirements.txt` lock files The [/Dockerfile](/Dockerfile) (used for staging/production) and the [requirements file](/requirements.txt) (built with `pip-tools`) in this repo shows you the application dependencies. +## Quick start with Make + +The repo ships a `Makefile` that wraps the most common Docker operations. From a clean checkout: + +```bash +make build # build the image +make run # start the container on http://localhost:8888 +make logs # follow logs (Ctrl-C to detach) +make test # run Django's test suite inside the container +make stop # stop the container +``` + +Run `make` with no arguments to see all available targets (`bash`, `shell`, `lint`, `check`, `migrate`, +`css`, `deps-compile`, `clean`, etc.). + # Development ## Database @@ -59,14 +77,14 @@ DB_PORT=5432 Prepare the app: -Make sure to have the correct node version: +Make sure to have the correct Node version (20): ```bash -nvm install 16 -nvm use 16 +nvm install 20 +nvm use 20 ``` -Create a Python 3.10 local environment (e.g. `python3.10 -m venv ~/okf-website-env`) +Create a Python 3.12 local environment (e.g. `python3.12 -m venv ~/okf-website-env`) ```bash pip install -r requirements.txt @@ -82,11 +100,20 @@ Start the server: python manage.py runserver ``` -Another option is to use Docker. +Another option is to use Docker — wrapped by the Makefile: + +```bash +make build +make run # http://localhost:8888 +make stop +``` + +Or the raw commands: ```bash docker build -t okfn . -docker run -d -p 8888:80 okfn +docker run -d --rm --name okfn -p 8888:80 okfn +docker stop okfn ``` ## File uploads @@ -111,7 +138,7 @@ of how it works: [Installing Tailwind CSS as a PostCSS plugin](https://tailwindc The css build is done by `PostCSS` and the configuration files for it are `tailwind.config.cjs` and `postcss.config.cjs`. -Running `npm run build` will compile our main `styles.css` file and place it in `static/css/styles.css`. (It then will be collected by +Running `npm run build` (or `make css`) will compile our main `styles.css` file and place it in `static/css/styles.css`. (It then will be collected by Django when building the Dockerfile) **Remember:** Tailwind CSS works by scanning all of our HTML files, JavaScript components, and any other templates @@ -162,12 +189,15 @@ For more info read this [doc](/docs/cloud/google-deploy.md). ## Dependency Management -Dependencies are managed with [pip-tools](https://github.com/jazzband/pip-tools). -Add new packages to `requirements.in` / `requirements.dev.in` -and compile `requirements.txt` / `requirements.dev.txt` with -`uv pip compile requirements.in -o requirements.txt` (previously `pip-compile`). +Dependencies are managed with [`uv`](https://github.com/astral-sh/uv) (formerly +[pip-tools](https://github.com/jazzband/pip-tools)). Add new packages to +`requirements.in` / `requirements.dev.in` and recompile the lock files: + +```bash +make deps-compile # regenerates both requirements.txt files +``` -You can run `pip list --outdated` to see outdated packages. +You can also run `pip list --outdated` to see outdated packages. ## Changelog diff --git a/foundation/settings.py b/foundation/settings.py index f665e9ee..803cefb9 100644 --- a/foundation/settings.py +++ b/foundation/settings.py @@ -99,6 +99,13 @@ def _parse_email_list(varname): DEFAULT_FROM_EMAIL = 'noreply@%s' % ALLOWED_HOSTS[0] SERVER_EMAIL = 'admin-noreply@%s' % ALLOWED_HOSTS[0] +# Origins (scheme + host[:port]) browsers may POST to. Required since Django 4 +# for any cross-origin POST, including admin login from a non-default port. +# Set via DJANGO_CSRF_TRUSTED_ORIGINS (comma-separated). +CSRF_TRUSTED_ORIGINS = [] +if env.get('DJANGO_CSRF_TRUSTED_ORIGINS'): + CSRF_TRUSTED_ORIGINS = env.get('DJANGO_CSRF_TRUSTED_ORIGINS').split(',') + INSTALLED_APPS = ( # CMS admin theme 'djangocms_admin_style', diff --git a/foundation/tests/test_home.py b/foundation/tests/test_home.py new file mode 100644 index 00000000..5bdc9fa9 --- /dev/null +++ b/foundation/tests/test_home.py @@ -0,0 +1,39 @@ +"""Smoke tests for the home page and a few critical entry points. + +These run against `foundation.tests.urls`, the slim URL conf swapped in by +`foundation/test_settings.py` when `manage.py test` is invoked. That conf +mounts `cms.urls` at the root, so `/` is served by Django CMS just like in +production — but without the surrounding i18n/sitemap/sendemail patterns. + +The test DB is empty, so there are no CMS Page objects. The point of these +tests is to catch regressions in routing, middleware, and template loading, +not to assert specific page content. +""" +from django.test import TestCase + + +class HomePageTests(TestCase): + def test_home_does_not_error(self): + response = self.client.get("/") + self.assertLess( + response.status_code, + 500, + f"GET / returned {response.status_code}; expected < 500", + ) + + def test_home_returns_html_when_successful(self): + response = self.client.get("/") + if response.status_code == 200: + self.assertIn("text/html", response["Content-Type"]) + + +class AdminEntryPointTests(TestCase): + def test_admin_redirects_anonymous_user(self): + response = self.client.get("/admin/") + self.assertEqual(response.status_code, 302) + self.assertIn("/admin/login/", response["Location"]) + + def test_admin_login_page_renders(self): + response = self.client.get("/admin/login/") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "csrfmiddlewaretoken") diff --git a/requirements.in b/requirements.in index 7fa955a6..42c94d9c 100644 --- a/requirements.in +++ b/requirements.in @@ -16,6 +16,11 @@ django-filer==3.4.4 django-markdown-deux==1.0.6 django-mptt==0.18.0 django-pagedown==2.2.1 +# Pinned directly so we get a 4.x release that uses `importlib.metadata` +# instead of the deprecated `pkg_resources`. django-filer pulls it in +# transitively but doesn't pin a specific version, so without this we'd +# resolve to an old 3.x release that breaks on Python 3.12. +django-polymorphic==4.11.2 django-reversion==5.1.0 django-sekizai==4.1.0 django-storages==1.14.6 diff --git a/requirements.txt b/requirements.txt index 7a3c92f5..82aa05de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -75,8 +75,10 @@ django-mptt==0.18.0 # via -r requirements.in django-pagedown==2.2.1 # via -r requirements.in -django-polymorphic==3.1.0 - # via django-filer +django-polymorphic==4.11.2 + # via + # -r requirements.in + # django-filer django-ranged-response==0.2.0 # via django-simple-captcha django-reversion==5.1.0 @@ -215,6 +217,7 @@ typing-extensions==4.15.0 # -r requirements.in # beautifulsoup4 # django-countries + # django-polymorphic urllib3==2.6.3 # via # -r requirements.in