Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/backend.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions backend/backend/management/commands/setup_legacy_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import subprocess
import shlex
import sys
from pathlib import Path

from django.conf import settings
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
20 changes: 9 additions & 11 deletions frontend/apply/src/app/utils/imageUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://")) {
Expand All @@ -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}`;
}
24 changes: 16 additions & 8 deletions migration/migrate.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading