rewrite using pyramid and wtforms

This commit is contained in:
evilchili 2024-01-31 22:39:54 -08:00
parent 5faf5c97c1
commit 3444f83c91
15 changed files with 234 additions and 163 deletions

View File

@ -10,19 +10,16 @@ packages = [
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" 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" python-dotenv = "^0.21.0"
typer = "^0.9.0"
rich = "^13.7.0" rich = "^13.7.0"
jinja2 = "^3.1.3" sqlalchemy = "^2.0.25"
tw2-forms = "^2.2.6" pyramid = "^2.0.2"
mako = "^1.3.0" pyramid-tm = "^2.5"
pyramid-jinja2 = "^2.10"
#"tg.devtools" = "^2.4.3" pyramid-sqlalchemy = "^1.6"
#repoze-who = "^3.0.0" wtforms-sqlalchemy = "^0.4.1"
transaction = "^4.0"
[build-system] [build-system]
@ -32,5 +29,3 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts] [tool.poetry.scripts]
ttfrog = "ttfrog.cli:app" ttfrog = "ttfrog.cli:app"

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ config['project_name'] }}{% block title %}{% endblock %}</title>
<meta name="og:provider_name" content="{{ config['project_name'] }}">
<link rel='stylesheet' href="{{config['static_url']}}/styles.css" />
{% block headers %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
{% block debug %}{% endblock %}
</body>
</html>

View File

@ -1,15 +1,35 @@
<div style='float:left; max-width:20%'> {% extends "base.html" %}
{% block content %}
<div style='float:left; max-width:20%;height: 90%'>
<ul> <ul>
{% for char in all_characters %} {% for char in all_characters %}
<li><a href="/sheet/{{char['slug']}}/{{char['name']}}">{{ char['name'] }}</a></li> <li><a href="/sheet/{{char['slug']}}/{{char['name']}}">{{ char['name'] }}</a></li>
{% endfor %} {% endfor %}
</ul>
</div> </div>
<div> <div>
<h1>{{ character.name }}</h1> <h1>{{ record.name }}</h1>
{{ form.display(value=character) }}
{{ flash }}
<pre> <form name="character_sheet" method="post" novalidate class="form">
{{ character }} {{ form.csrf_token }}
</pre>
{% if 'process' in form.errors %}
Error: {{ form.errors['process'] |join(',') }}
{% endif %}
<ul>
<li>{{form.name.label}}: {{ form.name }} {{form.errors['name'] | join(',') }}</li>
<li>{{form.level.label}}: {{ form.level }} {{form.errors['level'] | join(',') }}<//li>
</ul>
<button type="submit">Submit</button>
</form>
</div> </div>
{% endblock %}
{% block debug %}
<h2>Debug</h2>
<h3>Record</h3>
<pre>{{ record }}</pre>
<h3>Config</h3>
<pre>{{ config }}</pre>
{% endblock %}

55
ttfrog/db/base.py Normal file
View File

@ -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]

View File

