From 58ed559aea0ed8770b7148a6675edf176e0e40b8 Mon Sep 17 00:00:00 2001 From: Stephen Mudie Date: Thu, 10 Oct 2024 23:39:09 +1100 Subject: [PATCH 01/12] QR code PDF formating --- openday_scavenger/api/puzzles/service.py | 17 +++- openday_scavenger/api/qr_codes.py | 77 ++++++++++++++---- openday_scavenger/api/visitors/service.py | 15 +++- .../static/images/qr_codes/key.png | Bin 0 -> 10884 bytes .../static/images/qr_codes/lock.png | Bin 0 -> 9564 bytes pyproject.toml | 1 + uv.lock | 15 ++++ 7 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 openday_scavenger/static/images/qr_codes/key.png create mode 100644 openday_scavenger/static/images/qr_codes/lock.png diff --git a/openday_scavenger/api/puzzles/service.py b/openday_scavenger/api/puzzles/service.py index 9a26b864..d3edea0d 100644 --- a/openday_scavenger/api/puzzles/service.py +++ b/openday_scavenger/api/puzzles/service.py @@ -2,6 +2,8 @@ import random from datetime import datetime, timedelta from io import BytesIO +from pathlib import Path +from sys import modules from typing import Any from fastapi.encoders import jsonable_encoder @@ -452,7 +454,20 @@ def generate_puzzle_qr_code(name: str, as_file_buff: bool = False) -> str | Byte def generate_puzzle_qr_codes_pdf(db_session: Session): puzzles = get_all(db_session, only_active=False) - return generate_qr_codes_pdf([f"{config.BASE_URL}puzzles/{puzzle.name}/" for puzzle in puzzles]) + module = modules["openday_scavenger"] + module_path = module.__file__ + if module_path is not None: + logo_path = Path(module_path).parent / "static/images/qr_codes/lock.png" + else: + logo_path = None + + return generate_qr_codes_pdf( + [f"{config.BASE_URL}puzzles/{puzzle.name}/" for puzzle in puzzles], + logo=logo_path, + title="You Found A Puzzle Lock!", + title_font_size=30, + url_font_size=16, + ) def generate_test_data( diff --git a/openday_scavenger/api/qr_codes.py b/openday_scavenger/api/qr_codes.py index 25085a01..ccb342f6 100644 --- a/openday_scavenger/api/qr_codes.py +++ b/openday_scavenger/api/qr_codes.py @@ -1,12 +1,16 @@ from io import BytesIO +from pathlib import Path +from PIL import Image from reportlab.lib.pagesizes import A4 from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas from segno import make_qr -def generate_qr_code(url: str, as_file_buff: bool = False) -> str | BytesIO: +def generate_qr_code( + url: str, as_file_buff: bool = False, logo: Path | None = None +) -> str | BytesIO: """Generates a QR code for the provided URL. Args: @@ -21,9 +25,27 @@ def generate_qr_code(url: str, as_file_buff: bool = False) -> str | BytesIO: """ _qr = make_qr(f"{url}", error="H") + if logo is not None: + qr_image = _qr.to_pil() + qr_image = qr_image.convert("RGB") + logo_image = Image.open(logo) + qr_image = qr_image.resize((500, 500), Image.NEAREST) + qr_width, qr_height = qr_image.size + logo_width, logo_height = logo_image.size + max_logo_size = min(qr_width // 5, qr_height // 5) + ratio = min(max_logo_size / logo_width, max_logo_size / logo_height) + logo_image = logo_image.resize((int(logo_width * ratio), int(logo_height * ratio))) + + # Calculate the center position for the logo + logo_x = (qr_width - logo_image.width) // 2 + logo_y = (qr_height - logo_image.height) // 2 + + # Paste the logo onto the QR code image + qr_image.paste(logo_image, (logo_x, logo_y)) + if as_file_buff: buff = BytesIO() - _qr.save(buff, kind="png") + qr_image.save(buff, format="png") buff.seek(0) qr = buff else: @@ -32,7 +54,15 @@ def generate_qr_code(url: str, as_file_buff: bool = False) -> str | BytesIO: return qr -def generate_qr_codes_pdf(entries: list[str]) -> BytesIO: +def generate_qr_codes_pdf( + entries: list[str], + title: str = "blah", + title_font_size: int = 14, + url_font_size: int = 10, + columns: int = 1, + rows: int = 1, + logo: Path | None = None, +) -> BytesIO: """Generates a PDF document containing QR codes for each URL in the provided list. Args: @@ -47,30 +77,47 @@ def generate_qr_codes_pdf(entries: list[str]) -> BytesIO: c = canvas.Canvas(pdf_io, pagesize=A4) width, height = A4 - # Calculate the position to center the QR code - qr_size = 400 # Size of the QR code - x = (width - qr_size) / 2 - y = (height - qr_size) / 2 + x_margin = 50 / columns + y_margin = 100 + + qr_size = 500 / (rows) + + for i, entry in enumerate(entries): + col_index = i % columns + row_index = 0 if i % (columns * rows) < 2 else 1 + + x = x_margin + col_index * (qr_size + x_margin) + y = height - (row_index + 1) * (qr_size + y_margin) + + # Set the font size for the Title text + c.setFillColorRGB(0, 0.46, 0.75) + c.setFont("Helvetica-Bold", title_font_size) + + # Calculate the position to center the text + text_width = c.stringWidth(f"{title}", "Helvetica-Bold", title_font_size) + text_x = x + (qr_size - text_width) / 2 + + # Add the Title text above the QR code + c.drawString(text_x, y + 510 / rows, f"{title}") - for entry in entries: # Draw the QR code image from BytesIO - qr_code = generate_qr_code(entry, as_file_buff=True) + qr_code = generate_qr_code(entry, as_file_buff=True, logo=logo) qr_image = ImageReader(qr_code) c.drawImage(qr_image, x, y, width=qr_size, height=qr_size) # Set the font size for the URL text - font_size = 24 - c.setFont("Helvetica", font_size) + c.setFont("Helvetica", url_font_size) # Calculate the position to center the text - text_width = c.stringWidth(f"{entry}", "Helvetica", font_size) - text_x = (width - text_width) / 2 + text_width = c.stringWidth(f"{entry}", "Helvetica", url_font_size) + text_x = x + (qr_size - text_width) / 2 # Add the URL text below the QR code - c.drawString(text_x, y - 30, f"{entry}") + c.drawString(text_x, y, f"{entry}") # Create a new page for the next QR code - c.showPage() + if (i + 1) % (columns * rows) == 0: + c.showPage() c.save() pdf_io.seek(0) diff --git a/openday_scavenger/api/visitors/service.py b/openday_scavenger/api/visitors/service.py index 2b14d460..1c07fd29 100644 --- a/openday_scavenger/api/visitors/service.py +++ b/openday_scavenger/api/visitors/service.py @@ -1,6 +1,8 @@ import json from datetime import datetime from io import BytesIO +from pathlib import Path +from sys import modules from typing import Any from uuid import uuid4 @@ -222,8 +224,19 @@ def generate_visitor_qr_code(uid: str, as_file_buff: bool = False) -> str | Byte def generate_visitor_qr_codes_pdf(db_session: Session): visitors = get_visitor_pool(db_session) + module = modules["openday_scavenger"] + module_path = module.__file__ + if module_path is not None: + logo_path = Path(module_path).parent / "static/images/qr_codes/key.png" + else: + logo_path = None + return generate_qr_codes_pdf( - [f"{config.BASE_URL}register/{visitor.uid}" for visitor in visitors] + [f"{config.BASE_URL}register/{visitor.uid}" for visitor in visitors], + logo=logo_path, + rows=2, + columns=2, + title="Your Personal Adventure Key!", ) diff --git a/openday_scavenger/static/images/qr_codes/key.png b/openday_scavenger/static/images/qr_codes/key.png new file mode 100644 index 0000000000000000000000000000000000000000..1c25a97c7e9ad4608c3b35140fab65927288af1b GIT binary patch literal 10884 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 V-^+8cJo$GYQe8)_K=szW{{>bHob3Pr literal 0 HcmV?d00001 diff --git a/openday_scavenger/static/images/qr_codes/lock.png b/openday_scavenger/static/images/qr_codes/lock.png new file mode 100644 index 0000000000000000000000000000000000000000..a9dfb3352ccd39a9ccac0b55aaacf654854c6977 GIT binary patch literal 9564 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index daba524e..2a8ffb83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "python-dotenv>=1.0.1", "plotly>=5.24.1", "pandas>=2.2.3", + "qrcode-artistic>=3.0.2", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 14313f26..6989ba53 100644 --- a/uv.lock +++ b/uv.lock @@ -411,6 +411,7 @@ dependencies = [ { name = "plotly" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, + { name = "qrcode-artistic" }, { name = "reportlab" }, { name = "segno" }, { name = "sqlalchemy" }, @@ -445,6 +446,7 @@ requires-dist = [ { name = "psycopg2", marker = "extra == 'postgres'", specifier = ">=2.9.9" }, { name = "pydantic-settings", specifier = ">=2.4.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, + { name = "qrcode-artistic", specifier = ">=3.0.2" }, { name = "reportlab", specifier = ">=4.2.2" }, { name = "segno", specifier = ">=1.6.1" }, { name = "sqlalchemy", specifier = ">=2.0.32" }, @@ -748,6 +750,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "qrcode-artistic" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "segno" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/cf/5fff014b4ba48c7e985343bc59827e134a11bae71227c15a47bee3b999aa/qrcode-artistic-3.0.2.tar.gz", hash = "sha256:eb71f12673c89f638cf7252554a74ecbe4a22f1ce15920caa53da75a48c181f4", size = 9336 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/62/81f17a375319b3d0242a3b93dd2d30ce16e5592544e1762f8c2e7514c9d2/qrcode_artistic-3.0.2-py3-none-any.whl", hash = "sha256:a2aa751a7f0220767f70842cceec206b21f9207478b9612d468273a1f46429ae", size = 7636 }, +] + [[package]] name = "reportlab" version = "4.2.2" From cf75fec5ad16810489531f7fb79742b5e02dcb7b Mon Sep 17 00:00:00 2001 From: Stephen Mudie Date: Fri, 11 Oct 2024 09:37:00 +1100 Subject: [PATCH 02/12] Docstrings and defensive code for qr code logo. --- openday_scavenger/api/puzzles/service.py | 2 ++ openday_scavenger/api/qr_codes.py | 43 +++++++++++++++--------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/openday_scavenger/api/puzzles/service.py b/openday_scavenger/api/puzzles/service.py index d3edea0d..aef5b76d 100644 --- a/openday_scavenger/api/puzzles/service.py +++ b/openday_scavenger/api/puzzles/service.py @@ -458,6 +458,8 @@ def generate_puzzle_qr_codes_pdf(db_session: Session): module_path = module.__file__ if module_path is not None: logo_path = Path(module_path).parent / "static/images/qr_codes/lock.png" + if not logo_path.exists(): + logo_path = None else: logo_path = None diff --git a/openday_scavenger/api/qr_codes.py b/openday_scavenger/api/qr_codes.py index ccb342f6..79b855fa 100644 --- a/openday_scavenger/api/qr_codes.py +++ b/openday_scavenger/api/qr_codes.py @@ -1,3 +1,4 @@ +import logging from io import BytesIO from pathlib import Path @@ -7,6 +8,8 @@ from reportlab.pdfgen import canvas from segno import make_qr +logger = logging.getLogger(__name__) + def generate_qr_code( url: str, as_file_buff: bool = False, logo: Path | None = None @@ -18,30 +21,40 @@ def generate_qr_code( as_file_buff (bool, optional): If True, returns the QR code as a BytesIO object containing the PNG image data. Defaults to False, which returns the QR code as an SVG data URI string. + logo (Path, optional): Pathlib Path to an image to use as a logo in middle of QR code. Must be + compatible with `PIL.Image.open`. Logo is only created with `as_file_buff=True`. + If the image is incompatible a warning is logged and QR code is created without the logo. + Currently the ratio of logo to QR size is fixed at 1/25th. Returns: str | BytesIO: The QR code representation. If `as_file_buff` is True, a BytesIO object; otherwise, an SVG data URI string. """ _qr = make_qr(f"{url}", error="H") + qr_image = _qr.to_pil() # type: ignore if logo is not None: - qr_image = _qr.to_pil() qr_image = qr_image.convert("RGB") - logo_image = Image.open(logo) - qr_image = qr_image.resize((500, 500), Image.NEAREST) - qr_width, qr_height = qr_image.size - logo_width, logo_height = logo_image.size - max_logo_size = min(qr_width // 5, qr_height // 5) - ratio = min(max_logo_size / logo_width, max_logo_size / logo_height) - logo_image = logo_image.resize((int(logo_width * ratio), int(logo_height * ratio))) - - # Calculate the center position for the logo - logo_x = (qr_width - logo_image.width) // 2 - logo_y = (qr_height - logo_image.height) // 2 - - # Paste the logo onto the QR code image - qr_image.paste(logo_image, (logo_x, logo_y)) + try: + logo_image = Image.open(logo) + qr_image = qr_image.resize((500, 500), Image.NEAREST) + qr_width, qr_height = qr_image.size + logo_width, logo_height = logo_image.size + max_logo_size = min(qr_width // 5, qr_height // 5) + ratio = min(max_logo_size / logo_width, max_logo_size / logo_height) + logo_image = logo_image.resize((int(logo_width * ratio), int(logo_height * ratio))) + + # Calculate the center position for the logo + logo_x = (qr_width - logo_image.width) // 2 + logo_y = (qr_height - logo_image.height) // 2 + + # Paste the logo onto the QR code image + qr_image.paste(logo_image, (logo_x, logo_y)) + + except Exception as e: + logger.error( + f"Opening and merging Logo {logo} with the qr code raised an exception {e}" + ) if as_file_buff: buff = BytesIO() From 3a6ae506da7064c5e9d3d7c5400c037e770f4677 Mon Sep 17 00:00:00 2001 From: Stephen Mudie Date: Fri, 11 Oct 2024 10:43:31 +1100 Subject: [PATCH 03/12] Set default number of UID in form to 10. --- openday_scavenger/views/admin/templates/visitors_pool.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openday_scavenger/views/admin/templates/visitors_pool.html b/openday_scavenger/views/admin/templates/visitors_pool.html index b9f4431b..24435dde 100644 --- a/openday_scavenger/views/admin/templates/visitors_pool.html +++ b/openday_scavenger/views/admin/templates/visitors_pool.html @@ -11,7 +11,7 @@
Visitor Pool
- +
+
+ + + + + + \ No newline at end of file diff --git a/openday_scavenger/puzzles/controls_game/views.py b/openday_scavenger/puzzles/controls_game/views.py new file mode 100644 index 00000000..75423b13 --- /dev/null +++ b/openday_scavenger/puzzles/controls_game/views.py @@ -0,0 +1,46 @@ +from pathlib import Path +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import FileResponse +from fastapi.templating import Jinja2Templates + +from openday_scavenger.api.visitors.dependencies import get_auth_visitor +from openday_scavenger.api.visitors.schemas import VisitorAuth + +PUZZLE_NAME = "controls_game" + +router = APIRouter() +templates = Jinja2Templates(directory=Path(__file__).resolve().parent / "static") + + +@router.get("/static/{path:path}") +async def get_static_files( + path: Path, +): + """Serve files from a local static folder""" + # This route is required as the current version of FastAPI doesn't allow + # the mounting of folders on APIRouter. This is an open issue: + # https://github.com/fastapi/fastapi/discussions/9070 + parent_path = Path(__file__).resolve().parent / "static" + file_path = parent_path / path + + # Make sure the requested path is a file and relative to this path + if file_path.is_relative_to(parent_path) and file_path.is_file(): + return FileResponse(file_path) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Requested file does not exist" + ) + + +@router.get("/") +async def index( + request: Request, + visitor: Annotated[VisitorAuth, Depends(get_auth_visitor)], +): + return templates.TemplateResponse( + request=request, + name="index.html", + context={"puzzle": PUZZLE_NAME}, + ) diff --git a/openday_scavenger/static/css/openday.css b/openday_scavenger/static/css/openday.css index b57e3dff..fc0596e2 100644 --- a/openday_scavenger/static/css/openday.css +++ b/openday_scavenger/static/css/openday.css @@ -44,7 +44,7 @@ html { .type-h1 { color: #0076c0; - font-size: calc(2rem * 0.9vw); + font-size: calc(2rem + 0.9vw); font-weight: bold; line-height: 2.5rem; } From 5c3436369650d7575449e2bde2907a0ea4c600f4 Mon Sep 17 00:00:00 2001 From: Sean Dawson Date: Thu, 10 Oct 2024 17:13:22 +1100 Subject: [PATCH 07/12] allow upper and lower case --- .../puzzles/shuffleanagram/templates/index.html | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/openday_scavenger/puzzles/shuffleanagram/templates/index.html b/openday_scavenger/puzzles/shuffleanagram/templates/index.html index b6a8ad92..0ba2e32d 100644 --- a/openday_scavenger/puzzles/shuffleanagram/templates/index.html +++ b/openday_scavenger/puzzles/shuffleanagram/templates/index.html @@ -26,11 +26,12 @@

Anagram Puzzle!

-
+
-
- +
+ +
- +

From ab850c227bf39171dc720a45ea70e46e41a8f873 Mon Sep 17 00:00:00 2001 From: Sean Dawson Date: Fri, 11 Oct 2024 12:01:21 +1100 Subject: [PATCH 10/12] don't crash if key doesn't exist --- openday_scavenger/api/puzzles/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openday_scavenger/api/puzzles/service.py b/openday_scavenger/api/puzzles/service.py index f367c910..046edea3 100644 --- a/openday_scavenger/api/puzzles/service.py +++ b/openday_scavenger/api/puzzles/service.py @@ -546,7 +546,7 @@ def upsert_puzzle_json(db_session: Session, puzzle_json: PuzzleJson): existing_puzzles_by_id = {item.id: item for item in get_all(db_session)} for puzzle in puzzle_json.puzzles: - existing_puzzle = "id" in puzzle and existing_puzzles_by_id[puzzle["id"]] + existing_puzzle = "id" in puzzle and existing_puzzles_by_id.get(puzzle["id"]) if existing_puzzle: _ = update(db_session, existing_puzzle.name, PuzzleUpdate(**puzzle)) else: From 7f71f91c9ad31045ebd07a97abd6cbe8344a403d Mon Sep 17 00:00:00 2001 From: Sean Dawson Date: Fri, 11 Oct 2024 10:30:12 +1100 Subject: [PATCH 11/12] add basic linting to HTML templates --- .github/workflows/pytest.yaml | 3 + .pre-commit-config.yaml | 8 +- .../static/index.html | 6 +- .../puzzles/demo/static/index.html | 2 + .../puzzles/element/templates/index.html | 3 +- .../puzzles/element/templates/popup.html | 2 +- .../puzzles/finder/templates/hint_words.html | 2 +- .../puzzles/finder/templates/index.html | 9 +- .../puzzles/fourbyfour/templates/index.html | 1 - .../shuffleanagram/templates/index.html | 1 + .../puzzles/xray_filters/static/index.html | 9 +- openday_scavenger/static/html/layout.html | 1 + .../views/admin/templates/layout.html | 7 +- .../views/admin/templates/map.html | 3 +- .../views/admin/templates/puzzles.html | 2 - .../views/admin/templates/qr.html | 2 +- .../views/admin/templates/visitors_pool.html | 1 - .../views/game/static/index.html | 3 +- .../views/game/static/layout.html | 1 + openday_scavenger/views/game/static/map.html | 4 +- .../views/game/static/puzzle_completed.html | 2 +- pyproject.toml | 9 ++ uv.lock | 137 ++++++++++++++++++ 23 files changed, 187 insertions(+), 31 deletions(-) diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index e0c9a948..1359a7f6 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -28,6 +28,9 @@ jobs: - name: Ruff Format run: uvx ruff format + - name: HTML Template Lint (djlint) + run: uv run djlint openday_scavenger/ + - name: Run tests run: uv run pytest tests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b0b10292..98215a88 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,4 +7,10 @@ repos: - id: ruff args: [ --fix ] # Run the formatter. - - id: ruff-format \ No newline at end of file + - id: ruff-format +- repo: https://github.com/djlint/djLint + rev: v1.35.2 + hooks: + - id: djlint-jinja + files: "\\.html" + types_or: ["html"] \ No newline at end of file diff --git a/openday_scavenger/puzzles/ads_question_answer_matchup/static/index.html b/openday_scavenger/puzzles/ads_question_answer_matchup/static/index.html index 5d246249..c24b07d9 100644 --- a/openday_scavenger/puzzles/ads_question_answer_matchup/static/index.html +++ b/openday_scavenger/puzzles/ads_question_answer_matchup/static/index.html @@ -1,6 +1,8 @@ - + + + ADS Question Answer Matchup Puzzle @@ -50,7 +52,7 @@

ADS Question Answer Matchup Puzzle

150
300
- + image indicating that items should be dragged with finger
diff --git a/openday_scavenger/puzzles/demo/static/index.html b/openday_scavenger/puzzles/demo/static/index.html index 8f7b73c1..915cbd01 100644 --- a/openday_scavenger/puzzles/demo/static/index.html +++ b/openday_scavenger/puzzles/demo/static/index.html @@ -2,6 +2,8 @@ + Demo Puzzle + diff --git a/openday_scavenger/puzzles/element/templates/index.html b/openday_scavenger/puzzles/element/templates/index.html index 40d3a645..73481e6d 100644 --- a/openday_scavenger/puzzles/element/templates/index.html +++ b/openday_scavenger/puzzles/element/templates/index.html @@ -1,5 +1,6 @@ {% set title = "🧈 " + location + " Element Quiz 🪨" %} - + + {{ title }} diff --git a/openday_scavenger/puzzles/element/templates/popup.html b/openday_scavenger/puzzles/element/templates/popup.html index 454ce23c..22529d00 100644 --- a/openday_scavenger/puzzles/element/templates/popup.html +++ b/openday_scavenger/puzzles/element/templates/popup.html @@ -1,6 +1,6 @@