fixing form handling for relationships

This commit is contained in:
evilchili 2024-02-04 11:40:30 -08:00
parent 32d9c42847
commit 669c9b46d6
13 changed files with 206 additions and 122 deletions

View File

@ -23,6 +23,7 @@ transaction = "^4.0"
unicode-slugify = "^0.1.5" unicode-slugify = "^0.1.5"
nanoid = "^2.0.0" nanoid = "^2.0.0"
nanoid-dictionary = "^2.4.0" nanoid-dictionary = "^2.4.0"
wtforms-alchemy = "^0.18.0"
[build-system] [build-system]

View File

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

View File

@ -9,13 +9,13 @@
<h1>{{ c['record'].name }}</h1> <h1>{{ c['record'].name }}</h1>
<form name="character_sheet" method="post" novalidate class="form"> <form name="character_sheet" method="post" novalidate class="form">
{{ c['form'].csrf_token }} {{ c.form.csrf_token }}
{% if 'process' in c['form'].errors %} {% if 'process' in c.form.errors %}
Error: {{ c['form'].errors['process'] |join(',') }} Error: {{ c.form.errors.process |join(',') }}
{% endif %} {% endif %}
<ul> <ul>
{% for field in c['form'] %} {% for field in c.form %}
<li>{{ field.label }}: {{ field }} {{ field.errors|join(',') }}</li> <li>{{ field.label }}: {{ field }} {{ field.errors|join(',') }}</li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -1,9 +1,9 @@
{% macro build_list(c) %} {% macro build_list(c) %}
<div style='float:left; min-height: 90%; margin-right:5em;'> <div style='float:left; min-height: 90%; margin-right:5em;'>
<ul> <ul>
<li><a href="{{c['routes']['sheet']}}">Create a Character</a></li> <li><a href="{{ c.routes.sheet }}">Create a Character</a></li>
{% for rec in c['all_records'] %} {% for rec in c.all_records %}
<li><a href="{{c['routes']['sheet']}}/{{rec['uri']}}">{{ rec['uri'] }}</a></li> <li><a href="{{ c.routes.sheet }}/{{ rec.uri }}">{{ rec.uri }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>

70
ttfrog/attribute_map.py Normal file
View File

@ -0,0 +1,70 @@
from collections.abc import Mapping
from dataclasses import dataclass, field
@dataclass
class AttributeMap(Mapping):
"""
AttributeMap is a data class that is also a mapping, converting a dict
into an object with attributes. Example:
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
>>> amap.foo
True
>>> amap.bar
False
Instantiating an AttributeMap using the from_dict() class method will
recursively transform dictionary members sinto AttributeMaps:
>>> nested_dict = {'foo': {'bar': {'baz': True}, 'boz': False}}
>>> amap = AttributeMap.from_dict(nested_dict)
>>> amap.foo.bar.baz
True
>>> amap.foo.boz
False
The dictionary can be accessed directly via 'attributes':
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
>>> list(amap.attributes.keys()):
>>>['foo', 'bar']
Because AttributeMap is a mapping, you can use it anywhere you would use
a regular mapping, like a dict:
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
>>> 'foo' in amap
True
>>> "{foo}, {bar}".format(**amap)
True, False
"""
attributes: field(default_factory=dict)
def __getattr__(self, attr):
if attr in self.attributes:
return self.attributes[attr]
return self.__getattribute__(attr)
def __len__(self):
return len(self.attributes)
def __getitem__(self, key):
return self.attributes[key]
def __iter__(self):
return iter(self.attributes)
@classmethod
def from_dict(cls, kwargs: dict):
"""
Create a new AttributeMap object using keyword arguments. Dicts are
recursively converted to AttributeMap objects; everything else is
passed as-is.
"""
attrs = {}
for k, v in sorted(kwargs.items()):
attrs[k] = AttributeMap.from_dict(v) if type(v) is dict else v
return cls(attributes=attrs)

View File

@ -8,6 +8,7 @@ from slugify import slugify
from sqlalchemy import Column from sqlalchemy import Column
from sqlalchemy import String from sqlalchemy import String
def genslug(): def genslug():
return nanoid.generate(human_alphabet[2:], 5) return nanoid.generate(human_alphabet[2:], 5)
@ -37,38 +38,5 @@ class IterableMixin:
return f"{self.__class__.__name__}: {str(dict(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): # class Table(*Bases):
Bases = [BaseObject, IterableMixin, FormValidatorMixin, SlugMixin] Bases = [BaseObject, IterableMixin, SlugMixin]

View File

@ -8,9 +8,9 @@ from sqlalchemy.exc import IntegrityError
# move this to json or whatever # move this to json or whatever
data = { data = {
'Ancestry': [ 'Ancestry': [
{'id': 1, 'name': 'human'}, {'name': 'human'},
{'id': 2, 'name': 'dragonborn'}, {'name': 'dragonborn'},
{'id': 3, 'name': 'tiefling'}, {'name': 'tiefling'},
], ],
'Character': [ 'Character': [
{'id': 1, 'name': 'Sabetha', 'ancestry': 'tiefling', 'level': 10, 'str': 10, 'dex': 10, 'con': 10, 'int': 10, 'wis': 10, 'cha': 10}, {'id': 1, 'name': 'Sabetha', 'ancestry': 'tiefling', 'level': 10, 'str': 10, 'dex': 10, 'con': 10, 'int': 10, 'wis': 10, 'cha': 10},
@ -31,9 +31,9 @@ def bootstrap():
try: try:
with db.transaction(): with db.transaction():
db.session.add(obj) db.session.add(obj)
logging.info(f"Created {table} {obj}")
except IntegrityError as e: except IntegrityError as e:
if 'UNIQUE constraint failed' in str(e): if 'UNIQUE constraint failed' in str(e):
logging.info(f"Skipping existing {table} {obj}") logging.info(f"Skipping existing {table} {obj}")
continue continue
raise raise
logging.info(f"Created {table} {obj}")

View File

@ -11,8 +11,10 @@ from ttfrog.db.base import Bases
class Ancestry(*Bases): class Ancestry(*Bases):
__tablename__ = "ancestry" __tablename__ = "ancestry"
id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String, primary_key=True, unique=True)
name = Column(String, index=True, unique=True)
def __repr__(self):
return str(self.name)
class Character(*Bases): class Character(*Bases):

View File

@ -0,0 +1,12 @@
from ttfrog.db.schema import Ancestry
from ttfrog.db.manager import db
from wtforms_alchemy import ModelForm
class AncestryForm(ModelForm):
class Meta:
model = Ancestry
exclude = ['slug']
def get_session():
return db.session

View File

@ -1,19 +1,23 @@
import logging import logging
import re import re
from collections import defaultdict
from wtforms_sqlalchemy.orm import model_form from collections import defaultdict
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
from pyramid.interfaces import IRoutesMapper from pyramid.interfaces import IRoutesMapper
from sqlalchemy.inspection import inspect
from ttfrog.attribute_map import AttributeMap
from ttfrog.db.manager import db from ttfrog.db.manager import db
def get_all_routes(request): def get_all_routes(request):
routes = {
'static': '/static',
}
uri_pattern = re.compile(r"^([^\{\*]+)") uri_pattern = re.compile(r"^([^\{\*]+)")
mapper = request.registry.queryUtility(IRoutesMapper) mapper = request.registry.queryUtility(IRoutesMapper)
routes = {}
for route in mapper.get_routes(): for route in mapper.get_routes():
if route.name.startswith('__'): if route.name.startswith('__'):
continue continue
@ -23,79 +27,73 @@ def get_all_routes(request):
return routes return routes
def query_factory(model):
return lambda: db.query(model).all()
class BaseController: class BaseController:
model = None model = None
model_form = None
def __init__(self, request): def __init__(self, request):
self.request = request self.request = request
self.attrs = defaultdict(str) self.attrs = defaultdict(str)
self.record = None self._slug = None
self.form = None self._record = None
self.model_form = None self._form = None
self.config = { self.config = {
'static_url': '/static', 'static_url': '/static',
'project_name': 'TTFROG' 'project_name': 'TTFROG'
} }
self.configure_for_model() self.configure_for_model()
self.configure()
def configure_for_model(self): @property
def slug(self):
if not self._slug:
parts = self.request.matchdict.get('uri', '').split('-')
self._slug = parts[0].replace('/', '')
return self._slug
@property
def record(self):
if not self._record and self.model:
try:
self._record = db.query(self.model).filter(self.model.slug == self.slug)[0]
except IndexError:
logging.warning(f"Could not load record with slug {self.slug}")
self._record = self.model()
return self._record
@property
def form(self):
if not self.model: if not self.model:
return return
if not self.model_form: if not self._form:
self.model_form = model_form(self.model, db_session=db.session) if self.request.POST:
if not self.record: self._form = self.model_form(self.request.POST, obj=self.record)
self.record = self.get_record_from_slug() else:
self._form = self.model_form(obj=self.record)
return self._form
def configure_for_model(self):
if 'all_records' not in self.attrs: if 'all_records' not in self.attrs:
self.attrs['all_records'] = db.query(self.model).all() self.attrs['all_records'] = db.query(self.model).all()
def configure(self): def coerce_foreign_keys(self):
pass inspector = inspect(db.engine)
foreign_keys = inspector.get_foreign_keys(table_name=self.record.__class__.__tablename__)
for foreign_key in foreign_keys:
for col in inspector.get_columns(foreign_key['referred_table']):
if col['name'] == foreign_key['referred_columns'][0]:
col_name = foreign_key['constrained_columns'][0]
col_type = col['type'].python_type
col_value = col_type(getattr(self.record, col_name))
setattr(self.record, col_name, col_value)
def get_record_from_slug(self): def template_context(self, **kwargs) -> dict:
if not self.model: return AttributeMap.from_dict({
return 'c': dict(
parts = self.request.matchdict.get('uri', '').split('-')
if not parts:
return
slug = parts[0].replace('/', '')
if not slug:
return
try:
return db.query(self.model).filter(self.model.slug == slug)[0]
except IndexError:
logging.warning(f"Could not load record with slug {slug}")
def process_form(self):
if not self.model:
return False
if self.request.method == 'POST':
# if we haven't loaded a record, we're creating a new one
if not self.record:
self.record = self.model()
# generate a form object using the POST form data and the db record
self.form = self.model_form(self.request.POST, obj=self.record)
if self.model.validate(self.form):
# update the record. If it's a record bound to the session
# updates will be commited automatically. Otherwise we must
# add and commit the record.
self.form.populate_obj(self.record)
if not self.record.id:
with db.transaction():
db.session.add(self.record)
logging.debug(f"Added {self.record = }")
return True
return False
self.form = self.model_form(obj=self.record)
return False
def output(self, **kwargs) -> dict:
return dict(c=dict(
config=self.config, config=self.config,
request=self.request, request=self.request,
form=self.form, form=self.form,
@ -103,9 +101,19 @@ class BaseController:
routes=get_all_routes(self.request), routes=get_all_routes(self.request),
**self.attrs, **self.attrs,
**kwargs, **kwargs,
)) )
})
def response(self): def response(self):
if self.process_form(): if not (self.request.POST and self.form):
return HTTPFound(location=f"{self.request.current_route_path}/{self.record.uri}") return
return self.output() if self.form.validate():
self.form.populate_obj(self.record)
self.coerce_foreign_keys()
if not self.record.id:
with db.transaction():
db.session.add(self.record)
db.session.flush()
logging.debug(f"Added {self.record = }")
location = f"{self.request.current_route_path()}/{self.record.uri}"
return HTTPFound(location=location)

View File

@ -1,6 +1,22 @@
from ttfrog.webserver.controllers import BaseController from ttfrog.webserver.controllers.base import BaseController, query_factory
from ttfrog.db.schema import Character from ttfrog.db.schema import Character, Ancestry
from ttfrog.db.manager import db
from wtforms_alchemy import ModelForm, QuerySelectField
from wtforms.validators import InputRequired
class CharacterForm(ModelForm):
class Meta:
model = Character
exclude = ['slug']
def get_session():
return db.session
ancestry = QuerySelectField('Ancestry', validators=[InputRequired()],
query_factory=query_factory(Ancestry), get_label='name')
class CharacterSheet(BaseController): class CharacterSheet(BaseController):
model = Character model = CharacterForm.Meta.model
model_form = CharacterForm

View File

@ -0,0 +1,7 @@
from wtforms_alchemy import ModelForm
from db.schema import Character
class CharacterForm(ModelForm):
class Meta:
model = Character

View File

@ -12,5 +12,5 @@ def index(request):
@view_config(route_name='sheet', renderer='character_sheet.html') @view_config(route_name='sheet', renderer='character_sheet.html')
def sheet(request): def sheet(request):
sheet = request.context controller = request.context
return sheet.response() return controller.response() or controller.template_context()