diff --git a/ttfrog/assets/templates/character_sheet.html b/ttfrog/assets/templates/character_sheet.html index 1dd43d1..e4a0c11 100644 --- a/ttfrog/assets/templates/character_sheet.html +++ b/ttfrog/assets/templates/character_sheet.html @@ -1,5 +1,15 @@ +
+
+

{{ character.name }}

{{ form.display(value=character) }} +{{ flash }} +
 {{ character }}
 
+
diff --git a/ttfrog/db/bootstrap.py b/ttfrog/db/bootstrap.py index 37dec18..a8c0cb2 100644 --- a/ttfrog/db/bootstrap.py +++ b/ttfrog/db/bootstrap.py @@ -1,5 +1,3 @@ -import base64 -import hashlib import logging from ttfrog.db import db, session @@ -18,17 +16,6 @@ data = { } -def slug_from_rec(rec): - """ - Create a uniquish slug from a dictionary. - """ - sha1bytes = hashlib.sha1(str(rec).encode()) - return '-'.join([ - base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10], - rec.get('name', '') # will need to normalize this for URLs - ]) - - def bootstrap(): """ Initialize the database with source data. Idempotent; will skip anything that already exists. @@ -39,13 +26,17 @@ def bootstrap(): logging.debug("No bootstrap data for table {table_name}; skipping.") continue for rec in data[table_name]: - if 'slug' in table.columns: - rec['slug'] = slug_from_rec(rec) stmt = table.insert().values(**rec).prefix_with("OR IGNORE") - result = session.execute(stmt) - session.commit() - last_id = result.inserted_primary_key[0] - if last_id == 0: + result, error = db.execute(stmt) + if error: + raise RuntimeError(error) + + rec['id'] = result.inserted_primary_key[0] + if rec['id'] == 0: logging.info(f"Skipped existing {table_name} {rec}") - else: - logging.info(f"Created {table_name} {result.inserted_primary_key[0]}: {rec}") + continue + + if 'slug' in table.columns: + rec['slug'] = db.slugify(rec) + db.update(table, **rec) + logging.info(f"Created {table_name} {rec}") diff --git a/ttfrog/db/manager.py b/ttfrog/db/manager.py index 12cdf22..54bab4e 100644 --- a/ttfrog/db/manager.py +++ b/ttfrog/db/manager.py @@ -1,8 +1,12 @@ -from functools import cached_property +import base64 +import hashlib import logging +from functools import cached_property + from sqlalchemy import create_engine from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.exc import IntegrityError from ttfrog.path import database from ttfrog.db.schema import metadata @@ -32,17 +36,39 @@ class SQLDatabaseManager: def query(self, *args, **kwargs): return self.DBSession.query(*args, **kwargs) + def execute(self, statement) -> tuple: + logging.debug(statement) + result = None + error = None + try: + result = self.DBSession.execute(statement) + self.DBSession.commit() + except IntegrityError as exc: + logging.error(exc) + error = "An error occurred when saving changes." + return result, error + + def insert(self, table, **kwargs) -> tuple: + stmt = table.insert().values(**kwargs) + return self.execute(stmt) + def update(self, table, **kwargs): - stmt = table.update().values(**kwargs) - logging.debug(stmt) - result = self.DBSession.execute(stmt) - self.DBSession.commit() - return result + primary_key = kwargs.pop('id') + stmt = table.update().values(**kwargs).where(table.columns.id == primary_key) + return self.execute(stmt) def init_model(self, engine=None): metadata.create_all(bind=engine or self.engine) return self.DBSession + def slugify(self, rec: dict) -> str: + """ + Create a uniquish slug from a dictionary. + """ + sha1bytes = hashlib.sha1(str(rec['id']).encode()) + return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10] + + def __getattr__(self, name: str): try: return self.tables[name] diff --git a/ttfrog/db/schema.py b/ttfrog/db/schema.py index 995e453..0eca673 100644 --- a/ttfrog/db/schema.py +++ b/ttfrog/db/schema.py @@ -14,7 +14,8 @@ metadata = MetaData() Ancestry = Table( "ancestry", metadata, - Column("name", String, primary_key=True), + Column("id", Integer, primary_key=True, autoincrement=True), + Column("name", String, index=True, unique=True), Column("slug", String, index=True, unique=True), Column("description", UnicodeText), ) diff --git a/ttfrog/webserver/application.py b/ttfrog/webserver/application.py index c681686..2d3a5c3 100644 --- a/ttfrog/webserver/application.py +++ b/ttfrog/webserver/application.py @@ -9,7 +9,7 @@ from wsgiref.simple_server import make_server import webhelpers2 import tw2.core -from ttfrog.webserver.controllers import RootController +from ttfrog.webserver.controllers.root import RootController from ttfrog.db import db import ttfrog.path diff --git a/ttfrog/webserver/controllers/__init__.py b/ttfrog/webserver/controllers/__init__.py index c4f55f7..e69de29 100644 --- a/ttfrog/webserver/controllers/__init__.py +++ b/ttfrog/webserver/controllers/__init__.py @@ -1,3 +0,0 @@ -from .root import RootController - -__ALL__ = [RootController] diff --git a/ttfrog/webserver/controllers/base.py b/ttfrog/webserver/controllers/base.py new file mode 100644 index 0000000..88aa42c --- /dev/null +++ b/ttfrog/webserver/controllers/base.py @@ -0,0 +1,19 @@ +import inspect + +from tg import flash +from tg import TGController +from tg import tmpl_context +from markupsafe import Markup + + +class BaseController(TGController): + + def _before(self, *args, **kwargs): + tmpl_context.project_name = 'TableTop Frog' + + def output(self, **kwargs) -> dict: + return dict( + page=inspect.stack()[1].function, + flash=Markup(flash.render('flash', use_js=False)), + **kwargs, + ) diff --git a/ttfrog/webserver/controllers/character_sheet.py b/ttfrog/webserver/controllers/character_sheet.py index 08a0e84..8ece14b 100644 --- a/ttfrog/webserver/controllers/character_sheet.py +++ b/ttfrog/webserver/controllers/character_sheet.py @@ -1,29 +1,72 @@ +import base64 +import hashlib +import logging + from tg import expose -from tg import TGController +from tg import flash +from tg import validate +from tg.controllers.util import redirect from ttfrog.db import db from ttfrog.db.schema import Character +from ttfrog.webserver.controllers.base import BaseController from ttfrog.webserver.widgets import CharacterSheet -class CharacterSheetController(TGController): +class CharacterSheetController(BaseController): @expose() def _lookup(self, *parts): - return FormController(parts[0]), parts[1:] + slug = parts[0] if parts else '' + return FormController(slug), parts[1:] if len(parts) > 1 else [] -class FormController: +class FormController(BaseController): def __init__(self, slug: str): + super().__init__() self.character = dict() if slug: - self.load(slug) + self.load_from_slug(slug) - def load(self, slug: str) -> None: + @property + def uri(self): + if self.character: + return f"/sheet/{self.character['slug']}/{self.character['name']}" + else: + return None + + @property + def all_characters(self): + return [row._mapping for row in db.query(Character).all()] + + def load_from_slug(self, slug) -> None: self.character = db.query(Character).filter(Character.columns.slug == slug)[0]._mapping + def save(self, fields) -> str: + rec = dict() + if not self.character: + result, error = db.insert(Character, **fields) + if error: + return error + fields['id'] = result.inserted_primary_key[0] + fields['slug'] = db.slugify(fields) + else: + rec = dict(**self.character) + + rec.update(**fields) + result, error = db.update(Character, **rec) + self.load_from_slug(rec['slug']) + if not error: + flash(f"{self.character['name']} updated!") + return redirect(self.uri) + flash(error) + @expose('character_sheet.html') - def _default(self, *args, **kwargs): - if kwargs: - db.update(Character, **kwargs) - self.load(self.character['slug']) - return dict(page='sheet', form=CharacterSheet, character=self.character) + @validate(form=CharacterSheet) + def _default(self, *args, **fields): + if fields: + return self.save(fields) + return self.output( + form=CharacterSheet, + character=self.character, + all_characters=self.all_characters, + ) diff --git a/ttfrog/webserver/controllers/root.py b/ttfrog/webserver/controllers/root.py index bec9a01..6790ce5 100644 --- a/ttfrog/webserver/controllers/root.py +++ b/ttfrog/webserver/controllers/root.py @@ -1,18 +1,16 @@ from tg import expose -from tg import TGController -from tg import tmpl_context + from ttfrog.db import db +from ttfrog.webserver.controllers.base import BaseController from ttfrog.webserver.controllers.character_sheet import CharacterSheetController -class RootController(TGController): +class RootController(BaseController): + # serve character sheet interface on /sheet sheet = CharacterSheetController() - def _before(self, *args, **kwargs): - tmpl_context.project_name = 'TableTop Frog' - @expose('index.html') def index(self): ancestries = [row._mapping for row in db.query(db.ancestry).all()] - return dict(page='index', content=str(ancestries)) + return self.output(content=str(ancestries)) diff --git a/ttfrog/webserver/widgets.py b/ttfrog/webserver/widgets.py index 5cb71ea..61126f4 100644 --- a/ttfrog/webserver/widgets.py +++ b/ttfrog/webserver/widgets.py @@ -1,13 +1,21 @@ -import tw2.core +import tw2.core as twc import tw2.forms -from tg import lurl +from ttfrog.db import db class CharacterSheet(tw2.forms.Form): - class child(tw2.forms.TableLayout): - name = tw2.forms.TextField() - level = tw2.forms.TextField() - ancestry_name = tw2.forms.TextField(label='Ancestry') - id = tw2.forms.HiddenField() - action = '' + + class child(tw2.forms.TableLayout): + name = tw2.forms.TextField(validator=twc.Required) + level = tw2.forms.SingleSelectField( + prompt_text=None, + options=range(1, 21), + validator=twc.validation.IntValidator(min=1, max=20) + ) + ancestry_name = tw2.forms.SingleSelectField( + label='Ancestry', + prompt_text=None, + options=twc.Deferred(lambda: [a.name for a in db.query(db.ancestry)]), + validator=twc.Required, + )