feat: add per-user avatar seed regeneration with irreversible random avatar replacement
Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
2026-06-28 00:31:34 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from pydantic import ValidationError
|
|
|
|
|
|
|
|
|
|
from devplacepy.models import GameLegacyForm
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_game_legacy_form_accepts_valid_key():
|
|
|
|
|
assert GameLegacyForm(key="multiplier").key == "multiplier"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_game_legacy_form_rejects_empty_key():
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
GameLegacyForm(key="")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_game_legacy_form_rejects_overlong_key():
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
GameLegacyForm(key="x" * 41)
|
2026-07-06 05:57:47 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_isslop_run_form_accepts_git_and_http_sources():
|
|
|
|
|
from devplacepy.models import IsslopRunForm
|
|
|
|
|
|
|
|
|
|
assert IsslopRunForm(url="https://github.com/owner/repo").url == "https://github.com/owner/repo"
|
|
|
|
|
assert IsslopRunForm(url="git@github.com:owner/repo.git").url == "git@github.com:owner/repo.git"
|
|
|
|
|
assert IsslopRunForm(url=" https://example.com ").url == "https://example.com"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_isslop_run_form_rejects_other_schemes():
|
|
|
|
|
from devplacepy.models import IsslopRunForm
|
|
|
|
|
|
|
|
|
|
for bad in ("ftp://example.com/file", "javascript:alert(1)//aaa", "file:///etc/passwd"):
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
IsslopRunForm(url=bad)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_isslop_run_form_normalizes_typos_and_bare_domains():
|
|
|
|
|
from devplacepy.models import IsslopRunForm
|
|
|
|
|
|
|
|
|
|
assert (
|
|
|
|
|
IsslopRunForm(url="https:/retoor.molodetz.nl/retoor/devplacepy").url
|
|
|
|
|
== "https://retoor.molodetz.nl/retoor/devplacepy"
|
|
|
|
|
)
|
|
|
|
|
assert IsslopRunForm(url="example.com/owner/repo").url == "https://example.com/owner/repo"
|
|
|
|
|
assert IsslopRunForm(url="http:/x.dev/a").url == "http://x.dev/a"
|
2026-07-26 14:57:18 +02:00
|
|
|
|
|
|
|
|
|
Dedicate the project page to the project
The project detail page becomes a full project showcase built entirely
from existing platform mechanisms. One encompassing dark card wraps the
page; inner panels (tab bar, sidebar cards, devlog entries, comments)
sit one elevation lighter. The hero opens with a cover banner and an
optional logo tile, both plain attachment references
(cover_attachment_uid/logo_attachment_uid) uploaded through the
standard dp-upload attachment widget and linked via the existing
link_attachments choke point - the route validates each uid belongs to
the actor and is an image, and an empty value on edit keeps the current
one. The title block, type/platform chips and author row overlay the
banner behind a scrim with a dark text shadow, next to an owner-set
Visit Website CTA; website_url and repo_url are normalized in models
and render with rel noopener nofollow.
An anchor tab bar (Overview, Devlog, Screenshots when present,
Comments, Files) navigates the page. The main column keeps About, the
devlog timeline (with devlog_count and an owner Post update button
opening the shared composer preset to the devlog topic + project - the
form now lives once in _post_composer_form.html, included by feed.html
and project_detail.html), a Screenshots gallery built from image
attachments minus the cover/logo (thumbnails, lightbox, 12 rendered),
and the comment thread; the sidebar holds Links, Stats and the Author
card. Owners add gallery images from the More menu via
POST /projects/{slug}/screenshots (owner-only, audit
project.screenshots.add, Devii action project_add_screenshots, docs id
projects-screenshots). comment_count/devlog_count ride
ProjectDetailOut, the new fields ride ProjectOut, and the create/edit
faces (modals, Devii actions, API docs) carry them. The project
comment/files e2e tests scope their locators per the documented
dual-control idiom, and new unit/api/e2e tests cover URL normalization,
the counts, the hero attachment guard, the screenshots flow and the
preset composer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 23:00:12 +02:00
|
|
|
def test_project_form_link_urls_normalize_and_validate():
|
|
|
|
|
from devplacepy.models import ProjectForm, normalize_website_url
|
|
|
|
|
|
|
|
|
|
base = {"title": "T", "description": "D"}
|
|
|
|
|
assert ProjectForm(**base).website_url == ""
|
|
|
|
|
assert ProjectForm(**base, website_url="myproject.dev").website_url == "https://myproject.dev"
|
|
|
|
|
assert ProjectForm(**base, repo_url="github.com/me/x").repo_url == "https://github.com/me/x"
|
|
|
|
|
assert (
|
|
|
|
|
ProjectForm(**base, website_url="http://x.dev/a?b=1").website_url
|
|
|
|
|
== "http://x.dev/a?b=1"
|
|
|
|
|
)
|
|
|
|
|
assert normalize_website_url(" ") == ""
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
ProjectForm(**base, website_url="javascript:alert(1)")
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
ProjectForm(**base, repo_url="not a url")
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 14:57:18 +02:00
|
|
|
def test_reaction_form_accepts_any_single_emoji():
|
|
|
|
|
from devplacepy.models import ReactionForm
|
|
|
|
|
|
|
|
|
|
for value in (
|
|
|
|
|
"\U0001F44D",
|
|
|
|
|
"\U0001F984",
|
|
|
|
|
"\u2764\uFE0F",
|
|
|
|
|
"\U0001F44D\U0001F3FD",
|
|
|
|
|
"\U0001F9D1\u200d\U0001F4BB",
|
|
|
|
|
"\U0001F3F3\uFE0F\u200d\U0001F308",
|
|
|
|
|
"\U0001F1F3\U0001F1F1",
|
|
|
|
|
"1\uFE0F\u20E3",
|
|
|
|
|
):
|
|
|
|
|
assert ReactionForm(emoji=value).emoji == value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reaction_form_accepts_every_emoji_the_picker_offers():
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from devplacepy.models import ReactionForm
|
|
|
|
|
|
|
|
|
|
data_path = (
|
|
|
|
|
Path(__file__).resolve().parents[2]
|
|
|
|
|
/ "devplacepy"
|
|
|
|
|
/ "static"
|
|
|
|
|
/ "vendor"
|
|
|
|
|
/ "emoji-picker-element"
|
|
|
|
|
/ "data.json"
|
|
|
|
|
)
|
|
|
|
|
entries = json.loads(data_path.read_text(encoding="utf-8"))
|
|
|
|
|
offered = [entry["emoji"] for entry in entries]
|
|
|
|
|
offered += [
|
|
|
|
|
skin["emoji"] for entry in entries for skin in (entry.get("skins") or [])
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
assert len(offered) > 3000
|
|
|
|
|
for value in offered:
|
|
|
|
|
assert ReactionForm(emoji=value).emoji == value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reaction_form_strips_surrounding_whitespace():
|
|
|
|
|
from devplacepy.models import ReactionForm
|
|
|
|
|
|
|
|
|
|
assert ReactionForm(emoji=" \U0001F680 ").emoji == "\U0001F680"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reaction_form_rejects_non_emoji_values():
|
|
|
|
|
from devplacepy.models import ReactionForm
|
|
|
|
|
|
|
|
|
|
for value in (
|
|
|
|
|
"notanemoji",
|
|
|
|
|
"a",
|
|
|
|
|
" ",
|
|
|
|
|
"",
|
|
|
|
|
"<script>",
|
|
|
|
|
"\u200b",
|
|
|
|
|
"\U0001F44D x",
|
|
|
|
|
"\U0001F44D\U0001F44D",
|
|
|
|
|
):
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
ReactionForm(emoji=value)
|