from __future__ import annotations from datetime import datetime from typing import List from grung.types import BackReference, Collection, DateTime, Dict, Field, Password, Pointer, Record, Timestamp from tinydb import where 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 def fields(cls): # fmt: off return [ *super().fields(), # Pick up the UID and whatever other non-optional fields exist Field("uri", unique=True), # The URI for the page, relative to the app's VIEW_URI Field("name"), # The portion of the URI after the last / Field("title"), # The page title Field("body"), # The main content blob of the page Collection("members", Page), # The pages that exist below this page's URI 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("created"), # When the page was created Timestamp("last_modified"), # The last time the page was modified. Dict("acl"), ] # fmt: on 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) now = datetime.utcnow() if not self.doc_id and self.created < now: self.created = now if not self.name and not self.title: raise Exception("Must provide either a name or a title!") if not self.name: self.name = self.title.title().replace(" ", "") if not self.title: self.title = self.name self.uri = (self.parent.uri + "/" if self.parent and self.parent.uri != "/" else "") + self.name 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) if not hasattr(self, "members"): return for child in self.members: obj = BackReference.dereference(child, db) obj.uri = f"{self.uri}/{obj.name}" child = db.save(obj) def add_member(self, child: Record): from ttfrog import app app.check_state() self.members = list(set(self.members + [app.db.save(child)])) app.db.save(self) return self.get_child(child) def get_child(self, obj: Record): for page in self.members: if page.uid == obj.uid: return page return None def set_permissions(self, entity: Entity, permissions: List) -> str: from ttfrog import app app.check_state() perms = "".join(permissions) self.acl[entity.reference] = perms app.db.save(self) return perms def get_acl_for_entity(self, entity) -> (str, str | None): """ Search upward through the page hierarchy looking for one with an ACL that either has a grant for the entity we care about, or at least one group in which the entity is a member. """ from ttfrog import app app.check_state() def find_acl(obj): if hasattr(obj, "acl"): if entity.reference in obj.acl: return {entity.reference: obj.acl[entity.reference]} group_grants = {} for ref, grant in obj.acl.items(): if ref.startswith("Group::"): group = app.db.Group.get(where("uid") == ref.split("::")[1], recurse=False) if entity.reference in group.members: group_grants[ref] = grant if group_grants: return group_grants if hasattr(obj, "parent"): return find_acl(obj.parent) return {"": ""} return find_acl(self) class Entity(Page): def has_permission(self, record: Record, requested: str) -> bool | None: for entity, grants in record.get_acl_for_entity(self).items(): if requested in grants: return True return False def can_read(self, record: Record): return self.has_permission(record, Permissions.READ) def can_write(self, record: Record): return self.has_permission(record, Permissions.WRITE) def can_delete(self, record: Record): return self.has_permission(record, Permissions.DELETE) class User(Entity): """ A website user, editable as a wiki page. """ def check_credentials(self, username: str, password: str) -> bool: return username == self.name and self._metadata.fields["password"].compare(password, self.password) @classmethod def fields(cls): return [ field for field in [ *super().fields(), Field("email", unique=True), Password("password"), ] if field.name != "members" ] class Group(Entity): """ A set of users, editable as a wiki page. """ class NPC(Page): """ An NPC, editable as a wiki page. """ class Permissions(Record): READ = "r" WRITE = "w" DELETE = "d" @classmethod def fields(cls): return [*super().fields(), Pointer("entity", Entity), Field("grants")]