This commit is contained in:
evilchili 2025-10-04 01:26:09 -07:00
parent 120449386a
commit c9927656ce
8 changed files with 222 additions and 95 deletions

View File

@ -40,7 +40,7 @@ IN_MEMORY_DB=
DATA_ROOT=~/.dnd/ttfrog/ DATA_ROOT=~/.dnd/ttfrog/
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_EMAIL= ADMIN_EMAIL=admin@telisar
THEME=default THEME=default
@ -105,8 +105,8 @@ VIEW_URI=/
if not self._initialized: if not self._initialized:
raise ApplicationNotInitializedError("This action requires the application to be initialized.") raise ApplicationNotInitializedError("This action requires the application to be initialized.")
def add_page(self, parent: schema.Page, child: schema.Page): def add_member(self, parent: schema.Page, child: schema.Page):
parent.pages.append(self.db.save(child)) parent.members.append(self.db.save(child))
parent = self.db.save(parent) parent = self.db.save(parent)
return parent.get_child(child) return parent.get_child(child)
@ -115,34 +115,24 @@ VIEW_URI=/
Bootstrap the database entries by populating the first Page, the Admin user and the Admins group. Bootstrap the database entries by populating the first Page, the Admin user and the Admins group.
""" """
self.check_state() self.check_state()
home = schema.Page(stub=self.config.VIEW_URI, title="Home", body="This is the home page")
npcs = schema.Page(stub="NPC", title="NPC", body="NPCs!")
sabetha = schema.Page(title="Sabetha", body="Sabetha!")
try: # create the top-level pages
home = self.db.save(home) root = self.db.save(schema.Page(name=self.config.VIEW_URI, title="Home", body="This is the home page"))
except UniqueConstraintError: users = self.add_member(root, schema.Page(name="User", title="Users", body="users go here."))
pass groups = self.add_member(root, schema.Page(name="Group", title="Groups", body="groups go here."))
npcs = self.add_member(root, schema.Page(name="NPC", title="NPCS!", body="NPCS!"))
try: # create the NPCs
npcs = self.add_page(home, npcs) sabetha = self.db.save(schema.NPC(name="Sabetha", body=""))
except UniqueConstraintError: johns = self.db.save(schema.NPC(name="John", body=""))
pass self.add_member(npcs, sabetha)
self.add_member(npcs, johns)
try: # create the admin user and admins group
sabetha = self.add_page(npcs, sabetha) admin = self.add_member(users, schema.User(name=self.config.ADMIN_USERNAME, email=self.config.ADMIN_EMAIL))
except UniqueConstraintError: admins = self.db.save(schema.Group(name="administrators"))
pass admins = self.add_member(groups, admins)
self.add_member(admins, admin)
try:
admin = self.db.save(schema.User(name=self.config.ADMIN_USERNAME, email=self.config.ADMIN_EMAIL))
except UniqueConstraintError:
pass
try:
self.db.save(schema.Group(name="admins", users=[admin]))
except UniqueConstraintError:
pass
sys.modules[__name__] = ApplicationContext() sys.modules[__name__] = ApplicationContext()

69
src/ttfrog/forms.py Normal file
View File

@ -0,0 +1,69 @@
from dataclasses import dataclass, field
from functools import cached_property
from grung.types import BackReference, Collection, Pointer, Record
from ttfrog import schema
READ_ONLY_FIELD_TYPES = [Collection, Pointer, BackReference]
@dataclass
class Form:
"""
The base Form controller for the web UI.
"""
record: Record
data: field(default_factory=dict)
@cached_property
def read_only(self) -> set:
return [
name for (name, attr) in self.record._metadata.fields.items() if type(attr) in READ_ONLY_FIELD_TYPES
] + ["uid"]
def prepare(self):
for key, value in self.data.items():
# filter out fields that cannot be set by the user
if key in self.read_only:
continue
self.record[key] = value
return self.record
@dataclass
class Page(Form):
"""
A form for creating and updating Page records.
"""
record: schema.Page
@cached_property
def read_only(self) -> set:
return set(list(super().read_only) + ["stub"])
@dataclass
class NPC(Page):
"""
A form for creating and updating Page records.
"""
record: schema.NPC
@dataclass
class User(Page):
"""
A form for creating and updating Page records.
"""
record: schema.NPC
@dataclass
class Group(Page):
"""
A form for creating and updating Page records.
"""
record: schema.NPC

View File

