From 8750121e8a703d53c892021b14a47af3f68692c1 Mon Sep 17 00:00:00 2001 From: Ebin Bellini Date: Fri, 17 Jul 2026 21:29:17 +0200 Subject: [PATCH] Migrate team logos --- backend/backend.Dockerfile | 2 + .../commands/setup_legacy_schema.py | 168 ++++++++++++++++++ frontend/apply/src/app/utils/imageUrl.ts | 20 +-- migration/migrate.sql | 24 ++- 4 files changed, 195 insertions(+), 19 deletions(-) diff --git a/backend/backend.Dockerfile b/backend/backend.Dockerfile index bf90368..4d03603 100644 --- a/backend/backend.Dockerfile +++ b/backend/backend.Dockerfile @@ -7,6 +7,8 @@ WORKDIR /app # Needed to run manage.py setup_legacy_schema.py RUN apt-get update && apt-get install -y \ postgresql-client \ + openssh-client \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* # copy dependencies file and install dependencies, requirements.txt is taken from old project diff --git a/backend/backend/management/commands/setup_legacy_schema.py b/backend/backend/management/commands/setup_legacy_schema.py index cc4caba..f431b2e 100644 --- a/backend/backend/management/commands/setup_legacy_schema.py +++ b/backend/backend/management/commands/setup_legacy_schema.py @@ -3,6 +3,7 @@ import os import subprocess import shlex +import sys from pathlib import Path from django.conf import settings @@ -46,12 +47,33 @@ def add_arguments(self, parser): action="store_true", help="Drop and recreate the target database before running", ) + parser.add_argument( + "--moore-connection", + nargs="?", + const="", + default=None, + help="SSH connection string (user@host) for pulling team logos from " + "the old Wagtail server. If given without a value you will be " + "prompted interactively. Omit to skip logo pulling entirely.\n" + "If the moore server requires SSH key authentication, mount your " + "~/.ssh directory:\n" + ' docker compose run --rm -v "$HOME/.ssh:/root/.ssh:ro" ' + "backend python manage.py setup_legacy_schema --moore-connection …", + ) + parser.add_argument( + "--moore-remote-dir", + default="/var/www/moore/src/media/images", + help="Remote directory on the moore server containing Wagtail original " + "images (default: /var/www/moore/src/media/images).", + ) def handle(self, *args, **options): dump_file = Path(options["dump_file"]) scratch_db = options["scratch_db"] target_db = options["target_db"] recreate_target = options["recreate_target"] + moore_connection: str | None = options["moore_connection"] + moore_remote_dir: str = options["moore_remote_dir"] if not dump_file.exists(): raise CommandError(f"Dump file not found: {dump_file}") @@ -136,6 +158,7 @@ def handle(self, *args, **options): "set -o pipefail && " f"pg_dump -d {shlex.quote(scratch_db)} --section=pre-data --section=data --no-owner " "-t 'legacy.involvement_*' -t 'legacy.members_*' -t legacy.auth_group " + "-t 'legacy.wagtailimages_image' " "| sed '/transaction_timeout/d' " f"| psql -d {shlex.quote(target_db)} -v ON_ERROR_STOP=1" ) @@ -199,6 +222,16 @@ def handle(self, *args, **options): ], ) + # ------------------------------------------------------------------ + # Optional: pull team logo images from the old moore server + # ------------------------------------------------------------------ + if moore_connection is not None: + if moore_connection == "": + moore_connection = self._prompt( + "SSH connection string (user@host) for pulling team logos" + ) + self._pull_team_logos(moore_connection, moore_remote_dir) + finally: self.stdout.write(self.style.NOTICE("Cleaning up scratch DB...")) self._terminate_connections(env, scratch_db) @@ -236,3 +269,138 @@ def _run( raise CommandError( f"Command failed with exit code {result.returncode}: {' '.join(command)}" ) + + # ------------------------------------------------------------------ + # Team logo pulling helpers + # ------------------------------------------------------------------ + + @staticmethod + def _wagtail_original_path(filename: str) -> str: + """Insert ``.original`` before the file extension. + + Wagtail stores the raw upload as ``foo.original.png`` while the + ``file`` column in ``wagtailimages_image`` points to ``foo.png``. + """ + filename = filename.replace("160x160", "") # Remove size suffix + stem, dot, ext = filename.rpartition(".") + return f"{stem}.original{dot}{ext}" + + def _pull_team_logos(self, connection: str, remote_dir: str) -> None: + """SCP team logo originals from moore, then crop a left-aligned square + and resize to 160x160 px (retina-ready for 80x80 display) with ffmpeg. + + The database points to ``team_logos/foo.png`` (cropped square). + The original is kept as ``team_logos/foo.original.png`` for reference. + + .. note:: + + If the moore server requires SSH key authentication, mount your + ``~/.ssh`` directory into the container when running this command:: + + docker compose -f docker-compose.yml \\ + run --rm -v "$HOME/.ssh:/root/.ssh:ro" \\ + backend python manage.py setup_legacy_schema --moore-connection … + """ + from backend.models import Team + + SQUARE_SIZE = 160 # 2× for retina, displayed at 80×80 + + self.stdout.write(self.style.NOTICE("Pulling team logos from moore server...")) + + teams = Team.objects.exclude(logo="").values_list("logo", flat=True) + logo_paths = sorted(set(teams)) + + if not logo_paths: + self.stdout.write(self.style.WARNING("No team logos found in the database.")) + return + + self.stdout.write(f"Found {len(logo_paths)} unique logo filenames.") + + dest_dir = Path(settings.MEDIA_ROOT) / "team_logos" + dest_dir.mkdir(parents=True, exist_ok=True) + + succeeded = 0 + failed = 0 + auth_failures = 0 + + for logo_path in logo_paths: + # logo_path is e.g. "team_logos/BAS.png" + filename = Path(logo_path).name # "BAS.png" + original_name = self._wagtail_original_path(filename) # "BAS.original.png" + + remote = f"{connection}:{remote_dir}/{original_name}" + original_dest = dest_dir / original_name # kept for reference + # Insert size suffix before extension: BAS.png → BAS160x160.png + square_dest = dest_dir / filename # cropped version → DB + + self.stdout.write(f" {remote}", ending="") + + try: + # 1. Pull the Wagtail original from moore + result = subprocess.run( + ["scp", "-F", "/dev/null", "-o", "ConnectTimeout=10", + "-o", "StrictHostKeyChecking=no", remote, str(original_dest)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + stderr = result.stderr.strip() + if "Permission denied" in stderr: + auth_failures += 1 + if auth_failures == 1: + # Only print the help message once + self.stdout.write(self.style.ERROR(" PERMISSION DENIED")) + self.stderr.write( + "\n" + + self.style.ERROR( + "SSH key authentication failed. " + "Mount your ~/.ssh directory and try again:\n" + " docker compose -f docker-compose.yml \\\n" + " run --rm -v \"$HOME/.ssh:/root/.ssh:ro\" \\\n" + " backend python manage.py setup_legacy_schema " + "--moore-connection …\n" + ) + ) + continue + raise subprocess.CalledProcessError( + result.returncode, ["scp", "...", remote, str(original_dest)], + output=result.stdout, stderr=result.stderr, + ) + + # 2. Crop largest possible square from the left edge, resize + # The \\, escapes commas inside min() for ffmpeg's filter parser. + subprocess.run( + [ + "ffmpeg", + "-y", "-v", "error", + "-i", str(original_dest), + "-vf", + f"crop=min(in_w\\,in_h):min(in_w\\,in_h):0:0,scale={SQUARE_SIZE}:{SQUARE_SIZE}", + str(square_dest), + ], + check=True, + capture_output=True, + text=True, + ) + + self.stdout.write(self.style.SUCCESS(" OK")) + succeeded += 1 + except subprocess.CalledProcessError as exc: + self.stdout.write(self.style.ERROR(" FAILED")) + err = exc.stderr.strip() if exc.stderr else str(exc) + if err: + self.stderr.write(f" {err}") + failed += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Team logo pull done: {succeeded} pulled & cropped, {failed} failed." + ) + ) + + @staticmethod + def _prompt(text: str) -> str: + """Print a prompt to stderr and return the user's answer.""" + sys.stderr.write(f"{text}: ") + sys.stderr.flush() + return sys.stdin.readline().rstrip("\n") diff --git a/frontend/apply/src/app/utils/imageUrl.ts b/frontend/apply/src/app/utils/imageUrl.ts index 33ab82b..238c6b3 100644 --- a/frontend/apply/src/app/utils/imageUrl.ts +++ b/frontend/apply/src/app/utils/imageUrl.ts @@ -3,10 +3,10 @@ * Next.js Image Optimization fetches the source server-side, so we must * prepend an absolute base URL that the Next.js server can reach. * - * Production → set NEXT_PUBLIC_API_URL to a full absolute URL - * (e.g. "https://applytest.utn.se/api"), or the - * current origin is used when running on the client. - * Docker Compose → defaults to "http://backend:8000" + * Production set NEXT_PUBLIC_API_URL to a full absolute URL + * (e.g. "https://apply.utn.se/api"), or the + * current origin is used on the client. + * Docker Compose defaults to "http://backend:8000" */ export function getImageUrl(url: string): string { if (url.startsWith("http://") || url.startsWith("https://")) { @@ -20,11 +20,9 @@ export function getImageUrl(url: string): string { return `${apiUrl.replace(/\/api\/?$/, "")}${url}`; } - // 2. Server-side rendering (Docker Compose) - if (typeof window === "undefined") { - return `http://backend:8000${url}`; - } - - // 3. Client-side production – nginx proxies /media/ to Django - return `${window.location.origin}${url}`; + // 2. Docker Compose dev – always use the internal Docker hostname. + // next/image fetches images on the server, so the browser never + // needs to resolve "backend". Both SSR and client must return the + // same URL to avoid a hydration mismatch. + return `http://backend:8000${url}`; } diff --git a/migration/migrate.sql b/migration/migrate.sql index d04592a..a757314 100644 --- a/migration/migrate.sql +++ b/migration/migrate.sql @@ -91,16 +91,24 @@ FROM legacy.members_section_studies; -- --------------------------------------------------------------------------- -- 3. Team (legacy.involvement_team -> backend_team) --- NOTE: old logo (wagtail image FK) has no destination -> logo = ''. +-- Logo: JOINs legacy.wagtailimages_image via logo_id, extracts the +-- filename from the stored path (e.g. original_images/foo.png → foo.png), +-- prefixes "team_logos/" and inserts the square size before the extension +-- so the database points to e.g. "team_logos/BAS160x160.png". +-- After migration, files must be placed in MEDIA_ROOT/team_logos/. -- --------------------------------------------------------------------------- INSERT INTO backend_team (id, name_en, name_sv, logo, desc_en, desc_sv) -SELECT id, - coalesce(name_en, ''), - coalesce(name_sv, ''), - '', - coalesce(description_en, ''), - coalesce(description_sv, '') -FROM legacy.involvement_team; +SELECT t.id, + coalesce(t.name_en, ''), + coalesce(t.name_sv, ''), + CASE WHEN i.file IS NOT NULL + THEN 'team_logos/' || replace(split_part(i.file, '/', 2), '.', '160x160.') + ELSE '' + END, + coalesce(t.description_en, ''), + coalesce(t.description_sv, '') +FROM legacy.involvement_team t +LEFT JOIN legacy.wagtailimages_image i ON t.logo_id = i.id; -- --------------------------------------------------------------------------- -- 4. Role (legacy.involvement_role -> backend_role)