- Invite flow: admin issue/revoke on join requests, public single-use claim links (hash-only tokens, 7-day expiry, no state reveal), claim creates the Member account and marks the request accepted - Gallery: admin status page plus resync endpoint; sync refreshes thumbnails whose content changed; tools/gallery contract and checker - Docs: public content page, admin-only operator runbook, api.md invite/gallery sections, all routes in the live API docs, docs reachability gate test - Screenshots cover the new pages; version 1.0.18
132 lines
6.3 KiB
Python
132 lines
6.3 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
import io
|
|
import logging
|
|
|
|
import imagehash
|
|
from PIL import Image
|
|
|
|
from molodetz import config
|
|
from molodetz.attachments import blob_path, blob_url, write_blob
|
|
from molodetz.database import generate_uid, media_by_kind, upsert_media
|
|
from molodetz.database.media import get_media_by_file, retire_media
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
THUMB_WIDTH = 720
|
|
DUPLICATE_DISTANCE = 2
|
|
|
|
GALLERY = (
|
|
("flyer-elite-twice.jpg", "flyer", "Elite twice. Know the code. Use the machine. Read every line it writes."),
|
|
("flyer-taste.jpg", "flyer", "Taste does not autocomplete."),
|
|
("flyer-demo.jpg", "flyer", "A demo is not a product."),
|
|
("flyer-hands.jpg", "flyer", "Your hands are not the craft."),
|
|
("meme-elite-galaxy-brain.jpg", "meme", "Getting elite twice: know every line before the AI writes it, review every line after, ship by lunch."),
|
|
("meme-classic-autocomplete.jpg", "meme", "I don't need autocomplete. Hand-typed. Artisanal. Not shipped."),
|
|
("meme-vibe-prompt-and-pray.jpg", "meme", "Prompt and pray: the same TypeError pasted into the chat 31 times. Suggested fix: learn what a function is."),
|
|
("meme-elite-choose-your-fighter.jpg", "meme", "Choose your fighter: the classic, the vibe coder, or the elite who writes code, uses AI and reads every diff."),
|
|
("meme-classic-starter-pack.jpg", "meme", "The 'AI is just fancy autocomplete' starter pack: 36 tmux panes, a vimrc older than the intern, zero pull requests."),
|
|
("meme-vibe-starter-pack.jpg", "meme", "The 'built it in a weekend' starter pack: tab, tab, accept all, API key committed to main, works on my machine."),
|
|
("meme-elite-build-log.jpg", "meme", "Elite twice. The model types. You decide. 1,184 lines drafted, every line read, 212 rejected."),
|
|
("meme-classic-chart.jpg", "meme", "Free-range, hand-typed code: keystrokes up, features shipped flat, opinions about AI off the chart."),
|
|
("meme-vibe-galaxy-brain.jpg", "meme", "Vibe debugging: read the error, paste the error, tab tab accept all, delete the repo and prompt a new one."),
|
|
("meme-elite-take.jpg", "meme", "AI replaced the part of me that typed. The part that thinks got a raise."),
|
|
("meme-classic-same-guy.jpg", "meme", "1987: compilers make programmers lazy. 2026: AI makes programmers lazy. Same guy. Still not shipping."),
|
|
("meme-vibe-git-blame.jpg", "meme", "Git blame says it was you. You say it was the vibes."),
|
|
("meme-elite-curve.jpg", "meme", "Pick your curve: classic stays flat, the vibe coder crashes at the first prod incident, elite plus AI keeps climbing."),
|
|
("meme-classic-certificate.jpg", "meme", "Certificate of manual labour. Time saved: 0 hours. Moral high ground: maximum."),
|
|
("meme-vibe-weekend.jpg", "meme", "A vibe coder's weekend: a SaaS on Friday, a public database by Sunday, a course about it on Monday."),
|
|
("meme-elite-achievement.jpg", "meme", "Achievement unlocked: this developer embraced AI and is now elite twice."),
|
|
("meme-classic-family.jpg", "meme", "Three generations of refusing the tool. Three generations of getting outshipped."),
|
|
("meme-vibe-priorities.jpg", "meme", "Vibe coder priorities: learning what a for loop does, nah. Typing 'make it work, no bugs, please' 30 times, yep."),
|
|
("meme-compiles.jpg", "meme", "It compiles. Ship it, king."),
|
|
("meme-senior.jpg", "meme", "My copilot is the senior."),
|
|
("meme-trophy.jpg", "meme", "Peak performance, 6 out of 10."),
|
|
("meme-todo.jpg", "meme", "Side projects, a love story."),
|
|
)
|
|
|
|
RETIRED_GALLERY = (
|
|
"flyer-keuzes.jpg",
|
|
"flyer-chat.jpg",
|
|
"flyer-bijna.jpg",
|
|
"flyer-assistent.jpg",
|
|
"meme-vloer.jpg",
|
|
"meme-split.jpg",
|
|
"meme-cursus.jpg",
|
|
"meme-zes.jpg",
|
|
"meme-gesprek.jpg",
|
|
"meme-product.jpg",
|
|
"meme-bijna.jpg",
|
|
"poster-thuis.jpg",
|
|
"poster-vloer.jpg",
|
|
"poster-cursus.jpg",
|
|
"poster-luiheid.jpg",
|
|
"poster-spel.jpg",
|
|
"story.jpg",
|
|
"take-chatlog.jpg",
|
|
"take-copilot.jpg",
|
|
"take-coping.jpg",
|
|
)
|
|
|
|
|
|
def _thumbnail_bytes(image):
|
|
copy = image.convert("RGB")
|
|
copy.thumbnail((THUMB_WIDTH, THUMB_WIDTH * 2))
|
|
buffer = io.BytesIO()
|
|
copy.save(buffer, format="WEBP", quality=82, method=4)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def retire_gallery():
|
|
return sum(1 for filename in RETIRED_GALLERY if retire_media(filename))
|
|
|
|
|
|
def sync_gallery():
|
|
retire_gallery()
|
|
seen_hashes = []
|
|
synced = 0
|
|
for position, (filename, kind, caption) in enumerate(GALLERY):
|
|
source = config.MEDIA_SOURCE_DIR / filename
|
|
if not source.exists():
|
|
logger.warning("gallery source missing: %s", source)
|
|
continue
|
|
existing = get_media_by_file(filename)
|
|
with Image.open(source) as image:
|
|
width, height = image.size
|
|
phash = imagehash.phash(image)
|
|
if any(phash - other <= DUPLICATE_DISTANCE for other in seen_hashes):
|
|
logger.info("gallery duplicate skipped: %s", filename)
|
|
continue
|
|
seen_hashes.append(phash)
|
|
thumb_directory = existing.get("thumb_directory") if existing else None
|
|
thumb_name = existing.get("thumb_name") if existing else None
|
|
changed = not existing or existing.get("phash") != str(phash)
|
|
if changed or not thumb_directory or not blob_path(thumb_directory, thumb_name).exists():
|
|
thumb_directory, thumb_name = write_blob(
|
|
existing["uid"] if existing else generate_uid(), "webp", _thumbnail_bytes(image)
|
|
)
|
|
upsert_media(filename, kind, caption, position, width, height, str(phash), thumb_directory, thumb_name)
|
|
synced += 1
|
|
return synced
|
|
|
|
|
|
def missing_sources():
|
|
return [filename for filename, _, _ in GALLERY if not (config.MEDIA_SOURCE_DIR / filename).exists()]
|
|
|
|
|
|
def gallery_items(kind):
|
|
items = []
|
|
for row in media_by_kind(kind):
|
|
thumb = blob_url(row["thumb_directory"], row["thumb_name"]) if row.get("thumb_directory") else None
|
|
items.append(
|
|
{
|
|
"uid": row["uid"],
|
|
"filename": row["filename"],
|
|
"caption": row["caption"],
|
|
"width": row["width"],
|
|
"height": row["height"],
|
|
"source_path": f"/static/media/{row['filename']}",
|
|
"thumb_url": thumb,
|
|
}
|
|
)
|
|
return items
|