@ -1,52 +1,86 @@
from grung.types import BackReference, Collection, Field, Record
from grung.types import BackReference, Collection, Field, Record, Pointer
class User(Record):
@classmethod
def fields(cls):
return [*super().fields(), Field("name"), Field("email", unique=True)]
class Group(Record):
@classmethod
def fields(cls):
return [*super().fields(), Field("name", unique=True), Collection("users", User)]
class Page(Record): class Page(Record):
"""
A page in the wiki. Just about everything in the databse is either a Page or a subclass of a Page.
"""
@classmethod @classmethod
def fields(cls): def fields(cls):
return [ return [
*super().fields(), *super().fields(), # Pick up the UID and whatever other non-optional fields exist
Field("uri", unique=True), Field("uri", unique=True), # The URI for the page, relative to the app's VIEW_URI
Field("stub"), Field("name"), # The portion of the URI after the last /
Field("title"), Field("title"), # The page title
Field("body"), Field("body"), # The main content blob of the page
Collection("pages", Page), Collection("members", Page), # The pages that exist below this page's URI
BackReference("parent", value_type=Page), BackReference("parent", value_type=Page), # The page that exists above this page's URI
Pointer("author", value_type=User), # The last user to touch the page.
# DateTime("last_modified"), # The last time the page was modified.
] ]
def before_insert(self, db): def before_insert(self, db):
"""
Make the following adjustments before saving this record:
* Derive the name from the title, or the title from the name
* Derive the URI from the hierarchy of the parent.
"""
super().before_insert(db) super().before_insert(db)
if not self.stub and not self.title: if not self.name and not self.title:
raise Exception("Must provide either a stub or a title!") raise Exception("Must provide either a name or a title!")
if not self.stub: if not self.name:
self.stub = self.title.title().replace(" ", "") self.name = self.title.title().replace(" ", "")
if not self.title: if not self.title:
self.title = self.stub self.title = self.name
self.uri = (self.parent.uri + "/" if self.parent and self.parent.uri != "/" else "") + self.stub self.uri = (self.parent.uri + "/" if self.parent and self.parent.uri != "/" else "") + self.name
def after_insert(self, db): def after_insert(self, db):
"""
After saving this record, ensure that any page in the members collection is updated with the
correct URI. This ensures that if a page is moved from one collection to another, the URI is updated.
"""
super().after_insert(db) super().after_insert(db)
for child in self.pages: if not hasattr(self, 'members'):
return
for child in self.members:
obj = BackReference.dereference(child, db) obj = BackReference.dereference(child, db)
obj.uri = f"{self.uri}/{obj.stub}" obj.uri = f"{self.uri}/{obj.name}"
child = db.save(obj) child = db.save(obj)
def get_child(self, obj: Record): def get_child(self, obj: Record):
for page in self.pages: for page in self.members:
if page.uid == obj.uid: if page.uid == obj.uid:
return page return page
return None return None
class User(Page):
"""
A website user, editable as a wiki page.
"""
@classmethod
def fields(cls):
return [
field
for field in [
*super().fields(),
Field("email", unique=True)
] if field.name != "members"
]
class Group(Page):
"""
A set of users, editable as a wiki page.
"""
class NPC(Page):
"""
An NPC, editable as a wiki page.
"""

View File

@ -12,8 +12,8 @@
</head> </head>
<body> <body>
<nav> <nav>
{% for uri, stub in breadcrumbs %} {% for uri, name in breadcrumbs %}
<span>&nbsp; / <a href="{{ app.config.VIEW_URI }}{{ uri }}">{{ stub }}</a></span> <span>&nbsp; / <a href="{{ app.config.VIEW_URI }}{{ uri }}">{{ name }}</a></span>
{% endfor %} {% endfor %}
</nav> </nav>
<nav> <nav>

View File

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block menu %}
{% for child in page.pages %}
<li><a href="{{ app.config['VIEW_URI'] }}/{{ child.uri }}">{{ child.title }}</a></li>
{% endfor %}
{% endblock %}
{% block content %}
<h1>Create: {{ page.title }}</h1>
<h2>{{ page.uri }}</h2>
{{ page.body }}
<pre>
{{ page }}
</pre>
<pre>
{{ app.web.config }}
</pre>
{% endblock %}

View File