@ -5,6 +5,7 @@ from ttfrog.db.manager import db
from ttfrog.db import schema from ttfrog.db import schema
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.inspection import inspect
# move this to json or whatever # move this to json or whatever
data = { data = {
@ -28,16 +29,14 @@ def bootstrap():
model = getattr(schema, table) model = getattr(schema, table)
for rec in records: for rec in records:
with transaction.manager as tx:
obj = model(**rec) obj = model(**rec)
try:
with db.transaction():
db.session.add(obj) db.session.add(obj)
obj.slug = db.slugify(rec) obj.slug = db.slugify(rec)
try:
tx.commit()
except IntegrityError as e: except IntegrityError as e:
tx.abort()
if 'UNIQUE constraint failed' in str(e): if 'UNIQUE constraint failed' in str(e):
logging.info(f"Skipping existing {table} {rec}") logging.info(f"Skipping existing {table} {obj}")
continue continue
raise raise
logging.info(f"Created {table} {rec}") logging.info(f"Created {table} {obj}")

View File

@ -1,8 +1,8 @@
import transaction import transaction
import base64 import base64
import hashlib import hashlib
import logging
from contextlib import contextmanager
from functools import cached_property from functools import cached_property
from pyramid_sqlalchemy import Session from pyramid_sqlalchemy import Session
@ -10,7 +10,7 @@ from pyramid_sqlalchemy import init_sqlalchemy
from pyramid_sqlalchemy import metadata as _metadata from pyramid_sqlalchemy import metadata as _metadata
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError # from sqlalchemy.exc import IntegrityError
from ttfrog.path import database from ttfrog.path import database
import ttfrog.db.schema import ttfrog.db.schema
@ -30,7 +30,7 @@ class SQLDatabaseManager:
def engine(self): def engine(self):
return create_engine(self.url) return create_engine(self.url)
@cached_property @property
def session(self): def session(self):
return Session return Session
@ -42,31 +42,19 @@ class SQLDatabaseManager:
def tables(self): def tables(self):
return dict((t.name, t) for t in self.metadata.sorted_tables) 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): def query(self, *args, **kwargs):
return self.session.query(*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: def slugify(self, rec: dict) -> str:
""" """
Create a uniquish slug from a dictionary. Create a uniquish slug from a dictionary.

View File

@ -1,17 +1,15 @@
from sqlalchemy import MetaData
from sqlalchemy import Table
from sqlalchemy import Column from sqlalchemy import Column
from sqlalchemy import Integer from sqlalchemy import Integer
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy import UnicodeText
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy import CheckConstraint from sqlalchemy import CheckConstraint
# from sqlalchemy import PrimaryKeyConstraint # from sqlalchemy import PrimaryKeyConstraint
# from sqlalchemy import DateTime # from sqlalchemy import DateTime
from pyramid_sqlalchemy import BaseObject from ttfrog.db.base import Bases
class Ancestry(BaseObject):
class Ancestry(*Bases):
__tablename__ = "ancestry" __tablename__ = "ancestry"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
@ -19,17 +17,17 @@ class Ancestry(BaseObject):
slug = Column(String, index=True, unique=True) slug = Column(String, index=True, unique=True)
class Character(BaseObject): class Character(*Bases):
__tablename__ = "character" __tablename__ = "character"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
slug = Column(String, index=True, unique=True) slug = Column(String, index=True, unique=True)
ancestry = Column(String, ForeignKey("ancestry.name")) ancestry = Column(String, ForeignKey("ancestry.name"), nullable=False)
name = Column(String) name = Column(String(255), nullable=False)
level = Column(Integer, CheckConstraint('level > 0 AND level <= 20')) level = Column(Integer, nullable=False, info={'min': 1, 'max': 20})
str = Column(Integer, CheckConstraint('str >=0')) str = Column(Integer, info={'min': 1})
dex = Column(Integer, CheckConstraint('dex >=0')) dex = Column(Integer, info={'min': 1})
con = Column(Integer, CheckConstraint('con >=0')) con = Column(Integer, info={'min': 1})
int = Column(Integer, CheckConstraint('int >=0')) int = Column(Integer, info={'min': 1})
wis = Column(Integer, CheckConstraint('wis >=0')) wis = Column(Integer, info={'min': 1})
cha = Column(Integer, CheckConstraint('cha >=0')) cha = Column(Integer, info={'min': 1})

View File

@ -10,9 +10,14 @@ from ttfrog.webserver.routes import routes
def configuration(): def configuration():
config = Configurator(settings={ config = Configurator(settings={
'sqlalchemy.url': db.url, 'sqlalchemy.url': db.url,
'jinja2.directories': 'ttfrog.assets:templates/'
}) })
config.include('pyramid_tm') config.include('pyramid_tm')
config.include('pyramid_sqlalchemy') 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 return config

View File

@ -0,0 +1,4 @@
from .base import BaseController
from .character_sheet import CharacterSheet
__all__ = [BaseController, CharacterSheet]

View File

@ -1,19 +1,74 @@
import inspect import logging
from collections import defaultdict
from tg import flash from wtforms_sqlalchemy.orm import model_form
from tg import TGController
from tg import tmpl_context from ttfrog.db.manager import db
from markupsafe import Markup
class BaseController(TGController): class BaseController:
model = None
def _before(self, *args, **kwargs): def __init__(self, request):
tmpl_context.project_name = 'TableTop Frog' 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: def output(self, **kwargs) -> dict:
return dict( return dict(
page=inspect.stack()[1].function, config=self.config,
flash=Markup(flash.render('flash', use_js=False)), request=self.request,
record=self.record,
form=self.form(),
**self.attrs,
**kwargs, **kwargs,
) )
def response(self):
return self.output()

View File

@ -1,72 +1,20 @@
import base64
import hashlib
import logging import logging
from tg import expose from ttfrog.webserver.controllers import BaseController
from tg import flash from ttfrog.db.manager import db
from tg import validate
from tg.controllers.util import redirect
from ttfrog.db import db
from ttfrog.db.schema import Character from ttfrog.db.schema import Character
from ttfrog.webserver.controllers.base import BaseController
from ttfrog.webserver.widgets import CharacterSheet
class CharacterSheetController(BaseController): class CharacterSheet(BaseController):
@expose() model = Character
def _lookup(self, *parts):
slug = parts[0] if parts else ''
return FormController(slug), parts[1:] if len(parts) > 1 else []
def configure(self):
class FormController(BaseController): self.attrs['all_characters'] = db.query(Character).all()
slug = self.request.matchdict.get('slug', None)
def __init__(self, slug: str):
super().__init__()
self.character = dict()
if slug: if slug:
self.load_from_slug(slug) try:
self.record = db.query(Character).filter(Character.slug == slug)[0]
@property except IndexError:
def uri(self): logging.warning(f"Could not load record with slug {slug}")
if self.character:
return f"/sheet/{self.character['slug']}/{self.character['name']}"
else: else:
return None self.load_from_id()
@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,
)

View File

@ -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))

View File

@ -1,2 +1,3 @@
def routes(config): def routes(config):
config.add_route('index', '/') config.add_route('index', '/')
config.add_route('sheet', '/sheet/{slug}/{name}', factory='ttfrog.webserver.controllers.CharacterSheet')

View File

@ -8,3 +8,9 @@ from ttfrog.db.schema import Ancestry
def index(request): def index(request):
ancestries = [a.name for a in db.session.query(Ancestry).all()] ancestries = [a.name for a in db.session.query(Ancestry).all()]
return Response(','.join(ancestries)) return Response(','.join(ancestries))
@view_config(route_name='sheet', renderer='character_sheet.html')
def sheet(request):
sheet = request.context
return sheet.response()