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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{{ c.config.project_name }}{% block title %}{% endblock %}</title>
|
<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 }}">
|
<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 %}
|
{% block headers %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id='content'>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
{% block debug %}{% endblock %}
|
{% 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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -5,27 +5,71 @@
|
||||||
|
|
||||||
{{ build_list(c) }}
|
{{ build_list(c) }}
|
||||||
|
|
||||||
<div style='float:left;'>
|
<div id='character_sheet'>
|
||||||
<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 }}
|
|
||||||
<ul>
|
<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 %}
|
{% 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>
|
<li>{{ field.label }}: {{ field }} {{ field.errors|join(',') }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{{ c.form.save }} {{ c.form.delete }}
|
</div>
|
||||||
</form>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
{{ c.form.save }} {{ c.form.delete }}
|
||||||
|
</div>
|
||||||
|
{{ c.form.csrf_token }}
|
||||||
|
</form>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block debug %}
|
{% block debug %}
|
||||||
<div style='clear:both;display:block;'>
|
<div style='clear:both;display:block;'>
|
||||||
<h2>Debug</h2>
|
<h2>Debug</h2>
|
||||||
<pre>
|
<code>
|
||||||
{{ c }}
|
{{ c }}
|
||||||
</pre>
|
</code>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
{% macro build_list(c) %}
|
{% macro build_list(c) %}
|
||||||
<div style='float:left; min-height: 90%; margin-right:5em;'>
|
<ul class='nav'>
|
||||||
<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.name }}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
|
@ -75,7 +75,6 @@ def setup(context: typer.Context):
|
||||||
bootstrap()
|
bootstrap()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def serve(
|
def serve(
|
||||||
context: typer.Context,
|
context: typer.Context,
|
||||||
|
@ -98,8 +97,10 @@ def serve(
|
||||||
|
|
||||||
# delay loading the app until we have configured our environment
|
# delay loading the app until we have configured our environment
|
||||||
from ttfrog.webserver import application
|
from ttfrog.webserver import application
|
||||||
|
from ttfrog.db.bootstrap import bootstrap
|
||||||
|
|
||||||
print("Starting TableTop Frog server...")
|
print("Starting TableTop Frog server...")
|
||||||
|
bootstrap()
|
||||||
application.start(host=host, port=port, debug=debug)
|
application.start(host=host, port=port, debug=debug)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
import nanoid
|
import nanoid
|
||||||
from nanoid_dictionary import human_alphabet
|
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 Column
|
||||||
from sqlalchemy import String
|
from sqlalchemy import String
|
||||||
|
from sqlalchemy import String
|
||||||
|
from pyramid_sqlalchemy import BaseObject
|
||||||
|
from slugify import slugify
|
||||||
|
|
||||||
|
|
||||||
def genslug():
|
def genslug():
|
||||||
|
@ -38,5 +36,31 @@ class IterableMixin:
|
||||||
return f"{self.__class__.__name__}: {str(dict(self))}"
|
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):
|
# class Table(*Bases):
|
||||||
Bases = [BaseObject, IterableMixin, SlugMixin]
|
Bases = [BaseObject, IterableMixin, SlugMixin]
|
||||||
|
|
|
@ -7,13 +7,70 @@ from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
# move this to json or whatever
|
# move this to json or whatever
|
||||||
data = {
|
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': [
|
'Ancestry': [
|
||||||
{'name': 'human'},
|
{'name': 'human', 'creature_type': 'humanoid'},
|
||||||
{'name': 'dragonborn'},
|
{'name': 'dragonborn', 'creature_type': 'humanoid'},
|
||||||
{'name': 'tiefling'},
|
{'name': 'tiefling', 'creature_type': 'humanoid'},
|
||||||
],
|
],
|
||||||
'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',
|
||||||
|
'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 Column
|
||||||
from sqlalchemy import Integer
|
from sqlalchemy import Integer
|
||||||
from sqlalchemy import String
|
from sqlalchemy import String
|
||||||
from sqlalchemy import ForeignKey
|
from sqlalchemy import ForeignKey
|
||||||
# from sqlalchemy import PrimaryKeyConstraint
|
from sqlalchemy import Enum
|
||||||
# from sqlalchemy import DateTime
|
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):
|
STATS = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']
|
||||||
__tablename__ = "ancestry"
|
|
||||||
|
|
||||||
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):
|
def __repr__(self):
|
||||||
return str(self.name)
|
return str(self.name)
|
||||||
|
|
||||||
|
|
||||||
class Character(*Bases):
|
class Proficiency(*Bases):
|
||||||
__tablename__ = "character"
|
__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)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
ancestry = Column(String, ForeignKey("ancestry.name"), nullable=False)
|
ancestry = Column(String, ForeignKey("ancestry.name"), nullable=False)
|
||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20})
|
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})
|
str = Column(Integer, info={'min': 0, 'max': 30})
|
||||||
dex = Column(Integer, info={'min': 0, 'max': 30})
|
dex = Column(Integer, info={'min': 0, 'max': 30})
|
||||||
con = Column(Integer, info={'min': 0, 'max': 30})
|
con = Column(Integer, info={'min': 0, 'max': 30})
|
||||||
int = Column(Integer, info={'min': 0, 'max': 30})
|
int = Column(Integer, info={'min': 0, 'max': 30})
|
||||||
wis = Column(Integer, info={'min': 0, 'max': 30})
|
wis = Column(Integer, info={'min': 0, 'max': 30})
|
||||||
cha = 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.httpexceptions import HTTPFound
|
||||||
from pyramid.interfaces import IRoutesMapper
|
from pyramid.interfaces import IRoutesMapper
|
||||||
from wtforms.fields import SelectField
|
|
||||||
|
|
||||||
from ttfrog.attribute_map import AttributeMap
|
from ttfrog.attribute_map import AttributeMap
|
||||||
from ttfrog.db.manager import db
|
from ttfrog.db.manager import db
|
||||||
|
from ttfrog.db import transaction_log
|
||||||
|
|
||||||
|
|
||||||
def get_all_routes(request):
|
def get_all_routes(request):
|
||||||
|
@ -26,12 +26,6 @@ def get_all_routes(request):
|
||||||
return routes
|
return routes
|
||||||
|
|
||||||
|
|
||||||
class DeferredSelectField(SelectField):
|
|
||||||
def __init__(self, *args, model=None, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.choices = db.query(model).all()
|
|
||||||
|
|
||||||
|
|
||||||
class BaseController:
|
class BaseController:
|
||||||
model = None
|
model = None
|
||||||
model_form = None
|
model_form = None
|
||||||
|
@ -77,6 +71,12 @@ class BaseController:
|
||||||
self._form = self.model_form(obj=self.record)
|
self._form = self.model_form(obj=self.record)
|
||||||
return self._form
|
return self._form
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resources(self):
|
||||||
|
return [
|
||||||
|
{'type': 'style', 'uri': 'css/styles.css'},
|
||||||
|
]
|
||||||
|
|
||||||
def configure_for_model(self):
|
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()
|
||||||
|
@ -89,6 +89,7 @@ class BaseController:
|
||||||
form=self.form,
|
form=self.form,
|
||||||
record=self.record,
|
record=self.record,
|
||||||
routes=get_all_routes(self.request),
|
routes=get_all_routes(self.request),
|
||||||
|
resources=self.resources,
|
||||||
**self.attrs,
|
**self.attrs,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
@ -99,7 +100,9 @@ class BaseController:
|
||||||
return
|
return
|
||||||
if not self.form.validate():
|
if not self.form.validate():
|
||||||
return
|
return
|
||||||
|
previous = dict(self.record)
|
||||||
self.form.populate_obj(self.record)
|
self.form.populate_obj(self.record)
|
||||||
|
transaction_log.record(previous, self.record)
|
||||||
if self.record.id:
|
if self.record.id:
|
||||||
return
|
return
|
||||||
with db.transaction():
|
with db.transaction():
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
from ttfrog.webserver.controllers.base import BaseController, DeferredSelectField
|
from ttfrog.webserver.controllers.base import BaseController
|
||||||
from ttfrog.db.schema import Character, Ancestry
|
from ttfrog.webserver.forms import DeferredSelectField, DeferredSelectMultipleField
|
||||||
|
from ttfrog.db.schema import Character, Ancestry, CharacterClass, STATS
|
||||||
from wtforms_alchemy import ModelForm
|
from wtforms_alchemy import ModelForm
|
||||||
from wtforms.fields import SubmitField
|
from wtforms.fields import SubmitField, SelectMultipleField
|
||||||
|
from wtforms.widgets import Select
|
||||||
|
|
||||||
|
|
||||||
class CharacterForm(ModelForm):
|
class CharacterForm(ModelForm):
|
||||||
|
@ -11,9 +13,25 @@ class CharacterForm(ModelForm):
|
||||||
|
|
||||||
save = SubmitField()
|
save = SubmitField()
|
||||||
delete = 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):
|
class CharacterSheet(BaseController):
|
||||||
model = CharacterForm.Meta.model
|
model = CharacterForm.Meta.model
|
||||||
model_form = CharacterForm
|
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 ttfrog.db.manager import db
|
||||||
from db.schema import Character
|
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 DeferredSelectField(SelectField):
|
||||||
class Meta:
|
def __init__(self, *args, model=None, **kwargs):
|
||||||
model = Character
|
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