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

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>
{% for char in all_characters %}
<li><a href="/sheet/{{char['slug']}}/{{char['name']}}">{{ char['name'] }}</a></li>
{% endfor %}
</ul>
</div>
<div>
<h1>{{ character.name }}</h1>
{{ form.display(value=character) }}
{{ flash }}
<h1>{{ record.name }}</h1>
<pre>
{{ character }}
</pre>
<form name="character_sheet" method="post" novalidate class="form">
{{ form.csrf_token }}
{% 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>
{% 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 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}")

View File

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

View File

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

View File

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

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

View File

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

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):
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):
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()