fixing form handling for relationships
This commit is contained in:
parent
32d9c42847
commit
669c9b46d6
|
@ -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]
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
70
ttfrog/attribute_map.py
Normal 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)
|
|
@ -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]
|
||||||
|
|
|
@ -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}")
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
12
ttfrog/webserver/controllers/ancestry.py
Normal file
12
ttfrog/webserver/controllers/ancestry.py
Normal 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
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
7
ttfrog/webserver/forms.py
Normal file
7
ttfrog/webserver/forms.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
from wtforms_alchemy import ModelForm
|
||||||
|
from db.schema import Character
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Character
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in New Issue
Block a user