diff --git a/pyproject.toml b/pyproject.toml index 13c0936..c910ebe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" - - diff --git a/ttfrog/assets/public/static/styles.css b/ttfrog/assets/static/styles.css similarity index 100% rename from ttfrog/assets/public/static/styles.css rename to ttfrog/assets/static/styles.css diff --git a/ttfrog/assets/templates/base.html b/ttfrog/assets/templates/base.html new file mode 100644 index 0000000..b8e3bad --- /dev/null +++ b/ttfrog/assets/templates/base.html @@ -0,0 +1,13 @@ + + + + {{ config['project_name'] }}{% block title %}{% endblock %} + + + {% block headers %}{% endblock %} + + + {% block content %}{% endblock %} + {% block debug %}{% endblock %} + + diff --git a/ttfrog/assets/templates/character_sheet.html b/ttfrog/assets/templates/character_sheet.html index e4a0c11..d1c1ac6 100644 --- a/ttfrog/assets/templates/character_sheet.html +++ b/ttfrog/assets/templates/character_sheet.html @@ -1,15 +1,35 @@ -
+{% extends "base.html" %} + +{% block content %} +
-

{{ character.name }}

-{{ form.display(value=character) }} -{{ flash }} +

{{ record.name }}

-
-{{ character }}
-
+
+ {{ form.csrf_token }} + + {% if 'process' in form.errors %} + Error: {{ form.errors['process'] |join(',') }} + {% endif %} +
    +
  • {{form.name.label}}: {{ form.name }} {{form.errors['name'] | join(',') }}
  • +
  • {{form.level.label}}: {{ form.level }} {{form.errors['level'] | join(',') }} +
+ +
+{% endblock %} + +{% block debug %} +

Debug

+

Record

+
{{ record }}
+

Config

+
{{ config }}
+{% endblock %} diff --git a/ttfrog/db/base.py b/ttfrog/db/base.py new file mode 100644 index 0000000..e3c8de8 --- /dev/null +++ b/ttfrog/db/base.py @@ -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] diff --git a/ttfrog/db/bootstrap.py b/ttfrog/db/bootstrap.py index 6b7a27c..2847cfe 100644 --- a/ttfrog/db/bootstrap.py +++ b/ttfrog/db/bootstrap.py @@ -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}") diff --git a/ttfrog/db/manager.py b/ttfrog/db/manager.py index d81a220..0be22a2 100644 --- a/ttfrog/db/manager.py +++ b/ttfrog/db/manager.py @@ -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. diff --git a/ttfrog/db/schema.py b/ttfrog/db/schema.py index bf831be..0f5113a 100644 --- a/ttfrog/db/schema.py +++ b/ttfrog/db/schema.py @@ -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}) diff --git a/ttfrog/webserver/application.py b/ttfrog/webserver/application.py index 1e0bbff..dd2faeb 100644 --- a/ttfrog/webserver/application.py +++ b/ttfrog/webserver/application.py @@ -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 diff --git a/ttfrog/webserver/controllers/__init__.py b/ttfrog/webserver/controllers/__init__.py index e69de29..4ae38d0 100644 --- a/ttfrog/webserver/controllers/__init__.py +++ b/ttfrog/webserver/controllers/__init__.py @@ -0,0 +1,4 @@ +from .base import BaseController +from .character_sheet import CharacterSheet + +__all__ = [BaseController, CharacterSheet] diff --git a/ttfrog/webserver/controllers/base.py b/ttfrog/webserver/controllers/base.py index 88aa42c..48a592a 100644 --- a/ttfrog/webserver/controllers/base.py +++ b/ttfrog/webserver/controllers/base.py @@ -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() diff --git a/ttfrog/webserver/controllers/character_sheet.py b/ttfrog/webserver/controllers/character_sheet.py index 8ece14b..9a4ae6f 100644 --- a/ttfrog/webserver/controllers/character_sheet.py +++ b/ttfrog/webserver/controllers/character_sheet.py @@ -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() diff --git a/ttfrog/webserver/controllers/root.py b/ttfrog/webserver/controllers/root.py deleted file mode 100644 index 6790ce5..0000000 --- a/ttfrog/webserver/controllers/root.py +++ /dev/null @@ -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)) diff --git a/ttfrog/webserver/routes.py b/ttfrog/webserver/routes.py index b91c70d..2ac0eb9 100644 --- a/ttfrog/webserver/routes.py +++ b/ttfrog/webserver/routes.py @@ -1,2 +1,3 @@ def routes(config): config.add_route('index', '/') + config.add_route('sheet', '/sheet/{slug}/{name}', factory='ttfrog.webserver.controllers.CharacterSheet') diff --git a/ttfrog/webserver/views.py b/ttfrog/webserver/views.py index 73383ed..3b115e3 100644 --- a/ttfrog/webserver/views.py +++ b/ttfrog/webserver/views.py @@ -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()