diff --git a/pyproject.toml b/pyproject.toml index cbd385b..1b59fd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ transaction = "^4.0" unicode-slugify = "^0.1.5" nanoid = "^2.0.0" nanoid-dictionary = "^2.4.0" +wtforms-alchemy = "^0.18.0" [build-system] diff --git a/ttfrog/assets/templates/base.html b/ttfrog/assets/templates/base.html index 4bd1add..8f3f407 100644 --- a/ttfrog/assets/templates/base.html +++ b/ttfrog/assets/templates/base.html @@ -1,9 +1,9 @@ - {{ c['config']['project_name'] }}{% block title %}{% endblock %} - - + {{ c.config.project_name }}{% block title %}{% endblock %} + + {% block headers %}{% endblock %} diff --git a/ttfrog/assets/templates/character_sheet.html b/ttfrog/assets/templates/character_sheet.html index a1100d3..16220f8 100644 --- a/ttfrog/assets/templates/character_sheet.html +++ b/ttfrog/assets/templates/character_sheet.html @@ -9,13 +9,13 @@

{{ c['record'].name }}

- {{ c['form'].csrf_token }} + {{ c.form.csrf_token }} - {% if 'process' in c['form'].errors %} - Error: {{ c['form'].errors['process'] |join(',') }} + {% if 'process' in c.form.errors %} + Error: {{ c.form.errors.process |join(',') }} {% endif %} diff --git a/ttfrog/assets/templates/list.html b/ttfrog/assets/templates/list.html index 595d317..0032986 100644 --- a/ttfrog/assets/templates/list.html +++ b/ttfrog/assets/templates/list.html @@ -1,9 +1,9 @@ {% macro build_list(c) %}
diff --git a/ttfrog/attribute_map.py b/ttfrog/attribute_map.py new file mode 100644 index 0000000..77207e7 --- /dev/null +++ b/ttfrog/attribute_map.py @@ -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) diff --git a/ttfrog/db/base.py b/ttfrog/db/base.py index 7a3ec75..f38e295 100644 --- a/ttfrog/db/base.py +++ b/ttfrog/db/base.py @@ -8,6 +8,7 @@ from slugify import slugify from sqlalchemy import Column from sqlalchemy import String + def genslug(): return nanoid.generate(human_alphabet[2:], 5) @@ -37,38 +38,5 @@ class IterableMixin: 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, SlugMixin] +Bases = [BaseObject, IterableMixin, SlugMixin] diff --git a/ttfrog/db/bootstrap.py b/ttfrog/db/bootstrap.py index 25bd9d7..ef38a83 100644 --- a/ttfrog/db/bootstrap.py +++ b/ttfrog/db/bootstrap.py @@ -8,9 +8,9 @@ from sqlalchemy.exc import IntegrityError # move this to json or whatever data = { 'Ancestry': [ - {'id': 1, 'name': 'human'}, - {'id': 2, 'name': 'dragonborn'}, - {'id': 3, 'name': 'tiefling'}, + {'name': 'human'}, + {'name': 'dragonborn'}, + {'name': 'tiefling'}, ], 'Character': [ {'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: with db.transaction(): db.session.add(obj) + logging.info(f"Created {table} {obj}") 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}") diff --git a/ttfrog/db/schema.py b/ttfrog/db/schema.py index 8987fb7..63f32b5 100644 --- a/ttfrog/db/schema.py +++ b/ttfrog/db/schema.py @@ -11,8 +11,10 @@ from ttfrog.db.base import Bases class Ancestry(*Bases): __tablename__ = "ancestry" - id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String, index=True, unique=True) + name = Column(String, primary_key=True, unique=True) + + def __repr__(self): + return str(self.name) class Character(*Bases): diff --git a/ttfrog/webserver/controllers/ancestry.py b/ttfrog/webserver/controllers/ancestry.py new file mode 100644 index 0000000..a608e1b --- /dev/null +++ b/ttfrog/webserver/controllers/ancestry.py @@ -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 diff --git a/ttfrog/webserver/controllers/base.py b/ttfrog/webserver/controllers/base.py index 52d1043..a595545 100644 --- a/ttfrog/webserver/controllers/base.py +++ b/ttfrog/webserver/controllers/base.py @@ -1,19 +1,23 @@ import logging import re -from collections import defaultdict -from wtforms_sqlalchemy.orm import model_form +from collections import defaultdict from pyramid.httpexceptions import HTTPFound from pyramid.interfaces import IRoutesMapper +from sqlalchemy.inspection import inspect + +from ttfrog.attribute_map import AttributeMap from ttfrog.db.manager import db def get_all_routes(request): + routes = { + 'static': '/static', + } uri_pattern = re.compile(r"^([^\{\*]+)") mapper = request.registry.queryUtility(IRoutesMapper) - routes = {} for route in mapper.get_routes(): if route.name.startswith('__'): continue @@ -23,89 +27,93 @@ def get_all_routes(request): return routes +def query_factory(model): + return lambda: db.query(model).all() + + class BaseController: model = None + model_form = None def __init__(self, request): self.request = request self.attrs = defaultdict(str) - self.record = None - self.form = None - self.model_form = None + self._slug = None + self._record = None + self._form = None self.config = { 'static_url': '/static', 'project_name': 'TTFROG' } 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: return - if not self.model_form: - self.model_form = model_form(self.model, db_session=db.session) - if not self.record: - self.record = self.get_record_from_slug() + if not self._form: + if self.request.POST: + self._form = self.model_form(self.request.POST, obj=self.record) + else: + self._form = self.model_form(obj=self.record) + return self._form + def configure_for_model(self): if 'all_records' not in self.attrs: self.attrs['all_records'] = db.query(self.model).all() - def configure(self): - pass + def coerce_foreign_keys(self): + 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): - if not self.model: - return - 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, - request=self.request, - form=self.form, - record=self.record, - routes=get_all_routes(self.request), - **self.attrs, - **kwargs, - )) + def template_context(self, **kwargs) -> dict: + return AttributeMap.from_dict({ + 'c': dict( + config=self.config, + request=self.request, + form=self.form, + record=self.record, + routes=get_all_routes(self.request), + **self.attrs, + **kwargs, + ) + }) def response(self): - if self.process_form(): - return HTTPFound(location=f"{self.request.current_route_path}/{self.record.uri}") - return self.output() + if not (self.request.POST and self.form): + return + 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) diff --git a/ttfrog/webserver/controllers/character_sheet.py b/ttfrog/webserver/controllers/character_sheet.py index c239135..28d419a 100644 --- a/ttfrog/webserver/controllers/character_sheet.py +++ b/ttfrog/webserver/controllers/character_sheet.py @@ -1,6 +1,22 @@ -from ttfrog.webserver.controllers import BaseController -from ttfrog.db.schema import Character +from ttfrog.webserver.controllers.base import BaseController, query_factory +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): - model = Character + model = CharacterForm.Meta.model + model_form = CharacterForm diff --git a/ttfrog/webserver/forms.py b/ttfrog/webserver/forms.py new file mode 100644 index 0000000..c0c5c41 --- /dev/null +++ b/ttfrog/webserver/forms.py @@ -0,0 +1,7 @@ +from wtforms_alchemy import ModelForm +from db.schema import Character + + +class CharacterForm(ModelForm): + class Meta: + model = Character diff --git a/ttfrog/webserver/views.py b/ttfrog/webserver/views.py index 3b115e3..d2addf1 100644 --- a/ttfrog/webserver/views.py +++ b/ttfrog/webserver/views.py @@ -12,5 +12,5 @@ def index(request): @view_config(route_name='sheet', renderer='character_sheet.html') def sheet(request): - sheet = request.context - return sheet.response() + controller = request.context + return controller.response() or controller.template_context()