18 Commits

Author SHA1 Message Date
s3rius 042e8b8581 Added random board sharing.
/ docker_build (push) Successful in 1m12s
/ helm_deploy (push) Successful in 23s
Testing zangramru / lint (push) Successful in 29s
Testing zangramru / pytest (push) Successful in 18s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 18:33:37 +02:00
s3rius 9069f751c3 Some small enhancements additional words.
Testing zangramru / lint (push) Has been cancelled
Testing zangramru / pytest (push) Has been cancelled
/ docker_build (push) Successful in 5m49s
/ helm_deploy (push) Successful in 36s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 15:20:25 +02:00
s3rius 472956c76d lint fixes.
Testing zangramru / lint (push) Successful in 4m16s
Testing zangramru / pytest (push) Failing after 3m22s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 13:42:17 +02:00
s3rius 27c275bccb Possible tests fix.
Testing zangramru / lint (push) Failing after 48s
Testing zangramru / pytest (push) Failing after 43s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 13:23:45 +02:00
s3rius f89a031997 Fixed some backend issues.
Testing zangramru / lint (push) Failing after 34s
Testing zangramru / pytest (push) Failing after 47s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 13:05:20 +02:00
s3rius 8033b498f9 Merge pull request 'Increased contrast of letter connections for readability.' (#2) from task_frontend_relations_fix into master
Testing zangramru / lint (push) Successful in 32s
Testing zangramru / pytest (push) Failing after 22s
Reviewed-on: #2
Reviewed-by: s3rius <s3rius@noreply.gitea.le-memese.com>
2026-07-02 10:57:08 +00:00
shared_mutex 129a1ea5f7 Increased contrast of letter connections for readability.
Testing zangramru / lint (push) Successful in 33s
Testing zangramru / pytest (push) Has been cancelled
Bumped the board connection line color in both themes so the links
between letters read more clearly:
- light: #e2dbc7 -> #c7bb95
- dark:  #33333f -> #52525f

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:55:32 +03:00
s3rius c5dc537844 Merge pull request 'Redesigned frontend: neo-brutalist UI with swipe, keyboard input and animations.' (#1) from task_better_frontend into master
/ docker_build (push) Successful in 1m38s
/ helm_deploy (push) Successful in 37s
Testing zangramru / lint (push) Successful in 31s
Testing zangramru / pytest (push) Failing after 22s
Reviewed-on: #1
Reviewed-by: s3rius <s3rius@noreply.gitea.le-memese.com>
2026-07-02 10:30:50 +00:00
shared_mutex 2062083f80 Redesigned frontend: neo-brutalist UI with swipe, keyboard input and animations.
Testing zangramru / lint (push) Successful in 31s
Testing zangramru / pytest (push) Failing after 49s
Single-file rewrite of static/index.html preserving the puzzle API contract,
board coordinates and localStorage format. Adds swipe-to-select, keyboard
tracing (type/Backspace/Enter/Esc), juicy tile animations + confetti, and a
light/dark theme toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:28:49 +03:00
s3rius 55b4089e43 Updated dictionary according to 10_000 most fequent words form НКРЯ.
/ docker_build (push) Successful in 1m33s
/ helm_deploy (push) Successful in 35s
Testing zangramru / lint (push) Successful in 32s
Testing zangramru / pytest (push) Failing after 51s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 11:15:04 +02:00
s3rius 5355159e49 Updated dictionary.
Testing zangramru / lint (push) Successful in 30s
Testing zangramru / pytest (push) Failing after 51s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 04:05:25 +02:00
s3rius a34d63aebe Fixed finding words.
/ docker_build (push) Successful in 1m31s
/ helm_deploy (push) Successful in 26s
Testing zangramru / lint (push) Successful in 32s
Testing zangramru / pytest (push) Failing after 22s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 03:42:44 +02:00
s3rius 039d206ba0 Added og meta tags.
/ docker_build (push) Successful in 1m24s
/ helm_deploy (push) Successful in 35s
Testing zangramru / lint (push) Successful in 32s
Testing zangramru / pytest (push) Failing after 46s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 03:18:53 +02:00
s3rius 12b0eb08b4 added support for daily puzzles.
/ docker_build (push) Successful in 5m42s
/ helm_deploy (push) Successful in 49s
Testing zangramru / lint (push) Successful in 33s
Testing zangramru / pytest (push) Failing after 22s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-02 02:57:09 +02:00
s3rius f1d2b0f6c8 Made logs always show up.
Testing zangramru / lint (push) Successful in 31s
Testing zangramru / pytest (push) Failing after 49s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-01 14:28:14 +02:00
s3rius da37f240b1 Added logs.
Testing zangramru / lint (push) Successful in 32s
Testing zangramru / pytest (push) Failing after 50s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-01 13:59:18 +02:00
s3rius ef1d94cf68 Fixed pytest.
Testing zangramru / lint (push) Successful in 31s
Testing zangramru / pytest (push) Failing after 49s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-01 13:56:32 +02:00
s3rius 4306676699 Simplified workflows.
Testing zangramru / lint (push) Successful in 31s
Testing zangramru / pytest (push) Failing after 16s
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-01 13:42:32 +02:00
19 changed files with 15578 additions and 18031 deletions
+1
View File
@@ -1 +1,2 @@
**/*.csv filter=lfs diff=lfs merge=lfs -text **/*.csv filter=lfs diff=lfs merge=lfs -text
**/*.png filter=lfs diff=lfs merge=lfs -text
+1
View File
@@ -25,6 +25,7 @@ jobs:
push: true push: true
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64 platforms: linux/amd64
target: prod
tags: | tags: |
gitea.le-memese.com/s3rius/zangramru:latest gitea.le-memese.com/s3rius/zangramru:latest
gitea.le-memese.com/s3rius/zangramru:${{ gitea.ref_name }} gitea.le-memese.com/s3rius/zangramru:${{ gitea.ref_name }}
+7 -20
View File
@@ -4,15 +4,9 @@ on: push
jobs: jobs:
lint: lint:
strategy:
matrix:
cmd:
- ruff-format
- ruff
- mypy
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@@ -22,20 +16,13 @@ jobs:
- name: Install deps - name: Install deps
run: uv sync run: uv sync
- name: Run lint check - name: Run lint check
run: uv run pre-commit run -a ${{ matrix.cmd }} run: uv run pre-commit run -a
pytest: pytest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Create .env
run: touch .env
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Update docker-compose
uses: KengoTODA/actions-setup-docker-compose@v1
with:
version: "2.28.0"
- name: run tests - name: run tests
run: docker-compose run --rm api pytest -vv run: docker compose run --build --rm api pytest -vv
- name: show logs
if: 'always()'
run: docker compose logs
+8
View File
@@ -31,3 +31,11 @@ RUN --mount=from=uv,source=/usr/local/bin/uv,target=/bin/uv \
uv sync --locked --no-dev uv sync --locked --no-dev
CMD ["/usr/local/bin/python", "-m", "zangramru"] CMD ["/usr/local/bin/python", "-m", "zangramru"]
FROM prod AS dev
RUN --mount=from=uv,source=/usr/local/bin/uv,target=/bin/uv \
--mount=type=cache,target=/root/.cache/uv \
uv sync --locked --all-groups
+6 -5
View File
@@ -25,8 +25,8 @@ services:
ZANGRAMRU_DB_USER: zangramru ZANGRAMRU_DB_USER: zangramru
ZANGRAMRU_DB_PASS: zangramru ZANGRAMRU_DB_PASS: zangramru
ZANGRAMRU_DB_BASE: zangramru ZANGRAMRU_DB_BASE: zangramru
volumes: # volumes:
- .:/app/src/ # - .:/app/src/
db: db:
image: postgres:18.3-trixie image: postgres:18.3-trixie
@@ -49,13 +49,14 @@ services:
migrator: migrator:
<<: *main_app <<: *main_app
restart: "no" restart: "no"
command: piccolo migrations forwards all command:
- bash
- -c
- "ls && piccolo migrations forwards all"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
volumes: volumes:
zangramru-db-data: zangramru-db-data:
name: zangramru-db-data name: zangramru-db-data
+4 -2
View File
@@ -10,6 +10,7 @@ from piccolo.table import create_tables, drop_tables
from zangramru.settings import settings from zangramru.settings import settings
from zangramru.web.application import get_app from zangramru.web.application import get_app
from zangramru.web.lifespan import lifespan_setup
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -73,14 +74,15 @@ async def setup_db() -> AsyncGenerator[None, None]:
@pytest.fixture @pytest.fixture
def fastapi_app() -> FastAPI: async def fastapi_app() -> AsyncGenerator[FastAPI, None]:
""" """
Fixture for creating FastAPI app. Fixture for creating FastAPI app.
:return: fastapi app with mocked dependencies. :return: fastapi app with mocked dependencies.
""" """
application = get_app() application = get_app()
return application # noqa: RET504 async with lifespan_setup(application):
yield application
@pytest.fixture @pytest.fixture
-19
View File
@@ -1,19 +0,0 @@
import uuid
from fastapi import FastAPI
from httpx import AsyncClient
from starlette import status
async def test_echo(fastapi_app: FastAPI, client: AsyncClient) -> None:
"""
Tests that echo route works.
:param fastapi_app: current application.
:param client: client for the app.
"""
url = fastapi_app.url_path_for("send_echo_message")
message = uuid.uuid4().hex
response = await client.post(url, json={"message": message})
assert response.status_code == status.HTTP_200_OK
assert response.json()["message"] == message
+20
View File
@@ -0,0 +1,20 @@
from fastapi import FastAPI
from httpx import AsyncClient
from starlette import status
from zangramru.web.api.puzzles.schema import PuzzleOutputDTO
async def test_get_random_puzzle(fastapi_app: FastAPI, client: AsyncClient) -> None:
"""
Tests that echo route works.
:param fastapi_app: current application.
:param client: client for the app.
"""
url = fastapi_app.url_path_for("random_puzzle")
response = await client.get(url)
assert response.status_code == status.HTTP_200_OK
dto = PuzzleOutputDTO.model_validate(response.json())
assert sum(len(row) for row in dto.board) == 16
assert len(dto.solutions) >= 10
+1
View File
@@ -12,6 +12,7 @@ env:
ZANGRAMRU_DB_HOST: cnpg-cluster-rw.cnpg-cluster.svc.cluster.local ZANGRAMRU_DB_HOST: cnpg-cluster-rw.cnpg-cluster.svc.cluster.local
ZANGRAMRU_DB_USER: zangram ZANGRAMRU_DB_USER: zangram
ZANGRAMRU_DB_BASE: zangram ZANGRAMRU_DB_BASE: zangram
ZANGRAMRU_START_DATE: "2026-07-01"
probes: probes:
liveness: liveness:
@@ -0,0 +1,44 @@
from piccolo.apps.migrations.auto.migration_manager import MigrationManager
from piccolo.columns.column_types import Integer
from piccolo.columns.indexes import IndexMethod
ID = "2026-07-01T21:56:16:112157"
VERSION = "1.34.0"
DESCRIPTION = ""
async def forwards():
manager = MigrationManager(
migration_id=ID, app_name="zangramru_db", description=DESCRIPTION
)
manager.drop_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="date",
db_column_name="date",
schema=None,
)
manager.add_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="number",
db_column_name="number",
column_class_name="Integer",
column_class=Integer,
params={
"default": 0,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
return manager
@@ -0,0 +1,26 @@
from piccolo.apps.migrations.auto.migration_manager import MigrationManager
from piccolo.columns.column_types import Integer
ID = "2026-07-02T12:57:47:552419"
VERSION = "1.34.0"
DESCRIPTION = ""
async def forwards():
manager = MigrationManager(
migration_id=ID, app_name="zangramru_db", description=DESCRIPTION
)
manager.alter_column(
table_class_name="Puzzle",
tablename="puzzle",
column_name="number",
db_column_name="number",
params={"unique": True},
old_params={"unique": False},
column_class=Integer,
old_column_class=Integer,
schema=None,
)
return manager
+5 -3
View File
@@ -1,13 +1,15 @@
from piccolo.columns import JSON, UUID, Date, Varchar import uuid
from piccolo.columns import JSON, UUID, Integer, Varchar
from piccolo.table import Table from piccolo.table import Table
class Puzzle(Table): class Puzzle(Table):
"""Table for puzzles.""" """Table for puzzles."""
id = UUID(primary_key=True) id = UUID(primary_key=True, default=uuid.uuid4)
board = Varchar(length=16) board = Varchar(length=16)
solutions = JSON() solutions = JSON()
extra = JSON() extra = JSON()
definitions = JSON() definitions = JSON()
date = Date() number = Integer(unique=True)
+14370 -17463
View File
File diff suppressed because it is too large Load Diff
+60 -34
View File
@@ -1,7 +1,9 @@
import csv import csv
import datetime
import secrets import secrets
from collections import defaultdict, deque from collections import defaultdict, deque
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path from pathlib import Path
from pprint import pformat from pprint import pformat
from typing import Annotated from typing import Annotated
@@ -107,11 +109,11 @@ class PuzzleGenerator:
with dict_path.open("r") as f: with dict_path.open("r") as f:
rows = csv.DictReader(f, delimiter="|") rows = csv.DictReader(f, delimiter="|")
for row in rows: for row in rows:
word = row["word"] word = row["word"].lower().replace("ё", "е") # noqa: RUF001
self.words[word] = row["def"] self.words[word] = row["def"]
self.trie.add(word) self.trie.add(word)
def find_solutions( # noqa: C901 def _find_solutions( # noqa: C901
self, board: list[list[str]] self, board: list[list[str]]
) -> dict[str, list[tuple[int, int]]]: ) -> dict[str, list[tuple[int, int]]]:
""" """
@@ -139,7 +141,10 @@ class PuzzleGenerator:
while paths: while paths:
path, trie_node = paths.pop() path, trie_node = paths.pop()
if trie_node.valid_word and len(trie_node.value) >= 4: if trie_node.valid_word and len(trie_node.value) >= 4:
solutions[trie_node.value] = path word = trie_node.value
if word in solutions:
return {}
solutions[word] = path
for dx, dy in ( for dx, dy in (
(1, 1), (1, 1),
(-1, -1), (-1, -1),
@@ -234,6 +239,7 @@ class PuzzleGenerator:
prev = path[0] prev = path[0]
for tile in path[1:]: for tile in path[1:]:
neigbours[prev].add(tile) neigbours[prev].add(tile)
neigbours[tile].add(prev)
prev = tile prev = tile
extras = {w: p for w, p in solutions.items() if w not in cover} extras = {w: p for w, p in solutions.items() if w not in cover}
@@ -252,7 +258,7 @@ class PuzzleGenerator:
return cover, extras return cover, extras
def get_valid_points( def _get_valid_points(
self, self,
solutions: dict[str, list[tuple[int, int]]], solutions: dict[str, list[tuple[int, int]]],
) -> set[tuple[int, int]]: ) -> set[tuple[int, int]]:
@@ -263,45 +269,65 @@ class PuzzleGenerator:
return used_cells return used_cells
def generate(self, target: int, max_attempts: int) -> Puzzle | None: def _verify_solution(
""" self, board: list[list[str]], target: int
Generate a valid puzzle that has solutions. ) -> tuple[set[tuple[int, int]], Puzzle | None]:
solutions = self._find_solutions(board)
:param target: minimum number of words required to solve the puzzle.
:param max_attempts: Since sometimes we might fail to generate a correct
puzzle. We try it multiple times, this parameters limits how
many attempts we should have before giving up.
:return: puzzle.
"""
board: list[list[str]] = [["" for _ in range(4)] for _ in range(4)]
valid_points: set[tuple[int, int]] = set()
single_board_attempt = 0
for _ in range(max_attempts):
if single_board_attempt > 50:
valid_points = set()
for x in range(4):
for y in range(4):
if (x, y) not in valid_points:
board[x][y] = secrets.choice(WEIGHTED_ALPHABET)
solutions = self.find_solutions(board)
cover, extra = self._find_minimal_cover(solutions, target) cover, extra = self._find_minimal_cover(solutions, target)
valid_points = self.get_valid_points(cover) valid_points = self._get_valid_points(cover)
word_defs = {word: self.words[word] for word in cover | extra}
# We found a valid solution | board combination # We found a valid solution | board combination
if len(valid_points) != 16 or len(cover) < target: if len(valid_points) != 16 or len(cover) < target:
single_board_attempt += 1 return valid_points, None
continue
return Puzzle( word_defs = {word: self.words[word] for word in cover | extra}
return valid_points, Puzzle(
board=board, board=board,
solutions=cover, solutions=cover,
extra=extra, extra=extra,
word_defs=word_defs, word_defs=word_defs,
) )
return None def check_board(self, compressed_board: str, target: int = 10) -> Puzzle | None:
"""Check if given board is valid."""
board = [list(compressed_board[i * 4 : i * 4 + 4]) for i in range(4)]
_, puzzle = self._verify_solution(board, target)
return puzzle
def generate(self, target: int, timeout: int = 10) -> Puzzle:
"""
Generate a valid puzzle that has solutions.
:param target: minimum number of words required to solve the puzzle.
:param timeout: since puzzles can fail to generate sometimes,
we use timeout to not let the puzzle generator hung forever!
:return: puzzle.
"""
board: list[list[str]] = [["" for _ in range(4)] for _ in range(4)]
valid_points: set[tuple[int, int]] = set()
single_board_attempt = 0
started = datetime.datetime.now()
while True:
if datetime.datetime.now() - started > timedelta(seconds=timeout):
raise ValueError(
f"Coud not find a valid solution in {timeout} seconds!"
)
if single_board_attempt > 50:
valid_points = set()
single_board_attempt = 0
for x in range(4):
for y in range(4):
if (x, y) not in valid_points:
board[x][y] = secrets.choice(WEIGHTED_ALPHABET)
valid_points, puzzle = self._verify_solution(board, target)
if not puzzle:
single_board_attempt += 1
continue
return puzzle
def setup_generator(app: FastAPI) -> None: def setup_generator(app: FastAPI) -> None:
@@ -319,7 +345,7 @@ PuzzleGeneratorDep = Annotated[PuzzleGenerator, Depends(get_generator)]
if __name__ == "__main__": if __name__ == "__main__":
generator = PuzzleGenerator(CWD / "dict.csv") generator = PuzzleGenerator(CWD / "dict.csv")
res = generator.generate(target=10, max_attempts=200) res = generator.generate(target=10)
if res is None: if res is None:
print("No solution found!") # noqa: T201 print("No solution found!") # noqa: T201
else: else:
+4
View File
@@ -1,4 +1,5 @@
import enum import enum
from datetime import date
from pathlib import Path from pathlib import Path
from tempfile import gettempdir from tempfile import gettempdir
@@ -46,6 +47,9 @@ class Settings(BaseSettings):
db_base: str = "zangramru" db_base: str = "zangramru"
db_echo: bool = False db_echo: bool = False
# When first daily puzzle was generated.
start_date: date = date(2026, 7, 1)
@property @property
def db_url(self) -> URL: def db_url(self) -> URL:
""" """
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+6
View File
@@ -8,3 +8,9 @@ class PuzzleOutputDTO(BaseModel):
solutions: dict[str, list[tuple[int, int]]] solutions: dict[str, list[tuple[int, int]]]
extra: dict[str, list[tuple[int, int]]] extra: dict[str, list[tuple[int, int]]]
word_defs: dict[str, str] word_defs: dict[str, str]
class DailyPuzzleDTO(PuzzleOutputDTO):
"""Daily puzzles DTO."""
number: int
+75 -4
View File
@@ -1,14 +1,85 @@
from fastapi import APIRouter import asyncio
import json
from datetime import date, timedelta
from fastapi import APIRouter, HTTPException
from pydantic import PositiveInt
from starlette import status
from zangramru.db.models.puzzle import Puzzle
from zangramru.services.puzzles import PuzzleGeneratorDep from zangramru.services.puzzles import PuzzleGeneratorDep
from zangramru.settings import settings
from .schema import PuzzleOutputDTO from .schema import DailyPuzzleDTO, PuzzleOutputDTO
router = APIRouter() router = APIRouter()
@router.get("/random") @router.get("/random")
def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO: def random_puzzle(
generator: PuzzleGeneratorDep,
board: str | None = None,
) -> PuzzleOutputDTO:
"""Generate a random puzzle.""" """Generate a random puzzle."""
puzzle = generator.generate(target=10, max_attempts=500) if board:
puzzle = generator.check_board(board, target=10)
if puzzle is None:
raise HTTPException(
status_code=status.HTTP_421_MISDIRECTED_REQUEST,
detail="Given board has no solution",
)
else:
puzzle = generator.generate(10)
return PuzzleOutputDTO.model_validate(puzzle, from_attributes=True) return PuzzleOutputDTO.model_validate(puzzle, from_attributes=True)
@router.get("/daily")
async def daily_puzzle(
generator: PuzzleGeneratorDep,
number: PositiveInt | None = None,
) -> DailyPuzzleDTO:
"""Generate a random puzzle."""
# Because they start at one, so we need to subtract one.
target_date = date.today()
if number is not None:
target_date = settings.start_date + timedelta(days=number)
if target_date > date.today():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Doesn't exist",
)
number = (target_date - settings.start_date).days
daily_puzzle = await Puzzle.objects().where(Puzzle.number == number).first()
if daily_puzzle is not None:
board = [list(daily_puzzle.board[i * 4 : i * 4 + 4]) for i in range(4)]
return DailyPuzzleDTO(
number=daily_puzzle.number,
board=board,
solutions=json.loads(daily_puzzle.solutions),
extra=json.loads(daily_puzzle.extra),
word_defs=json.loads(daily_puzzle.definitions),
)
loop = asyncio.get_running_loop()
puzzle = await loop.run_in_executor(None, generator.generate, 10)
compressedboard = "".join("".join(row) for row in puzzle.board)
await Puzzle(
{
Puzzle.board: compressedboard,
Puzzle.number: number,
Puzzle.solutions: json.dumps(puzzle.solutions),
Puzzle.definitions: json.dumps(puzzle.word_defs),
Puzzle.extra: json.dumps(puzzle.extra),
}
).save()
return DailyPuzzleDTO(
number=number,
board=puzzle.board,
solutions=puzzle.solutions,
extra=puzzle.extra,
word_defs=puzzle.word_defs,
)