diff --git a/pyproject.toml b/pyproject.toml index 13c0936..c910ebe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,19 +10,16 @@ packages = [ [tool.poetry.dependencies] python = "^3.10" -TurboGears2 = "^2.4.3" -sqlalchemy = "^2.0.25" -tgext-admin = "^0.7.4" -webhelpers2 = "^2.0" -typer = "^0.9.0" python-dotenv = "^0.21.0" +typer = "^0.9.0" rich = "^13.7.0" -jinja2 = "^3.1.3" -tw2-forms = "^2.2.6" -mako = "^1.3.0" - -#"tg.devtools" = "^2.4.3" -#repoze-who = "^3.0.0" +sqlalchemy = "^2.0.25" +pyramid = "^2.0.2" +pyramid-tm = "^2.5" +pyramid-jinja2 = "^2.10" +pyramid-sqlalchemy = "^1.6" +wtforms-sqlalchemy = "^0.4.1" +transaction = "^4.0" [build-system] @@ -32,5 +29,3 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] ttfrog = "ttfrog.cli:app" - - diff --git a/ttfrog/assets/public/static/styles.css b/ttfrog/assets/static/styles.css similarity index 100% rename from ttfrog/assets/public/static/styles.css rename to ttfrog/assets/static/styles.css diff --git a/ttfrog/assets/templates/base.html b/ttfrog/assets/templates/base.html new file mode 100644 index 0000000..b8e3bad --- /dev/null +++ b/ttfrog/assets/templates/base.html @@ -0,0 +1,13 @@ + + +
+
-{{ character }}
-
+
{{ record }}
+{{ config }}
+{% endblock %}
diff --git a/ttfrog/db/base.py b/ttfrog/db/base.py
new file mode 100644
index 0000000..e3c8de8
--- /dev/null
+++ b/ttfrog/db/base.py
@@ -0,0 +1,55 @@
+from itertools import chain
+
+from pyramid_sqlalchemy import BaseObject
+from wtforms import validators
+
+
+class IterableMixin:
+ """
+ Allows for iterating over Model objects' column names and values
+ """
+ def __iter__(self):
+ values = vars(self)
+ for attr in self.__mapper__.columns.keys():
+ if attr in values:
+ yield attr, values[attr]
+
+ def __repr__(self):
+ return f"{self.__class__.__name__}: {str(dict(self))}"
+
+
+class FormValidatorMixin:
+ """
+ Add form validation capabilities using the .info attributes of columns.
+ """
+
+ # column.info could contain any of these keywords. define the list of validators that should apply
+ # whenever we encounter one such keyword.
+ _validators_by_keyword = {
+ 'min': [validators.NumberRange],
+ 'max': [validators.NumberRange],
+ }
+
+ @classmethod
+ def validate(cls, form):
+ for name, column in cls.__mapper__.columns.items():
+ if name not in form._fields:
+ continue
+
+ # step through the info keywords and create a deduped list of validator classes that
+ # should apply to this form field. This prevents adding unnecessary copies of the same
+ # validator when two or more keywords map to the same one.
+ extras = set()
+ for key in column.info.keys():
+ for val in cls._validators_by_keyword.get(key, []):
+ extras.add(val)
+
+ # Add an instance of every unique validator for this column to the associated form field.
+ form._fields[name].validators.extend([v(**column.info) for v in extras])
+
+ # return the results of the form validation,.
+ return form.validate()
+
+
+# class Table(*Bases):
+Bases = [BaseObject, IterableMixin, FormValidatorMixin]
diff --git a/ttfrog/db/bootstrap.py b/ttfrog/db/bootstrap.py
index 6b7a27c..2847cfe 100644
--- a/ttfrog/db/bootstrap.py
+++ b/ttfrog/db/bootstrap.py
@@ -5,6 +5,7 @@ from ttfrog.db.manager import db
from ttfrog.db import schema
from sqlalchemy.exc import IntegrityError
+from sqlalchemy.inspection import inspect
# move this to json or whatever
data = {
@@ -28,16 +29,14 @@ def bootstrap():
model = getattr(schema, table)
for rec in records:
- with transaction.manager as tx:
- obj = model(**rec)
- db.session.add(obj)
- obj.slug = db.slugify(rec)
- try:
- tx.commit()
- except IntegrityError as e:
- tx.abort()
- if 'UNIQUE constraint failed' in str(e):
- logging.info(f"Skipping existing {table} {rec}")
- continue
- raise
- logging.info(f"Created {table} {rec}")
+ obj = model(**rec)
+ try:
+ with db.transaction():
+ db.session.add(obj)
+ obj.slug = db.slugify(rec)
+ except IntegrityError as e:
+ if 'UNIQUE constraint failed' in str(e):
+ logging.info(f"Skipping existing {table} {obj}")
+ continue
+ raise
+ logging.info(f"Created {table} {obj}")
diff --git a/ttfrog/db/manager.py b/ttfrog/db/manager.py
index d81a220..0be22a2 100644
--- a/ttfrog/db/manager.py
+++ b/ttfrog/db/manager.py
@@ -1,8 +1,8 @@
import transaction
import base64
import hashlib
-import logging
+from contextlib import contextmanager
from functools import cached_property
from pyramid_sqlalchemy import Session
@@ -10,7 +10,7 @@ from pyramid_sqlalchemy import init_sqlalchemy
from pyramid_sqlalchemy import metadata as _metadata
from sqlalchemy import create_engine
-from sqlalchemy.exc import IntegrityError
+# from sqlalchemy.exc import IntegrityError
from ttfrog.path import database
import ttfrog.db.schema
@@ -30,7 +30,7 @@ class SQLDatabaseManager:
def engine(self):
return create_engine(self.url)
- @cached_property
+ @property
def session(self):
return Session
@@ -42,31 +42,19 @@ class SQLDatabaseManager:
def tables(self):
return dict((t.name, t) for t in self.metadata.sorted_tables)
+ @contextmanager
+ def transaction(self):
+ with transaction.manager as tm:
+ yield tm
+ try:
+ tm.commit()
+ except Exception:
+ tm.abort()
+ raise
+
def query(self, *args, **kwargs):
return self.session.query(*args, **kwargs)
- def execute(self, statement) -> tuple:
- logging.info(statement)
- result = None
- error = None
- try:
- with transaction.manager as tx:
- result = self.session.execute(statement)
- tx.commit()
- except IntegrityError as exc:
- logging.error(exc)
- error = "I AM ERROR."
- return result, error
-
- def insert(self, table, **kwargs) -> tuple:
- stmt = table.insert().values(**kwargs)
- return self.execute(stmt)
-
- def update(self, table, **kwargs):
- primary_key = kwargs.pop('id')
- stmt = table.update().values(**kwargs).where(table.columns.id == primary_key)
- return self.execute(stmt)
-
def slugify(self, rec: dict) -> str:
"""
Create a uniquish slug from a dictionary.
diff --git a/ttfrog/db/schema.py b/ttfrog/db/schema.py
index bf831be..0f5113a 100644
--- a/ttfrog/db/schema.py
+++ b/ttfrog/db/schema.py
@@ -1,17 +1,15 @@
-from sqlalchemy import MetaData
-from sqlalchemy import Table
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
-from sqlalchemy import UnicodeText
from sqlalchemy import ForeignKey
from sqlalchemy import CheckConstraint
# from sqlalchemy import PrimaryKeyConstraint
# from sqlalchemy import DateTime
-from pyramid_sqlalchemy import BaseObject
+from ttfrog.db.base import Bases
-class Ancestry(BaseObject):
+
+class Ancestry(*Bases):
__tablename__ = "ancestry"
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -19,17 +17,17 @@ class Ancestry(BaseObject):
slug = Column(String, index=True, unique=True)
-class Character(BaseObject):
+class Character(*Bases):
__tablename__ = "character"
id = Column(Integer, primary_key=True, autoincrement=True)
slug = Column(String, index=True, unique=True)
- ancestry = Column(String, ForeignKey("ancestry.name"))
- name = Column(String)
- level = Column(Integer, CheckConstraint('level > 0 AND level <= 20'))
- str = Column(Integer, CheckConstraint('str >=0'))
- dex = Column(Integer, CheckConstraint('dex >=0'))
- con = Column(Integer, CheckConstraint('con >=0'))
- int = Column(Integer, CheckConstraint('int >=0'))
- wis = Column(Integer, CheckConstraint('wis >=0'))
- cha = Column(Integer, CheckConstraint('cha >=0'))
+ ancestry = Column(String, ForeignKey("ancestry.name"), nullable=False)
+ name = Column(String(255), nullable=False)
+ level = Column(Integer, nullable=False, info={'min': 1, 'max': 20})
+ str = Column(Integer, info={'min': 1})
+ dex = Column(Integer, info={'min': 1})
+ con = Column(Integer, info={'min': 1})
+ int = Column(Integer, info={'min': 1})
+ wis = Column(Integer, info={'min': 1})
+ cha = Column(Integer, info={'min': 1})
diff --git a/ttfrog/webserver/application.py b/ttfrog/webserver/application.py
index 1e0bbff..dd2faeb 100644
--- a/ttfrog/webserver/application.py
+++ b/ttfrog/webserver/application.py
@@ -10,9 +10,14 @@ from ttfrog.webserver.routes import routes
def configuration():
config = Configurator(settings={
'sqlalchemy.url': db.url,
+ 'jinja2.directories': 'ttfrog.assets:templates/'
})
config.include('pyramid_tm')
config.include('pyramid_sqlalchemy')
+ config.include('pyramid_jinja2')
+ config.add_static_view(name='/static', path='ttfrog.assets:static/')
+ config.add_jinja2_renderer('.html', settings_prefix='jinja2.')
+
return config
diff --git a/ttfrog/webserver/controllers/__init__.py b/ttfrog/webserver/controllers/__init__.py
index e69de29..4ae38d0 100644
--- a/ttfrog/webserver/controllers/__init__.py
+++ b/ttfrog/webserver/controllers/__init__.py
@@ -0,0 +1,4 @@
+from .base import BaseController
+from .character_sheet import CharacterSheet
+
+__all__ = [BaseController, CharacterSheet]
diff --git a/ttfrog/webserver/controllers/base.py b/ttfrog/webserver/controllers/base.py
index 88aa42c..48a592a 100644
--- a/ttfrog/webserver/controllers/base.py
+++ b/ttfrog/webserver/controllers/base.py
@@ -1,19 +1,74 @@
-import inspect
+import logging
+from collections import defaultdict
-from tg import flash
-from tg import TGController
-from tg import tmpl_context
-from markupsafe import Markup
+from wtforms_sqlalchemy.orm import model_form
+
+from ttfrog.db.manager import db
-class BaseController(TGController):
+class BaseController:
+ model = None
- def _before(self, *args, **kwargs):
- tmpl_context.project_name = 'TableTop Frog'
+ def __init__(self, request):
+ self.request = request
+ self.record = None
+ self.attrs = defaultdict(str)
+ self.configure()
+ if self.model:
+ self.model_form = model_form(self.model, db_session=db.session)
+
+ # load this from dotenv or something
+ self.config = {
+ 'static_url': '/static',
+ 'project_name': 'TTFROG'
+ }
+
+ def configure(self):
+ self.load_from_id()
+
+
+ def load_from_id(self):
+ if not self.request.POST['id']:
+ return
+ self.record = db.query(self.model).get(self.request.POST['id'])
+
+ def form(self) -> str:
+ # no model? no form.
+ if not self.model:
+ return ''
+
+ # no user submission to process
+ if self.request.method != 'POST':
+ return self.model_form(obj=self.record)
+
+ # process submission
+ form = self.model_form(self.request.POST, obj=self.record)
+ if self.model.validate(form):
+ form.populate_obj(self.record)
+ error = self.save_changes()
+ if error:
+ form.errors['process'] = error
+ return form
+
+ def save_changes(self):
+ try:
+ with db.transaction():
+ for (key, val) in self.request.POST.items():
+ if hasattr(self.record, key):
+ setattr(self.record, key, val)
+ except Exception as e:
+ return e
+ return None
def output(self, **kwargs) -> dict:
return dict(
- page=inspect.stack()[1].function,
- flash=Markup(flash.render('flash', use_js=False)),
+ config=self.config,
+ request=self.request,
+ record=self.record,
+ form=self.form(),
+ **self.attrs,
**kwargs,
)
+
+ def response(self):
+ return self.output()
diff --git a/ttfrog/webserver/controllers/character_sheet.py b/ttfrog/webserver/controllers/character_sheet.py
index 8ece14b..9a4ae6f 100644
--- a/ttfrog/webserver/controllers/character_sheet.py
+++ b/ttfrog/webserver/controllers/character_sheet.py
@@ -1,72 +1,20 @@
-import base64
-import hashlib
import logging
-from tg import expose
-from tg import flash
-from tg import validate
-from tg.controllers.util import redirect
-from ttfrog.db import db
+from ttfrog.webserver.controllers import BaseController
+from ttfrog.db.manager import db
from ttfrog.db.schema import Character
-from ttfrog.webserver.controllers.base import BaseController
-from ttfrog.webserver.widgets import CharacterSheet
-class CharacterSheetController(BaseController):
- @expose()
- def _lookup(self, *parts):
- slug = parts[0] if parts else ''
- return FormController(slug), parts[1:] if len(parts) > 1 else []
+class CharacterSheet(BaseController):
+ model = Character
-
-class FormController(BaseController):
-
- def __init__(self, slug: str):
- super().__init__()
- self.character = dict()
+ def configure(self):
+ self.attrs['all_characters'] = db.query(Character).all()
+ slug = self.request.matchdict.get('slug', None)
if slug:
- self.load_from_slug(slug)
-
- @property
- def uri(self):
- if self.character:
- return f"/sheet/{self.character['slug']}/{self.character['name']}"
+ try:
+ self.record = db.query(Character).filter(Character.slug == slug)[0]
+ except IndexError:
+ logging.warning(f"Could not load record with slug {slug}")
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')
- @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,
- )
+ self.load_from_id()
diff --git a/ttfrog/webserver/controllers/root.py b/ttfrog/webserver/controllers/root.py
deleted file mode 100644
index 6790ce5..0000000
--- a/ttfrog/webserver/controllers/root.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from tg import expose
-
-from ttfrog.db import db
-from ttfrog.webserver.controllers.base import BaseController
-from ttfrog.webserver.controllers.character_sheet import CharacterSheetController
-
-
-class RootController(BaseController):
-
- # serve character sheet interface on /sheet
- sheet = CharacterSheetController()
-
- @expose('index.html')
- def index(self):
- ancestries = [row._mapping for row in db.query(db.ancestry).all()]
- return self.output(content=str(ancestries))
diff --git a/ttfrog/webserver/routes.py b/ttfrog/webserver/routes.py
index b91c70d..2ac0eb9 100644
--- a/ttfrog/webserver/routes.py
+++ b/ttfrog/webserver/routes.py
@@ -1,2 +1,3 @@
def routes(config):
config.add_route('index', '/')
+ config.add_route('sheet', '/sheet/{slug}/{name}', factory='ttfrog.webserver.controllers.CharacterSheet')
diff --git a/ttfrog/webserver/views.py b/ttfrog/webserver/views.py
index 73383ed..3b115e3 100644
--- a/ttfrog/webserver/views.py
+++ b/ttfrog/webserver/views.py
@@ -8,3 +8,9 @@ from ttfrog.db.schema import Ancestry
def index(request):
ancestries = [a.name for a in db.session.query(Ancestry).all()]
return Response(','.join(ancestries))
+
+
+@view_config(route_name='sheet', renderer='character_sheet.html')
+def sheet(request):
+ sheet = request.context
+ return sheet.response()