added transaction log, UX scaffolding
This commit is contained in:
parent
2dcaa3fac6
commit
da6255a86a
112
ttfrog/assets/static/css/styles.css
Normal file
112
ttfrog/assets/static/css/styles.css
Normal file
|
@ -0,0 +1,112 @@
|
|||
body {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
#content {
|
||||
margin: 1rem auto;
|
||||
max-width:1280px;
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #000055;
|
||||
}
|
||||
a:visited, a:active {
|
||||
color: #000055;
|
||||
}
|
||||
|
||||
|
||||
ul.nav {
|
||||
background: #e7e7e7;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
list-style-type: none;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
ul.nav li {
|
||||
display: inline;
|
||||
text-align: center;
|
||||
background: #FFF;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
#character_sheet {
|
||||
width:50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#character_sheet .statblock {
|
||||
width: 100%;
|
||||
margin-bottom:3rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 250px;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
#character_sheet .banner {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
#character_sheet .banner #portrait {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #e7e7e7;
|
||||
}
|
||||
|
||||
|
||||
#character_sheet h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#character_sheet .sidebar {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
#character_sheet input,
|
||||
#character_sheet select,
|
||||
#character_sheet textarea {
|
||||
font-weight: bold;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.stats .cards {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-auto-rows: auto;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
.sidebar .cards {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
grid-gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cards .card {
|
||||
border: 2px solid #e7e7e7;
|
||||
border-radius: 4px;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.card .label {
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card input {
|
||||
font-size: 1.25em;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
31
ttfrog/assets/static/js/character_sheet.js
Normal file
31
ttfrog/assets/static/js/character_sheet.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
function proficiency() {
|
||||
return parseInt(document.getElementById('proficiency_bonus').innerHTML);
|
||||
}
|
||||
|
||||
function bonus(stat) {
|
||||
return parseInt(document.getElementById(stat + '_bonus').innerHTML);
|
||||
}
|
||||
|
||||
function setStatBonus(stat) {
|
||||
var score = document.getElementById(stat).value;
|
||||
var bonus = Math.floor((score - 10) / 2);
|
||||
document.getElementById(stat + '_bonus').innerHTML = bonus;
|
||||
}
|
||||
|
||||
function setProficiencyBonus() {
|
||||
var score = document.getElementById('level').value;
|
||||
var bonus = Math.ceil(1 + (0.25 * score));
|
||||
document.getElementById('proficiency_bonus').innerHTML = bonus;
|
||||
}
|
||||
|
||||
function setSpellSaveDC() {
|
||||
var score = 8 + proficiency() + bonus('wis');
|
||||
document.getElementById('spell_save_dc').innerHTML = score;
|
||||
}
|
||||
|
||||
(function () {
|
||||
const stats = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||
stats.forEach(setStatBonus);
|
||||
setProficiencyBonus();
|
||||
setSpellSaveDC();
|
||||
})();
|
|
@ -2,12 +2,26 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ c.config.project_name }}{% block title %}{% endblock %}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="og:provider_name" content="{{ c.config.project_name }}">
|
||||
<link rel='stylesheet' href="{{c.routes.static}}/styles.css" />
|
||||
{% for resource in c.resources %}
|
||||
<link rel='preload' href="{{c.routes.static}}/{{resource['uri']}}" as="{{resource['type']}}"/>
|
||||
{% if resource['type'] == 'style' %}
|
||||
<link rel='stylesheet' href="{{c.routes.static}}/{{resource['uri']}}" />
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% block headers %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div id='content'>
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% block debug %}{% endblock %}
|
||||
{% for resource in c.resources %}
|
||||
{% if resource['type'] == 'script' %}
|
||||
<script type="text/javascript" src="{{c.routes.static}}/{{resource['uri']}}"></script>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -5,27 +5,71 @@
|
|||
|
||||
{{ build_list(c) }}
|
||||
|
||||
<div style='float:left;'>
|
||||
<h1>{{ c['record'].name }}</h1>
|
||||
|
||||
<div id='character_sheet'>
|
||||
<form name="character_sheet" method="post" novalidate class="form">
|
||||
{{ c.form.csrf_token }}
|
||||
|
||||
<div class='banner'>
|
||||
<div><img id='portrait' /></div>
|
||||
<div>
|
||||
<h1>{{ c.record.name }}</h1>
|
||||
{{ c.form.ancestry }} {{ c.record.character_class|join(' / ') }} Level {{ c.form.level }}
|
||||
</div>
|
||||
</div>
|
||||
<div class='statblock'>
|
||||
<div class='stats'>
|
||||
<div class='cards'>
|
||||
{% for stat in ['str', 'dex', 'con', 'int', 'wis', 'cha'] %}
|
||||
<div class='card'>
|
||||
<div class='label'>{{ c.form[stat].label }}</div>
|
||||
{{ c.form[stat] }}
|
||||
<div id='{{stat}}_bonus'></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class='card'>
|
||||
<div class='label'>AC</div>
|
||||
{{ c.form.armor_class }}
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Initiative: <span id='initiative'>3</span></li>
|
||||
<li>Proficiency Bonus: <span id='proficiency_bonus'></span></li>
|
||||
<li>Spell Save DC: <span id='spell_save_dc'></span></li>
|
||||
<li>Saving Throws: <span id='saving_throws'>{{ c.record.saving_throws |join(', ') }}</span></li>
|
||||
<li>Skills: <span id='skills'>{{ c.record.skills |join(', ') }}</span></li>
|
||||
{% for field in c.form %}
|
||||
{% if field.name not in ['save', 'delete'] %}
|
||||
{% if field.name in ['proficiencies', 'speed', 'passive_perception', 'passive_insight', 'passive_investigation'] %}
|
||||
<li>{{ field.label }}: {{ field }} {{ field.errors|join(',') }}</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{{ c.form.save }} {{ c.form.delete }}
|
||||
</form>
|
||||
</div>
|
||||
<div class='sidebar'>
|
||||
<div class='cards'>
|
||||
<div class='card'>
|
||||
<div class='label'>HP</div>
|
||||
{{ c.form.max_hit_points }} / {{ c.form.hit_points }}
|
||||
</div>
|
||||
<div class='card'>
|
||||
<div class='label'>TEMP HP</div>
|
||||
{{ c.form.temp_hit_points }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
{{ c.form.save }} {{ c.form.delete }}
|
||||
</div>
|
||||
{{ c.form.csrf_token }}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block debug %}
|
||||
<div style='clear:both;display:block;'>
|
||||
<h2>Debug</h2>
|
||||
<pre>
|
||||
<code>
|
||||
{{ c }}
|
||||
</pre>
|
||||
</code>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
{% macro build_list(c) %}
|
||||
<div style='float:left; min-height: 90%; margin-right:5em;'>
|
||||
<ul>
|
||||
<ul class='nav'>
|
||||
<li><a href="{{ c.routes.sheet }}">Create a Character</a></li>
|
||||
{% 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.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
|
|
@ -75,7 +75,6 @@ def setup(context: typer.Context):
|
|||
bootstrap()
|
||||
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
context: typer.Context,
|
||||
|
@ -98,8 +97,10 @@ def serve(
|
|||
|
||||
# delay loading the app until we have configured our environment
|
||||
from ttfrog.webserver import application
|
||||
from ttfrog.db.bootstrap import bootstrap
|
||||
|
||||
print("Starting TableTop Frog server...")
|
||||
bootstrap()
|
||||
application.start(host=host, port=port, debug=debug)
|
||||
|
||||
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
import nanoid
|
||||
from nanoid_dictionary import human_alphabet
|
||||
|
||||
from pyramid_sqlalchemy import BaseObject
|
||||
from wtforms import validators
|
||||
from slugify import slugify
|
||||
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import String
|
||||
from pyramid_sqlalchemy import BaseObject
|
||||
from slugify import slugify
|
||||
|
||||
|
||||
def genslug():
|
||||
|
@ -38,5 +36,31 @@ class IterableMixin:
|
|||
return f"{self.__class__.__name__}: {str(dict(self))}"
|
||||
|
||||
|
||||
def multivalue_string_factory(name, column=Column(String), separator=';'):
|
||||
"""
|
||||
Generate a mixin class that adds a string column with getters and setters
|
||||
that convert list values to strings and back again. Equivalent to:
|
||||
|
||||
class MultiValueString:
|
||||
_name = column
|
||||
|
||||
@property
|
||||
def name_property(self):
|
||||
return self._name.split(';')
|
||||
|
||||
@name.setter
|
||||
def name(self, val):
|
||||
return ';'.join(val)
|
||||
"""
|
||||
attr = f"_{name}"
|
||||
prop = property(lambda self: getattr(self, attr).split(separator))
|
||||
setter = prop.setter(lambda self, val: setattr(self, attr, separator.join(val)))
|
||||
return type('MultiValueString', (object, ), {
|
||||
attr: column,
|
||||
f"{name}_property": prop,
|
||||
name: setter,
|
||||
})
|
||||
|
||||
|
||||
# class Table(*Bases):
|
||||
Bases = [BaseObject, IterableMixin, SlugMixin]
|
||||
|
|
|
@ -7,13 +7,70 @@ from sqlalchemy.exc import IntegrityError
|
|||
|
||||
# move this to json or whatever
|
||||
data = {
|
||||
'CharacterClass': [
|
||||
{
|
||||
'name': 'fighter',
|
||||
'hit_dice': '1d10',
|
||||
'hit_dice_stat': 'CON',
|
||||
'proficiencies': 'all armor, all shields, simple weapons, martial weapons',
|
||||
'saving_throws': ['STR, CON'],
|
||||
'skills': ['Acrobatics', 'Animal Handling', 'Athletics', 'History', 'Insight', 'Intimidation', 'Perception', 'Survival'],
|
||||
},
|
||||
{
|
||||
'name': 'rogue',
|
||||
'hit_dice': '1d8',
|
||||
'hit_dice_stat': 'DEX',
|
||||
'proficiencies': 'simple weapons, hand crossbows, longswords, rapiers, shortswords',
|
||||
'saving_throws': ['DEX', 'INT'],
|
||||
'skills': ['Acrobatics', 'Athletics', 'Deception', 'Insight', 'Intimidation', 'Investigation', 'Perception', 'Performance', 'Persuasion', 'Sleight of Hand', 'Stealth'],
|
||||
},
|
||||
],
|
||||
'Skill': [
|
||||
{'name': 'Acrobatics'},
|
||||
{'name': 'Animal Handling'},
|
||||
{'name': 'Athletics'},
|
||||
{'name': 'Deception'},
|
||||
{'name': 'History'},
|
||||
{'name': 'Insight'},
|
||||
{'name': 'Intimidation'},
|
||||
{'name': 'Investigation'},
|
||||
{'name': 'Perception'},
|
||||
{'name': 'Performance'},
|
||||
{'name': 'Persuasion'},
|
||||
{'name': 'Sleight of Hand'},
|
||||
{'name': 'Stealth'},
|
||||
{'name': 'Survival'},
|
||||
],
|
||||
'Ancestry': [
|
||||
{'name': 'human'},
|
||||
{'name': 'dragonborn'},
|
||||
{'name': 'tiefling'},
|
||||
{'name': 'human', 'creature_type': 'humanoid'},
|
||||
{'name': 'dragonborn', 'creature_type': 'humanoid'},
|
||||
{'name': 'tiefling', 'creature_type': 'humanoid'},
|
||||
],
|
||||
'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',
|
||||
'character_class': ['fighter', 'rogue'],
|
||||
'level': 1,
|
||||
'armor_class': 10,
|
||||
'max_hit_points': 14,
|
||||
'hit_points': 14,
|
||||
'temp_hit_points': 0,
|
||||
'passive_perception': 10,
|
||||
'passive_insight': 10,
|
||||
'passive_investigation': 10,
|
||||
'speed': '30 ft.',
|
||||
'str': 16,
|
||||
'dex': 12,
|
||||
'con': 18,
|
||||
'int': 11,
|
||||
'wis': 12,
|
||||
'cha': 8,
|
||||
'proficiencies': 'all armor, all shields, simple weapons, martial weapons',
|
||||
'saving_throws': ['STR', 'CON'],
|
||||
'skills': ['Acrobatics', 'Animal Handling'],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
@ -1,32 +1,97 @@
|
|||
import enum
|
||||
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import Integer
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import ForeignKey
|
||||
# from sqlalchemy import PrimaryKeyConstraint
|
||||
# from sqlalchemy import DateTime
|
||||
from sqlalchemy import Enum
|
||||
from sqlalchemy import Text
|
||||
|
||||
from ttfrog.db.base import Bases
|
||||
from ttfrog.db.base import Bases, BaseObject, IterableMixin
|
||||
from ttfrog.db.base import multivalue_string_factory
|
||||
|
||||
|
||||
class Ancestry(*Bases):
|
||||
__tablename__ = "ancestry"
|
||||
STATS = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']
|
||||
|
||||
name = Column(String, primary_key=True, unique=True)
|
||||
CREATURE_TYPES = ['aberation', 'beast', 'celestial', 'construct', 'dragon', 'elemental', 'fey', 'fiend', 'Giant',
|
||||
'humanoid', 'monstrosity', 'ooze', 'plant', 'undead']
|
||||
|
||||
# enums for db schemas
|
||||
StatsEnum = enum.Enum("StatsEnum", ((k, k) for k in STATS))
|
||||
CreatureTypesEnum = enum.Enum("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES))
|
||||
|
||||
CharacterClassMixin = multivalue_string_factory('character_class', Column(String, nullable=False))
|
||||
SavingThrowsMixin = multivalue_string_factory('saving_throws')
|
||||
SkillsMixin = multivalue_string_factory('skills')
|
||||
|
||||
|
||||
class Skill(*Bases):
|
||||
__tablename__ = "skill"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, index=True, unique=True)
|
||||
description = Column(Text)
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.name)
|
||||
|
||||
|
||||
class Character(*Bases):
|
||||
__tablename__ = "character"
|
||||
class Proficiency(*Bases):
|
||||
__tablename__ = "proficiency"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, index=True, unique=True)
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.name)
|
||||
|
||||
|
||||
class Ancestry(*Bases):
|
||||
__tablename__ = "ancestry"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, index=True, unique=True)
|
||||
creature_type = Column(Enum(CreatureTypesEnum))
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.name)
|
||||
|
||||
|
||||
class CharacterClass(*Bases, SavingThrowsMixin, SkillsMixin):
|
||||
__tablename__ = "character_class"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, index=True, unique=True)
|
||||
hit_dice = Column(String, default='1d6')
|
||||
hit_dice_stat = Column(Enum(StatsEnum))
|
||||
proficiencies = Column(String)
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.name)
|
||||
|
||||
|
||||
class Character(*Bases, CharacterClassMixin, SavingThrowsMixin, SkillsMixin):
|
||||
__tablename__ = "character"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
ancestry = Column(String, ForeignKey("ancestry.name"), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20})
|
||||
armor_class = Column(Integer, nullable=False, info={'min': 1, 'max': 99})
|
||||
hit_points = Column(Integer, nullable=False, info={'min': 0, 'max': 999})
|
||||
max_hit_points = Column(Integer, nullable=False, info={'min': 0, 'max': 999})
|
||||
temp_hit_points = Column(Integer, nullable=False, info={'min': 0})
|
||||
passive_perception = Column(Integer, nullable=False)
|
||||
passive_insight = Column(Integer, nullable=False)
|
||||
passive_investigation = Column(Integer, nullable=False)
|
||||
speed = Column(String, nullable=False, default="30 ft.")
|
||||
str = Column(Integer, info={'min': 0, 'max': 30})
|
||||
dex = Column(Integer, info={'min': 0, 'max': 30})
|
||||
con = Column(Integer, info={'min': 0, 'max': 30})
|
||||
int = Column(Integer, info={'min': 0, 'max': 30})
|
||||
wis = Column(Integer, info={'min': 0, 'max': 30})
|
||||
cha = Column(Integer, info={'min': 0, 'max': 30})
|
||||
proficiencies = Column(String)
|
||||
|
||||
|
||||
class TransactionLog(BaseObject, IterableMixin):
|
||||
__tablename__ = "transaction_log"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source_table_name = Column(String, index=True, nullable=False)
|
||||
primary_key = Column(Integer, index=True)
|
||||
diff = Column(Text)
|
||||
|
|
36
ttfrog/db/transaction_log.py
Normal file
36
ttfrog/db/transaction_log.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from ttfrog.db.manager import db
|
||||
from ttfrog.db.schema import TransactionLog
|
||||
|
||||
|
||||
def record(previous, new):
|
||||
diff = list((set(previous.items()) ^ set(dict(new).items())))
|
||||
if not diff:
|
||||
return
|
||||
|
||||
rec = TransactionLog(
|
||||
source_table_name=new.__tablename__,
|
||||
primary_key=new.id,
|
||||
diff=json.dumps(diff),
|
||||
)
|
||||
with db.transaction():
|
||||
db.add(rec)
|
||||
logging.debug(f"Saved restore point: {dict(rec)}")
|
||||
return rec
|
||||
|
||||
|
||||
def restore(rec, log_id=None):
|
||||
if log_id:
|
||||
log = db.query(TransactionLog).filter_by(id=log_id).one()
|
||||
else:
|
||||
log = db.query(TransactionLog).filter_by(source_table_name=rec.__tablename__, primary_key=rec.id).one()
|
||||
logging.debug(f"Located restore point {log = }")
|
||||
diff = json.loads(log.diff)
|
||||
updates = dict(diff[::2])
|
||||
if not updates:
|
||||
return
|
||||
logging.debug(f"{updates = }")
|
||||
with db.transaction():
|
||||
db.query(db.tables[log.source_table_name]).update(updates)
|
|
@ -5,10 +5,10 @@ from collections import defaultdict
|
|||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
from pyramid.interfaces import IRoutesMapper
|
||||
from wtforms.fields import SelectField
|
||||
|
||||
from ttfrog.attribute_map import AttributeMap
|
||||
from ttfrog.db.manager import db
|
||||
from ttfrog.db import transaction_log
|
||||
|
||||
|
||||
def get_all_routes(request):
|
||||
|
@ -26,12 +26,6 @@ def get_all_routes(request):
|
|||
return routes
|
||||
|
||||
|
||||
class DeferredSelectField(SelectField):
|
||||
def __init__(self, *args, model=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.choices = db.query(model).all()
|
||||
|
||||
|
||||
class BaseController:
|
||||
model = None
|
||||
model_form = None
|
||||
|
@ -77,6 +71,12 @@ class BaseController:
|
|||
self._form = self.model_form(obj=self.record)
|
||||
return self._form
|
||||
|
||||
@property
|
||||
def resources(self):
|
||||
return [
|
||||
{'type': 'style', 'uri': 'css/styles.css'},
|
||||
]
|
||||
|
||||
def configure_for_model(self):
|
||||
if 'all_records' not in self.attrs:
|
||||
self.attrs['all_records'] = db.query(self.model).all()
|
||||
|
@ -89,6 +89,7 @@ class BaseController:
|
|||
form=self.form,
|
||||
record=self.record,
|
||||
routes=get_all_routes(self.request),
|
||||
resources=self.resources,
|
||||
**self.attrs,
|
||||
**kwargs,
|
||||
)
|
||||
|
@ -99,7 +100,9 @@ class BaseController:
|
|||
return
|
||||
if not self.form.validate():
|
||||
return
|
||||
previous = dict(self.record)
|
||||
self.form.populate_obj(self.record)
|
||||
transaction_log.record(previous, self.record)
|
||||
if self.record.id:
|
||||
return
|
||||
with db.transaction():
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
from ttfrog.webserver.controllers.base import BaseController, DeferredSelectField
|
||||
from ttfrog.db.schema import Character, Ancestry
|
||||
from ttfrog.webserver.controllers.base import BaseController
|
||||
from ttfrog.webserver.forms import DeferredSelectField, DeferredSelectMultipleField
|
||||
from ttfrog.db.schema import Character, Ancestry, CharacterClass, STATS
|
||||
from wtforms_alchemy import ModelForm
|
||||
from wtforms.fields import SubmitField
|
||||
from wtforms.fields import SubmitField, SelectMultipleField
|
||||
from wtforms.widgets import Select
|
||||
|
||||
|
||||
class CharacterForm(ModelForm):
|
||||
|
@ -11,9 +13,25 @@ class CharacterForm(ModelForm):
|
|||
|
||||
save = SubmitField()
|
||||
delete = SubmitField()
|
||||
ancestry = DeferredSelectField('Ancestry', model=Ancestry, coerce=str, validate_choice=True)
|
||||
|
||||
ancestry = DeferredSelectField('Ancestry', model=Ancestry, validate_choice=True, widget=Select())
|
||||
|
||||
character_class = DeferredSelectMultipleField(
|
||||
'CharacterClass',
|
||||
model=CharacterClass,
|
||||
validate_choice=True,
|
||||
# option_widget=Select(multiple=True)
|
||||
)
|
||||
|
||||
saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS)
|
||||
|
||||
|
||||
class CharacterSheet(BaseController):
|
||||
model = CharacterForm.Meta.model
|
||||
model_form = CharacterForm
|
||||
|
||||
@property
|
||||
def resources(self):
|
||||
return super().resources + [
|
||||
{'type': 'script', 'uri': 'js/character_sheet.js'},
|
||||
]
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
from wtforms_alchemy import ModelForm
|
||||
from db.schema import Character
|
||||
from ttfrog.db.manager import db
|
||||
from wtforms.fields import SelectField, SelectMultipleField
|
||||
|
||||
class DeferredSelectMultipleField(SelectMultipleField):
|
||||
def __init__(self, *args, model=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.choices = db.query(model).all()
|
||||
|
||||
class CharacterForm(ModelForm):
|
||||
class Meta:
|
||||
model = Character
|
||||
class DeferredSelectField(SelectField):
|
||||
def __init__(self, *args, model=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.choices = db.query(model).all()
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import tw2.core as twc
|
||||
import tw2.forms
|
||||
from ttfrog.db import db
|
||||
|
||||
|
||||
class CharacterSheet(tw2.forms.Form):
|
||||
action = ''
|
||||
|
||||
class child(tw2.forms.TableLayout):
|
||||
name = tw2.forms.TextField(validator=twc.Required)
|
||||
level = tw2.forms.SingleSelectField(
|
||||
prompt_text=None,
|
||||
options=range(1, 21),
|
||||
validator=twc.validation.IntValidator(min=1, max=20)
|
||||
)
|
||||
ancestry_name = tw2.forms.SingleSelectField(
|
||||
label='Ancestry',
|
||||
prompt_text=None,
|
||||
options=twc.Deferred(lambda: [a.name for a in db.query(db.ancestry)]),
|
||||
validator=twc.Required,
|
||||
)
|
Loading…
Reference in New Issue
Block a user