rewrite using pyramid and wtforms
This commit is contained in:
parent
5faf5c97c1
commit
3444f83c91
|
@ -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"
|
||||
|
||||
|
||||
|
|
13
ttfrog/assets/templates/base.html
Normal file
13
ttfrog/assets/templates/base.html
Normal 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>
|
|
@ -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
55
ttfrog/db/base.py
Normal 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]
|
|
@ -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)
|
||||
try:
|
||||
with db.transaction():
|
||||
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}")
|
||||
logging.info(f"Skipping existing {table} {obj}")
|
||||
continue
|
||||
raise
|
||||
logging.info(f"Created {table} {rec}")
|
||||
logging.info(f"Created {table} {obj}")
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
from .base import BaseController
|
||||
from .character_sheet import CharacterSheet
|
||||
|
||||
__all__ = [BaseController, CharacterSheet]
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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))
|
|
@ -1,2 +1,3 @@
|
|||
def routes(config):
|
||||
config.add_route('index', '/')
|
||||
config.add_route('sheet', '/sheet/{slug}/{name}', factory='ttfrog.webserver.controllers.CharacterSheet')
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue
Block a user