@ -1,8 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block menu %} {% block menu %}
{% for child in page.pages %} {% for member in page.members %}
<li><a href="{{ app.config.VIEW_URI }}{{ child.uri }}">{{ child.title }}</a></li> <li><a href="{{ app.config.VIEW_URI }}{{ member.uri }}">{{ member.name }}</a></li>
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -1,8 +1,7 @@
from flask import Response, render_template, request from flask import Response, render_template, request
from tinydb import where from tinydb import where
from ttfrog import app, schema from ttfrog import app, schema, forms
from ttfrog.forms import PageForm
STATIC = ["static"] STATIC = ["static"]
@ -15,30 +14,43 @@ def relative_uri(path: str = ""):
return (path or request.path).replace(app.config.VIEW_URI, "", 1).strip("/") or "/" return (path or request.path).replace(app.config.VIEW_URI, "", 1).strip("/") or "/"
def get_parent(uri: str): def get_parent(table: str, uri: str):
try: try:
parent_uri = uri.strip("/").split("/", -1)[0] parent_uri = uri.strip("/").split("/", -1)[0]
except IndexError: except IndexError:
return None return None
app.web.logger.debug(f"Looking for parent with {parent_uri = }")
return get_page(parent_uri or "/", create_okay=False) return get_page(parent_uri, table=table if '/' in parent_uri else 'Page', create_okay=False)
def get_page(path: str = "", create_okay: bool = False): def get_page(path: str = "", table: str = "Page", create_okay: bool = False):
""" """
Get one page, including its subpages, but not recursively. Get one page, including its members, but not recursively.
""" """
uri = path or relative_uri(request.path) uri = path.strip("/") or relative_uri(request.path)
matches = app.db.Page.search(where("uri") == uri, recurse=False) app.web.logger.debug(f"Need a page in {table} for {uri = }")
app.web.logger.debug(f"Found {len(matches)} pages where {uri = }") if table not in app.db.tables():
return None
matches = app.db.table(table).search(where("uri") == uri, recurse=False)
if not matches: if not matches:
if not create_okay: if not create_okay:
return None return None
return schema.Page(stub=uri.split("/")[-1], body="This page does not exist", parent=get_parent(uri)) return getattr(schema, table)(
uids = [pointer.split("::")[-1] for pointer in matches[0].pages] name=uri.split("/")[-1],
subpages = app.db.Page.search(where("uid").one_of(uids), recurse=False) body="This page does not exist",
matches[0].pages = subpages parent=get_parent(table, uri)
return matches[0] )
page = matches[0]
if hasattr(page, 'members'):
subpages = []
for pointer in matches[0].members:
table, uid = pointer.split("::")
subpages += app.db.table(table).search(where("uid") == uid, recurse=False)
page.members = subpages
return page
def rendered(page: schema.Record, template: str = "page.html"): def rendered(page: schema.Record, template: str = "page.html"):
@ -53,15 +65,15 @@ def get_static(path):
def breadcrumbs(): def breadcrumbs():
""" """
Return (uri, stub) pairs for the parents leading from the VIEW_URI to the current request. Return (uri, name) pairs for the parents leading from the VIEW_URI to the current request.
""" """
if app.config.VIEW_URI != "/": if app.config.VIEW_URI != "/":
root = get_page() root = get_page()
yield (app.config.VIEW_URI, root.stub) yield (app.config.VIEW_URI, root.name)
uri = "" uri = ""
for stub in relative_uri().split("/"): for name in relative_uri().split("/"):
uri = "/".join([uri, stub]).lstrip("/") uri = "/".join([uri, name]).lstrip("/")
yield (uri, stub) yield (uri, name)
@app.web.route("/") @app.web.route("/")
@ -69,31 +81,32 @@ def index():
return rendered(get_page(create_okay=False)) return rendered(get_page(create_okay=False))
@app.web.route(f"{app.config.VIEW_URI}/<path:path>", methods=["GET"]) @app.web.route(f"{app.config.VIEW_URI}/<path:table>/<path:path>", methods=["GET"])
def view(path): @app.web.route(f"{app.config.VIEW_URI}/<path:path>", methods=["GET"], defaults={'table': 'Page'})
return rendered(get_page(request.path, create_okay=True)) def view(table, path):
return rendered(get_page(request.path, table=table, create_okay=True))
@app.web.route(f"{app.config.VIEW_URI}/<path:path>", methods=["POST"]) @app.web.route(f"{app.config.VIEW_URI}/<path:table>/<path:path>", methods=["POST"])
def edit(path): @app.web.route(f"{app.config.VIEW_URI}/<path:path>", methods=["POST"], defaults={'table': 'Page'})
def edit(table, path):
uri = relative_uri() uri = relative_uri()
parent = get_parent(uri) parent = get_parent(table, uri)
app.web.logger.debug(f"Handling form submission: {uri = }, {parent = }, {request.form = }")
if not parent: if not parent:
return Response(f"Parent for {uri} does not exist.", status=403) return Response(f"Parent for {uri} does not exist.", status=403)
page = get_page(uri, create_okay=True) # get or create the docoument at this uri
app.web.logger.debug(f"Editing {page.doc_id} for {uri = }") page = get_page(uri, table=table, create_okay=True)
save_data = getattr(forms, table)(page, request.form).prepare()
# editing existing document
if page.doc_id: if page.doc_id:
if page.uid != request.form["uid"]: if page.uid != request.form["uid"]:
return Response("Invalid UID.", status=403) return Response("Invalid UID.", status=403)
return rendered(app.db.save(save_data))
form = PageForm(page, request.form) # saving a new document
page = app.db.save(form.prepare()) return rendered(app.add_member(parent, save_data))
app.web.logger.debug(f"Saved {page.doc_id}; now updating parent {parent.doc_id}")
parent.pages.append(page)
app.db.save(parent)
return get_page(stub)
@app.web.after_request @app.web.after_request