Home intro from retoor as editable site_intro setting; fix settings upsert

This commit is contained in:
2026-10-05 11:12:18 +02:00
parent 3fb47bf401
commit bbdecdc95b
14 changed files with 131 additions and 16 deletions
+7
View File
@@ -1,4 +1,10 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import re
from pathlib import Path
INTRO_SEED_FILE = Path(__file__).resolve().parent / "seed" / "intro.md"
SITE_INTRO_SEED = re.sub(r"\A<!--.*?-->\n", "", INTRO_SEED_FILE.read_text(encoding="utf-8"), count=1, flags=re.S)
ROLE_ADMIN = "Admin" ROLE_ADMIN = "Admin"
ROLE_MEMBER = "Member" ROLE_MEMBER = "Member"
ROLES = (ROLE_ADMIN, ROLE_MEMBER) ROLES = (ROLE_ADMIN, ROLE_MEMBER)
@@ -76,6 +82,7 @@ GZIP_SKIP_PREFIXES: tuple[str, ...] = ()
SETTINGS_DEFAULTS = { SETTINGS_DEFAULTS = {
"site_title": "Molodetz", "site_title": "Molodetz",
"site_tagline": "De kring, niet de reclame.", "site_tagline": "De kring, niet de reclame.",
"site_intro": SITE_INTRO_SEED,
"maintenance_mode": "0", "maintenance_mode": "0",
"maintenance_message": "Molodetz is even dicht voor onderhoud.", "maintenance_message": "Molodetz is even dicht voor onderhoud.",
"rate_limit_per_minute": "60", "rate_limit_per_minute": "60",
+2 -3
View File
@@ -157,9 +157,8 @@ def _seed_settings():
if "site_settings" not in db.tables: if "site_settings" not in db.tables:
return return
for key, value in SETTINGS_DEFAULTS.items(): for key, value in SETTINGS_DEFAULTS.items():
uid = f"default_{key}" if db["site_settings"].find_one(key=key) is None:
if db["site_settings"].find_one(uid=uid) is None: db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value, "updated_at": now_iso()})
db["site_settings"].insert({"uid": uid, "key": key, "value": value, "updated_at": now_iso()})
def _seed_notification_key(): def _seed_notification_key():
+8 -7
View File
@@ -61,9 +61,7 @@ def get_setting(key, default=None):
return cached return cached
value = default value = default
if "site_settings" in db.tables: if "site_settings" in db.tables:
row = db["site_settings"].find_one(uid=f"setting_{key}") row = db["site_settings"].find_one(key=key)
if row is None:
row = db["site_settings"].find_one(uid=f"default_{key}")
if row is not None and row.get("value") is not None: if row is not None and row.get("value") is not None:
value = str(row["value"]) value = str(row["value"])
_settings_cache.set(key, value) _settings_cache.set(key, value)
@@ -78,10 +76,13 @@ def get_int_setting(key, default):
def set_setting(key, value): def set_setting(key, value):
db["site_settings"].upsert( table = db["site_settings"]
{"uid": f"setting_{key}", "key": key, "value": str(value), "updated_at": now_iso()}, stamp = now_iso()
["uid"], with db:
) if table.find_one(key=key) is None:
table.insert({"uid": f"setting_{key}", "key": key, "value": str(value), "updated_at": stamp})
else:
table.update({"key": key, "value": str(value), "updated_at": stamp}, ["key"])
bump_cache_version("settings", _settings_cache) bump_cache_version("settings", _settings_cache)
+6
View File
@@ -104,6 +104,7 @@ class RoleForm(FormModel):
class SettingsForm(FormModel): class SettingsForm(FormModel):
site_title: str | None = Field(default=None, max_length=80) site_title: str | None = Field(default=None, max_length=80)
site_tagline: str | None = Field(default=None, max_length=200) site_tagline: str | None = Field(default=None, max_length=200)
site_intro: str | None = Field(default=None, max_length=20000)
maintenance_mode: str | None = None maintenance_mode: str | None = None
maintenance_message: str | None = Field(default=None, max_length=500) maintenance_message: str | None = Field(default=None, max_length=500)
rate_limit_per_minute: str | None = None rate_limit_per_minute: str | None = None
@@ -115,6 +116,11 @@ class SettingsForm(FormModel):
audit_retention_days: str | None = None audit_retention_days: str | None = None
outbound_proxy_url: str | None = Field(default=None, max_length=500) outbound_proxy_url: str | None = Field(default=None, max_length=500)
@field_validator("site_intro")
@classmethod
def normalize_newlines(cls, value):
return value.replace("\r\n", "\n").replace("\r", "\n") if value else value
class ServiceConfigForm(FormModel): class ServiceConfigForm(FormModel):
model_config = ConfigDict(extra="allow", str_strip_whitespace=True) model_config = ConfigDict(extra="allow", str_strip_whitespace=True)
+6 -2
View File
@@ -3,8 +3,10 @@ from fastapi import APIRouter, Request
from molodetz import config from molodetz import config
from molodetz.cache import TTLCache from molodetz.cache import TTLCache
from molodetz.database import count_media, published_posts from molodetz.constants import SITE_INTRO_SEED
from molodetz.database import count_media, get_setting, published_posts
from molodetz.gallery import gallery_items from molodetz.gallery import gallery_items
from molodetz.rendering import render_content
from molodetz.responses import respond from molodetz.responses import respond
from molodetz.schemas.posts import HomeOut from molodetz.schemas.posts import HomeOut
from molodetz.seo import base_seo_context, organization_schema, website_schema from molodetz.seo import base_seo_context, organization_schema, website_schema
@@ -39,4 +41,6 @@ async def index(request: Request):
description_for("home"), description_for("home"),
schemas=[website_schema(request), organization_schema(request)], schemas=[website_schema(request), organization_schema(request)],
) )
return respond(request, "home.html", {**blocks, "seo": seo}, model=HomeOut) intro = get_setting("site_intro", SITE_INTRO_SEED)
ctx = {**blocks, "intro": intro, "intro_html": render_content(intro), "seo": seo}
return respond(request, "home.html", ctx, model=HomeOut)
+1
View File
@@ -28,6 +28,7 @@ class PostDetailOut(OutModel):
class HomeOut(OutModel): class HomeOut(OutModel):
intro: str = ""
posts: list[PostOut] = [] posts: list[PostOut] = []
flyer_count: int = 0 flyer_count: int = 0
meme_count: int = 0 meme_count: int = 0
+26
View File
@@ -0,0 +1,26 @@
<!-- retoor <retoor@molodetz.nl> -->
I wrote more code than you. I wrote more code than the best programmer you know. I wrote more code than you two together.
Yes, that pill is hard to swallow. I do understand that. I am running a keylogger that i wrote myself in C many, many years ago.
I ended up with a sqlite3 database with more than 60.000.000 rows in it and it runs better than your production server and it will stay that way.
When I created this process, I did not know why, I thought, it must have been worth SOMETHING. I just do what the big companies do, collect data and find out later, what to do with it.
Well, for starters, I can extract with AI what I did every hour. Yes, I know what I did for many years literally every hour with a detailed description. A zero effort hour registration system.
And did you know what that system told me? I wrote more code than you. I wrote more code than the best programmer you know. I wrote more code than you two together. To top it off, a plugin for ai autocompletion did measure its usage among millions of programmers (Codeium). I was in the top 0.01. Sad that it didn't go lower, since I came late to the party and was only using it for a half year.
Once, I published automated my working hours on my website automatically. Why not. Doing good stuff is something worth publishing right. Well, I took it down because people considered 72hrs in three days a bit disturbing, especially since it was not an anomaly.
And yes, I am very proud of that. I do not watch Netflix, I do not play games, I rarely do watch something not code related on youtube. I only work on developing myself and that is the target product. I do not develop software with goal to deliver great software. I develop software for the sake of development. Because, it makes as high as fuck. Developing new skills are the best feeling there is. I really feel amazing and according the book "The subtle art of not giving a fuck" explains literally why. Great book. Buy now. It really teaches you how to become happy. A method that can't fail.
Do not let people tell you, that you are flexing why you are just stating the truth. I know, it hurts if you do not have anything to flex yourself.
So, my previous site had only the 0.01% claim backed by Codeium and, you have literally, no idea, how absurdly people reacted to it. Completely crazy. It was just a random statement from the biggest ai autocompletion plugin. It just tracked that. That's all.
It even costed me a friend. I don't understand, i even linked to my profile page on codeium and ...
Also, when some stuff i host is malfunctioning, it can lead up to multiple forum posts with some referring to that statistic.
I thought, maybe I just delete it. But i can't delete it. It is all i have in life and you know what? It was even a freaking understatement and i triple down backed by data.
You have feelings, i have data. One of them is reliable.
Anyway, welcome to my new hostile website. No professional texts anymore. Just me, taking down all people that refuse to use AI. Also I take down people that refuse to learn to code (Vibe "Coders"). And, to promote a product I am working on (but i am working with) for weeks. I am working on a development environment for the elite. Fun fact, you already know the environment if you're the classic elite (vim, tmux, etc with the pretty well know adjustments). But what people extremely underestimate is that AI is something that needs to be learnt as well. AI didn't make us equal at all. We are more divided in skills than ever before.
And let's see, if dividing people is as fun as it looks and if i can offer a solution for the problem.
Will you be the one that will be replaced? Or will you be the one that will replace. Look at the guy next to you.
+8
View File
@@ -12,6 +12,13 @@
.hero .lead { font-size: var(--text-lg); color: var(--ink-soft); max-width: 30em; margin: 0; } .hero .lead { font-size: var(--text-lg); color: var(--ink-soft); max-width: 30em; margin: 0; }
.hero .lead em { color: var(--ink); font-style: italic; } .hero .lead em { color: var(--ink); font-style: italic; }
.intro { max-width: var(--measure); font-family: var(--font-serif); color: var(--ink-soft); font-size: var(--text-base); line-height: var(--leading-body); }
.intro p { margin: 0 0 var(--space-md); }
.intro > p:first-child { font-size: var(--text-xl); line-height: 1.3; color: var(--ink); letter-spacing: -0.01em; margin-bottom: var(--space-lg); }
.intro > p:last-child { margin-bottom: 0; }
.intro-edit { font-family: var(--font-sans); font-size: var(--text-xs); margin: var(--space-md) 0 0; }
.intro-edit a { color: var(--muted); }
.section-head { .section-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -125,4 +132,5 @@
@media (max-width: 480px) { @media (max-width: 480px) {
.article-header h1 { font-size: var(--text-xl); } .article-header h1 { font-size: var(--text-xl); }
.prose { font-size: var(--text-base); } .prose { font-size: var(--text-base); }
.intro > p:first-child { font-size: var(--text-lg); }
} }
+4 -1
View File
@@ -6,7 +6,10 @@
{% for key, value in settings.items() %} {% for key, value in settings.items() %}
<div class="field"> <div class="field">
<label for="s-{{ key }}">{{ key }}</label> <label for="s-{{ key }}">{{ key }}</label>
{% if key in ('maintenance_mode', 'friendly_404') %} {% if key == 'site_intro' %}
<textarea id="s-{{ key }}" name="{{ key }}" rows="24">{{ value }}</textarea>
<span class="help">Intro bovenaan de homepage. Markdown; elke regelovergang blijft staan, een lege regel begint een nieuwe alinea. De eerste alinea wordt groot gezet.</span>
{% elif key in ('maintenance_mode', 'friendly_404') %}
<select id="s-{{ key }}" name="{{ key }}"><option value="0" {% if value == '0' %}selected{% endif %}>0</option><option value="1" {% if value == '1' %}selected{% endif %}>1</option></select> <select id="s-{{ key }}" name="{{ key }}"><option value="0" {% if value == '0' %}selected{% endif %}>0</option><option value="1" {% if value == '1' %}selected{% endif %}>1</option></select>
{% else %} {% else %}
<input id="s-{{ key }}" type="text" name="{{ key }}" value="{{ value }}"> <input id="s-{{ key }}" type="text" name="{{ key }}" value="{{ value }}">
+2
View File
@@ -10,4 +10,6 @@ Onder *Beheer, Services* staan alle achtergrondservices. Elke service heeft een
## Onderhoudsmodus ## Onderhoudsmodus
De intro bovenaan de homepage staat in de instelling `site_intro` (markdown, regelovergangen blijven staan). Bewerk hem onder *Instellingen*; de eerste alinea wordt groot gezet.
Zet `maintenance_mode` op `1` onder *Instellingen*. Beheerders blijven alles zien. Zet `maintenance_mode` op `1` onder *Instellingen*. Beheerders blijven alles zien.
+3 -1
View File
@@ -1,10 +1,12 @@
<!-- retoor <retoor@molodetz.nl> --> <!-- retoor <retoor@molodetz.nl> -->
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
{% set viewer = current_user(request) %}
<section class="hero"> <section class="hero">
<span class="label">Een blogrol</span> <span class="label">Een blogrol</span>
<h1>Molodetz</h1> <h1>Molodetz</h1>
<p class="lead">Een kleine kring die schrijft over het werk. De standaard ligt boven wat nu gewoon lijkt. <em>Geen cursus. Geen geschreeuw. Wel een mening.</em></p> <div class="intro" data-intro>{{ intro_html | safe }}</div>
{% if viewer and is_admin(viewer) %}<p class="intro-edit"><a href="/admin/settings#s-site_intro">Intro bewerken</a></p>{% endif %}
</section> </section>
<div class="section-head"> <div class="section-head">
+2 -2
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "molodetz" name = "molodetz"
version = "1.0.5" version = "1.0.6"
description = "Molodetz, a calm community blog roll." description = "Molodetz, a calm community blog roll."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -53,7 +53,7 @@ molodetz = "molodetz.cli.main:main"
include = ["molodetz*"] include = ["molodetz*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
molodetz = ["templates/**/*", "static/**/*", "docs_api/*.py"] molodetz = ["templates/**/*", "static/**/*", "docs_api/*.py", "seed/*.md"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests/unit", "tests/api", "tests/e2e"] testpaths = ["tests/unit", "tests/api", "tests/e2e"]
+46
View File
@@ -13,3 +13,49 @@ def test_unknown_page_is_404(anon):
response = anon.get("/bestaat-niet-xyz") response = anon.get("/bestaat-niet-xyz")
assert response.status_code == 404 assert response.status_code == 404
assert anon.get("/bestaat-niet-xyz", headers={"Accept": "application/json"}).json()["error"] assert anon.get("/bestaat-niet-xyz", headers={"Accept": "application/json"}).json()["error"]
def _intro_section(html):
from bs4 import BeautifulSoup
return BeautifulSoup(html, "html.parser").select_one("section.hero [data-intro]")
def test_intro_renders_on_home(anon):
from molodetz.constants import SITE_INTRO_SEED
intro = _intro_section(anon.get("/").text)
assert intro is not None
paragraphs = intro.find_all("p", recursive=False)
assert len(paragraphs) == len([block for block in SITE_INTRO_SEED.split("\n\n") if block.strip()])
assert paragraphs[0].get_text(" ", strip=True).startswith("I wrote more code than you. I wrote more code than the best programmer you know.")
assert paragraphs[-1].get_text(strip=True) == "Will you be the one that will be replaced? Or will you be the one that will replace. Look at the guy next to you."
assert len(paragraphs[1].find_all("br")) == 5
text = intro.get_text(" ", strip=True)
for fixed in ("especially since it was", "Well, for starters", "development environment", "more divided in skills", "i triple down", "No professional texts", "it must have been worth", "measure its usage", "Buy now.", 'Vibe "Coders"'):
assert fixed in text, fixed
for typo in ("sicne", "deelopment", "devided", "tripple", "teksts", "have be worth", "it s usage", "\u1e82"):
assert typo not in text, typo
assert "<!--" not in str(intro)
def test_intro_json_face(anon):
data = anon.get("/", headers={"Accept": "application/json"}).json()
assert data["intro"].startswith("I wrote more code than you.")
def test_admin_can_edit_intro(admin, anon):
original = admin.get("/admin/settings", headers={"Accept": "application/json"}).json()["settings"]["site_intro"]
try:
response = admin.post("/admin/settings", json={"site_intro": "Eerste regel.\n\nTweede alinea\nmet een regel erbij."}, headers={"Accept": "application/json"})
assert response.status_code == 200
intro = _intro_section(anon.get("/").text)
assert intro.find("p").get_text() == "Eerste regel."
assert intro.find_all("p")[1].find("br") is not None
form = admin.post("/admin/settings", data={"site_intro": "Via formulier.\r\n\r\nRegel een\r\nregel twee"})
assert form.status_code in (302, 303)
stored = admin.get("/admin/settings", headers={"Accept": "application/json"}).json()["settings"]["site_intro"]
assert stored == "Via formulier.\n\nRegel een\nregel twee"
finally:
admin.post("/admin/settings", json={"site_intro": original}, headers={"Accept": "application/json"})
assert _intro_section(anon.get("/").text).find("p").get_text(" ", strip=True).startswith("I wrote more code than you.")
+10
View File
@@ -6,3 +6,13 @@ def test_soft_delete_tables_partition():
tables = set(SOFT_DELETE_TABLES) tables = set(SOFT_DELETE_TABLES)
assert tables == set(REPORTABLE_TARGETS) | set(UNREPORTABLE_TABLES) assert tables == set(REPORTABLE_TARGETS) | set(UNREPORTABLE_TABLES)
assert not set(REPORTABLE_TARGETS) & set(UNREPORTABLE_TABLES) assert not set(REPORTABLE_TARGETS) & set(UNREPORTABLE_TABLES)
def test_intro_seed_is_clean():
from molodetz.constants import SETTINGS_DEFAULTS, SITE_INTRO_SEED
assert SETTINGS_DEFAULTS["site_intro"] == SITE_INTRO_SEED
assert SITE_INTRO_SEED.startswith("I wrote more code than you.")
assert "retoor@molodetz.nl" not in SITE_INTRO_SEED
assert "I wrote more code than you. I wrote more code than the best programmer you know. I wrote more code than you two together. To top it off" in SITE_INTRO_SEED
assert chr(0x2014) not in SITE_INTRO_SEED and chr(0x2013) not in SITE_INTRO_SEED