restructuring for poetry-slam

This commit is contained in:
evilchili
2024-03-26 00:53:21 -07:00
parent b1d7639a62
commit 78115023bb
38 changed files with 483 additions and 465 deletions
View File
View File
+252
View File
@@ -0,0 +1,252 @@
body {
width: 100%;
padding: 0;
margin: 0;
}
.disabled {
position : relative;
opacity: 0.3;
}
.disabled:after {
position :absolute;
left : 0;
top : 0;
width : 100%;
height : 100%;
content :' ';
}
#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;
}
#sheet_container {
display: block;
}
#character_sheet {
margin-bottom:3rem;
display: grid;
grid-gap: 1rem;
grid-template-columns: 1fr 1fr;
}
#sheet_container .banner {
display: grid;
grid-template-columns: 64px 1fr;
grid-gap: 1rem;
}
#sheet_container .banner #portrait {
width: 64px;
height: 64px;
background: #e7e7e7;
}
#controls {
float: right;
display: inline-block;
}
.temp_hp input {
font-size: 0.75rem !important;
}
#sheet_container h1 {
margin: 0;
}
#sheet_container .sidebar {
grid-column-start: 2;
grid-row-start: 1;
}
#sheet_container .sidebar .card {
margin-bottom: 1rem;
}
#sheet_container .sidebar ul {
list-style-type: none;
margin: 0;
padding: 0;
}
#sheet_container .sidebar ul > li {
margin: 0;
padding: 0;
}
#hp {
grid-row-start: 1;
grid-column: 7;
grid-column-end: 9;
}
#saves {
grid-row-start: 2;
grid-column: 3;
grid-column-start: span 2;
}
#proficiency {
grid-row-start: 2;
grid-column: 5;
}
#initiative {
grid-row-start: 2;
grid-column: 6;
}
#ac {
grid-row-start: 2;
grid-column: 7;
}
#speed {
grid-row-start: 2;
grid-column: 8;
}
#skills {
grid-row-start: 2;
grid-row-end: 50;
grid-column: 1;
grid-column-start: span 2;
text-align:left;
}
#actions {
grid-row-start: 3;
grid-column: 4;
grid-column-start: span 6;
}
table {
display: grid;
grid-template-columns: minmax(50px, 150px) 1fr;
grid-gap: 0rem;
}
table th {
grid-column-start: span 4;
white-space: nowrap;
padding-right: 1rem;
text-align: left;
}
table td {
padding-right: 1rem;
white-space: nowrap;
}
.note {
font-size: 0.75em;
font-style: italic;
}
#sheet_container input,
#sheet_container select,
#sheet_container textarea {
font-weight: bold;
border: 0;
}
#sheet_container input#name {
font-size: 2.0rem;
font-weight: bold;
width: 100%;
}
.stats {
display: grid;
grid-template-columns: repeat(8, minmax(6rem, 1fr));
grid-gap: 1rem;
}
.card {
border: 2px solid #e7e7e7;
border-radius: 4px;
padding: .5rem;
text-align: center;
}
.label {
text-align: center;
text-transform: uppercase;
font-size: 0.75rem;
}
.card input {
font-size: 1.25em;
text-align: center;
padding: 0;
margin: 0;
}
ul.multiclass {
display: inline;
list-style: none;
margin: 0;
padding: 0;
}
.multiclass li {
display: inline;
}
.multiclass label {
display: none;
}
ul#class_attributes {
list-style-type: none;
list-style: none;
margin: 0;
padding: 0;
}
ul#class_attributes li {
display: grid;
grid-template-columns: min-content 1fr 1fr;
}
ul#class_attributes span,
ul#class_attributes label {
margin-right: 0.5rem;
}
ul#class_attributes label {
text-align: left;
font-weight: bold;
}
ul#class_attributes span select {
width: 100%;
}
@@ -0,0 +1,63 @@
function getTraitModifiersForStat(stat) {
var mods = {};
for (const prop in TRAITS) {
var props = [];
for (const desc in TRAITS[prop]) {
trait = TRAITS[prop][desc]
if (trait.type == "stat" && trait.target == stat) {
props.push(trait);
}
}
if (props) {
mods[prop] = props;
}
}
return mods;
}
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 applyStatModifiers(stat) {
var score = parseInt(document.getElementById(stat).value);
var modsForStat = getTraitModifiersForStat(stat);
for (desc in modsForStat) {
for (idx in modsForStat[desc]) {
var value = modsForStat[desc][idx].value;
console.log(`Ancestry Trait "${desc}" grants ${value} to ${stat}`);
score += parseInt(value);
}
}
document.getElementById(stat).value = score;
}
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(applyStatModifiers);
stats.forEach(setStatBonus);
setProficiencyBonus();
// setSpellSaveDC();
})();
+30
View File
@@ -0,0 +1,30 @@
{% from "list.html" import build_list %}
<!doctype html>
<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 }}">
{% 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>
{{ build_list(c) }}
<div id='content'>
{% block content %}{% endblock %}
</div>
{% block debug %}{% endblock %}
{% block script %}{% 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>
@@ -0,0 +1,197 @@
{% extends "base.html" %}
{% set DISABLED = False if c.record.id else True %}
{% macro field(name, disabled=False) %}
{% set default_value = c.record[name] if c.record.id else c.form[name].default %}
{{ c.form[name](disabled=disabled, **{'data-initial_value': default_value}) }}
{% endmacro %}
{% block content %}
<div id='sheet_container'>
<form name="character_sheet" method="post" novalidate class="form">
<div class='banner'>
<div><img id='portrait' /></div>
<div>
{{ field('name') }}
{{ field('ancestry_id') }}
{% for obj in c.form['classes'] %}
{{ obj(class='multiclass') }}
{% endfor %}
<span class='label'>Add Class:</span> {{ c.form['newclass'](class='multiclass') }}
<div id='controls'>
{{ c.form.save }} &nbsp; {{ c.form.delete }}
</div>
</div>
</div>
</div>
<div id='character_sheet' {% if not c.record.id %}class='disabled'{% endif %} >
<div class='stats'>
{% for stat in ['str', 'dex', 'con', 'int', 'wis', 'cha'] %}
<div class='card'>
<div class='label'>{{ c.form[stat].label }}</div>
{{ field(stat, DISABLED) }}
<div id='{{stat}}_bonus'></div>
</div>
{% endfor %}
<div id='hp' class='card'>
<div class='label'>HP</div>
{{ field('hit_points', DISABLED) }} / {{ field('max_hit_points', DISABLED) }}
<div id='temp_hp'>
<span class='label'>TEMP</span> {{ field('temp_hit_points', DISABLED) }}
</div>
</div>
<div id='skills'>
<div class='label'>Skills</div>
<table>
{% for skill in c.record.skills %}
<tr><td>{{ skill }}</td><td>3</td></tr>
{% endfor %}
</table>
</div>
<div id='saves' class='card'>
<div class='label'>Saving Throws</div>
{% for save in c.record.saving_throws %}
{{ save }} 3&nbsp;
{% endfor %}
</div>
<div id='proficiency' class='card'>
<div class='label'>PROF</div>
<div id='proficiency_bonus'></div>
<div class='label'>BONUS</div>
</div>
<div id="ac" class='card'>
<div class='label'>Armor</div>
{{ field('armor_class', DISABLED) }}
<div class='label'>Class</div>
</div>
<div id='initiative' class='card'>
<div class='label'>Initiative</div>
<span id='initiative_bonus'>3 </span>
<div class='label'>Bonus</div>
</div>
<div id='speed' class='card'>
<div class='label'>Speed</div>
{{ field('speed', DISABLED) }}
</div>
<div id="actions" class='card'>
<table>
<tr>
<td class='label' colspan='2'>Actions</td>
<td class='label'>To Hit</td>
<td class='label'>Range</td>
<td class='label'>Targets</td>
<td class='label'>Damage</td>
</tr>
<tr>
<th>Attack</th>
<td>Dagger</td>
<td>+7</td>
<td>5</td>
<td>1</td>
<td>1d4+3 slashing</td>
</tr>
<tr>
<th>Attack</th>
<td>Sabetha's Fans</td>
<td>+7</td>
<td>5</td>
<td>1</td>
<td>2d6 slashing</td>
</tr>
<tr>
<th>Spell</th>
<td>Eldritch Blast</td>
<td>+5</td>
<td>120</td>
<td>1</td>
<td>1d10 force</td>
</tr>
<tr>
<td class='label' colspan='2'>Bonus Actions</td>
<td class='label'>To Hit</td>
<td class='label'>Range</td>
<td class='label'>Targets</td>
<td class='label'>Damage</td>
</tr>
</table>
<p>
<span class='note'>
Attack (1 per Action), Cast a Spell, Dash, Disengage, Dodge, Grapple,<br>Help, Hide, Improvise, Ready, Search, Shove, or Use an Object
</span>
</p>
</div>
</div>
<!-- SIDEBAR -->
<div class='sidebar'>
<div class='card'>
<div class='label'>Inspiration</div>
<ul>
</ul>
</div>
<div class='card'>
<div class='label'>Conditions</div>
<ul>
</ul>
</div>
<div class='card'>
<div class='label'>Attributes</div>
{% if c.record.class_attributes %}
{{ field('class_attributes') }}
{% endif %}
</div>
<div class='card'>
<div class='label'>Defenses</div>
<ul>
<li>Vulnerable to Fire</li>
<li>Immune to Cold</li>
<li>Resistant to Poison</li>
</ul>
</div>
</div>
</div>
<hr>
{{ c.form.csrf_token }}
</form>
{% endblock %}
{% block debug %}
<div style='clear:both;display:block;'>
<h2>Debug</h2>
<code>
{% for field, msg in c.form.errors.items() %}
{{ field }}: {{ msg }}
{% endfor %}
</code>
{{ c.record.class_attributes }}
</code>
{% endblock %}
{% block script %}
<script type='text/javascript'>
const TRAITS = {
{% for trait_desc, traits in [] %}
'{{ trait_desc }}': [
{% for trait in traits %}
{
"type": "{{ trait['type'] }}",
"target": "{{ trait.target }}",
"value": "{{ trait.value }}",
},
{% endfor %}
],
{% endfor %}
};
</script>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ tmpl_context.project_name }}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{ tmpl_context.project_name }}">
<meta name="og:title" content="{{ tmpl_context.project_name }}">
<meta name="og:description" content="{{ tmpl_context.project_name }}">
<meta name="og:url" content="">
<meta name="og:type" content="text">
<meta name="og:provider_name" content="{{ tmpl_context.project_name }}">
<!--
<meta name="og:image" content="/static/45.svg">
-->
<link rel='stylesheet' href='/static/styles.css' />
<!--
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
-->
</head>
<body>
<h1>{{ tmpl_context.project_name }}: {{ page }}</h1>
<pre>
{{ content }}
</pre>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{% macro build_list(c) %}
<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.name }}</a></li>
{% endfor %}
</ul>
{% endmacro %}
+71
View File
@@ -0,0 +1,71 @@
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)
+103
View File
@@ -0,0 +1,103 @@
import io
import logging
import os
from pathlib import Path
from textwrap import dedent
from typing import Optional
import typer
from dotenv import load_dotenv
from rich import print
from rich.logging import RichHandler
from ttfrog.path import assets
default_data_path = Path("~/.dnd/ttfrog")
default_host = "127.0.0.1"
default_port = 2323
SETUP_HELP = f"""
# Please make sure you set the SECRET_KEY in your environment. By default,
# TableTop Frog will attempt to load these variables from:
# {default_data_path}/defaults
#
# which may contain the following variables as well.
#
# See also the --root paramter.
DATA_PATH={default_data_path}
# Uncomment one or both of these to replace the packaged static assets and templates:
#
# STATIC_FILES_PATH={assets()}/public
# TEMPLATES_PATH={assets()}/templates
HOST={default_host}
PORT={default_port}
"""
app = typer.Typer()
app_state = dict()
@app.callback()
def main(
context: typer.Context,
root: Optional[Path] = typer.Option(
default_data_path,
help="Path to the TableTop Frog environment",
),
):
app_state["env"] = root.expanduser() / Path("defaults")
load_dotenv(stream=io.StringIO(SETUP_HELP))
load_dotenv(app_state["env"])
debug = os.getenv("DEBUG", None)
logging.basicConfig(
format="%(message)s",
level=logging.DEBUG if debug else logging.INFO,
handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])],
)
@app.command()
def setup(context: typer.Context):
"""
(Re)Initialize TableTop Frog. Idempotent; will preserve any existing configuration.
"""
from ttfrog.db.bootstrap import bootstrap
if not os.path.exists(app_state["env"]):
app_state["env"].parent.mkdir(parents=True, exist_ok=True)
app_state["env"].write_text(dedent(SETUP_HELP))
print(f"Wrote defaults file {app_state['env']}.")
bootstrap()
@app.command()
def serve(
context: typer.Context,
host: str = typer.Argument(
default_host,
help="bind address",
),
port: int = typer.Argument(
default_port,
help="bind port",
),
debug: bool = typer.Option(False, help="Enable debugging output"),
):
"""
Start the TableTop Frog server.
"""
# delay loading the app until we have configured our environment
from ttfrog.db.bootstrap import bootstrap
from ttfrog.webserver import application
print("Starting TableTop Frog server...")
bootstrap()
application.start(host=host, port=port, debug=debug)
if __name__ == "__main__":
app()
View File
+121
View File
@@ -0,0 +1,121 @@
import enum
import nanoid
from nanoid_dictionary import human_alphabet
from pyramid_sqlalchemy import BaseObject
from slugify import slugify
from sqlalchemy import Column, String
def genslug():
return nanoid.generate(human_alphabet[2:], 5)
class SlugMixin:
slug = Column(String, index=True, unique=True, default=genslug)
@property
def uri(self):
return "-".join([self.slug, slugify(self.name.title().replace(" ", ""), ok="", only_ascii=True, lower=False)])
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]
for relname in self.__mapper__.relationships.keys():
relvals = []
reliter = self.__getattribute__(relname)
if not reliter:
yield relname, relvals
continue
for rel in reliter:
try:
relvals.append({k: v for k, v in vars(rel).items() if not k.startswith("_")})
except TypeError:
relvals.append(rel)
yield relname, relvals
def __json__(self, request):
serialized = dict()
for key, value in self:
try:
serialized[key] = getattr(self.value, "__json__")(request)
except AttributeError:
serialized[key] = value
return serialized
def __repr__(self):
return 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 EnumField(enum.Enum):
"""
A serializable enum.
"""
def __json__(self, request):
return self.value
SavingThrowsMixin = multivalue_string_factory("saving_throws")
SkillsMixin = multivalue_string_factory("skills")
STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
CREATURE_TYPES = [
"aberation",
"beast",
"celestial",
"construct",
"dragon",
"elemental",
"fey",
"fiend",
"Giant",
"humanoid",
"monstrosity",
"ooze",
"plant",
"undead",
]
CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES))
StatsEnum = EnumField("StatsEnum", ((k, k) for k in STATS))
# class Table(*Bases):
Bases = [BaseObject, IterableMixin, SlugMixin]
+192
View File
@@ -0,0 +1,192 @@
import logging
from sqlalchemy.exc import IntegrityError
from ttfrog.db import schema
from ttfrog.db.manager import db
# move this to json or whatever
data = {
"CharacterClass": [
{
"id": 1,
"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",
],
},
{
"id": 2,
"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": [
{"id": 1, "name": "human", "creature_type": "humanoid"},
{"id": 2, "name": "dragonborn", "creature_type": "humanoid"},
{"id": 3, "name": "tiefling", "creature_type": "humanoid"},
{"id": 4, "name": "elf", "creature_type": "humanoid"},
],
"AncestryTrait": [
{
"id": 1,
"name": "+1 to All Ability Scores",
},
{
"id": 2,
"name": "Breath Weapon",
},
{
"id": 3,
"name": "Darkvision",
},
],
"AncestryTraitMap": [
{"ancestry_id": 1, "ancestry_trait_id": 1, "level": 1}, # human +1 to scores
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1}, # dragonborn breath weapon
{"ancestry_id": 3, "ancestry_trait_id": 3, "level": 1}, # tiefling darkvision
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1}, # elf darkvision
],
"CharacterClassMap": [
{
"character_id": 1,
"character_class_id": 1,
"level": 2,
},
{
"character_id": 1,
"character_class_id": 2,
"level": 3,
},
],
"Character": [
{
"id": 1,
"name": "Sabetha",
"ancestry_id": 1,
"armor_class": 10,
"max_hit_points": 14,
"hit_points": 14,
"temp_hit_points": 0,
"speed": 30,
"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"],
},
],
"ClassAttribute": [
{"id": 1, "name": "Fighting Style"},
{"id": 2, "name": "Another Attribute"},
],
"ClassAttributeOption": [
{"id": 1, "attribute_id": 1, "name": "Archery"},
{"id": 2, "attribute_id": 1, "name": "Battlemaster"},
{"id": 3, "attribute_id": 2, "name": "Another Option 1"},
{"id": 4, "attribute_id": 2, "name": "Another Option 2"},
],
"ClassAttributeMap": [
{"class_attribute_id": 1, "character_class_id": 1, "level": 2}, # Fighter: Fighting Style
{"class_attribute_id": 2, "character_class_id": 1, "level": 1}, # Fighter: Another Attr
],
"CharacterClassAttributeMap": [
{"character_id": 1, "class_attribute_id": 2, "option_id": 4}, # Sabetha, another option, option 2
{"character_id": 1, "class_attribute_id": 1, "option_id": 1}, # Sabetha, fighting style, archery
],
"Modifier": [
# Humans
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "str"},
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "dex"},
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "con"},
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "int"},
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "wis"},
{"source_table_name": "ancestry_trait", "source_table_id": 1, "value": "+1", "type": "stat", "target": "cha"},
# Dragonborn
{
"source_table_name": "ancestry_trait",
"source_table_id": 2,
"value": "60",
"type": "attribute ",
"target": "Darkvision",
},
{"source_table_name": "ancestry_trait", "source_table_id": 2, "value": "+1", "type": "stat", "target": ""},
{"source_table_name": "ancestry_trait", "source_table_id": 2, "value": "+1", "type": "stat", "target": ""},
# Fighting Style: Archery
{
"source_table_name": "class_attribute",
"source_table_id": 1,
"value": "+2",
"type": "weapon ",
"target": "ranged",
},
],
}
def bootstrap():
"""
Initialize the database with source data. Idempotent; will skip anything that already exists.
"""
db.init()
for table, records in data.items():
model = getattr(schema, table)
for rec in records:
obj = model(**rec)
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
+87
View File
@@ -0,0 +1,87 @@
import base64
import hashlib
import os
from contextlib import contextmanager
from functools import cached_property
import transaction
from pyramid_sqlalchemy import Session, init_sqlalchemy
from pyramid_sqlalchemy import metadata as _metadata
from sqlalchemy import create_engine
import ttfrog.db.schema
from ttfrog.path import database
# from sqlalchemy.exc import IntegrityError
ttfrog.db.schema
class SQLDatabaseManager:
"""
A context manager for working with sqllite database.
"""
@cached_property
def url(self):
return os.environ.get("DATABASE_URL", f"sqlite:///{database()}")
@cached_property
def engine(self):
return create_engine(self.url)
@cached_property
def session(self):
return Session
@cached_property
def metadata(self):
return _metadata
@cached_property
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 add(self, *args, **kwargs):
self.session.add(*args, **kwargs)
self.session.flush()
def query(self, *args, **kwargs):
return self.session.query(*args, **kwargs)
def slugify(self, rec: dict) -> str:
"""
Create a uniquish slug from a dictionary.
"""
sha1bytes = hashlib.sha1(str(rec["id"]).encode())
return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10]
def init(self):
init_sqlalchemy(self.engine)
self.metadata.create_all(self.engine)
def dump(self):
results = {}
for table_name, table in self.tables.items():
results[table_name] = [row for row in self.query(table).all()]
return results
def __getattr__(self, name: str):
try:
return self.tables[name]
except KeyError:
raise AttributeError(f"{self} does not contain the attribute '{name}'.")
db = SQLDatabaseManager()
+4
View File
@@ -0,0 +1,4 @@
from .character import *
from .classes import *
from .property import *
from .transaction import *
+151
View File
@@ -0,0 +1,151 @@
from sqlalchemy import Column, Enum, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import relationship
from ttfrog.db.base import BaseObject, Bases, CreatureTypesEnum, IterableMixin, SavingThrowsMixin, SkillsMixin
__all__ = [
"Ancestry",
"AncestryTrait",
"AncestryTraitMap",
"CharacterClassMap",
"CharacterClassAttributeMap",
"Character",
]
def class_map_creator(fields):
if isinstance(fields, CharacterClassMap):
return fields
return CharacterClassMap(**fields)
def attr_map_creator(fields):
if isinstance(fields, CharacterClassAttributeMap):
return fields
return CharacterClassAttributeMap(**fields)
class AncestryTraitMap(BaseObject):
__tablename__ = "trait_map"
id = Column(Integer, primary_key=True, autoincrement=True)
ancestry_id = Column(Integer, ForeignKey("ancestry.id"))
ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id"))
trait = relationship("AncestryTrait", lazy="immediate")
level = Column(Integer, nullable=False, info={"min": 1, "max": 20})
class Ancestry(*Bases):
"""
A character ancestry ("race"), which has zero or more AncestryTraits.
"""
__tablename__ = "ancestry"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
creature_type = Column(Enum(CreatureTypesEnum))
traits = relationship("AncestryTraitMap", lazy="immediate")
def __repr__(self):
return self.name
class AncestryTrait(BaseObject, IterableMixin):
"""
A trait granted to a character via its Ancestry.
"""
__tablename__ = "ancestry_trait"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
description = Column(Text)
def __repr__(self):
return self.name
class CharacterClassMap(BaseObject, IterableMixin):
__tablename__ = "class_map"
id = Column(Integer, primary_key=True, autoincrement=True)
character_id = Column(Integer, ForeignKey("character.id"))
character_class_id = Column(Integer, ForeignKey("character_class.id"))
mapping = UniqueConstraint(character_id, character_class_id)
level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1)
character_class = relationship("CharacterClass", lazy="immediate")
character = relationship("Character", uselist=False, viewonly=True)
def __repr__(self):
return f"{self.character.name}, {self.character_class.name}, level {self.level}"
class CharacterClassAttributeMap(BaseObject, IterableMixin):
__tablename__ = "character_class_attribute_map"
id = Column(Integer, primary_key=True, autoincrement=True)
character_id = Column(Integer, ForeignKey("character.id"), nullable=False)
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
option_id = Column(Integer, ForeignKey("class_attribute_option.id"), nullable=False)
mapping = UniqueConstraint(character_id, class_attribute_id)
class_attribute = relationship("ClassAttribute", lazy="immediate")
option = relationship("ClassAttributeOption", lazy="immediate")
character_class = relationship(
"CharacterClass",
secondary="class_map",
primaryjoin="CharacterClassAttributeMap.character_id == CharacterClassMap.character_id",
secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id",
viewonly=True,
)
class Character(*Bases, SavingThrowsMixin, SkillsMixin):
__tablename__ = "character"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, default="New Character", nullable=False)
armor_class = Column(Integer, default=10, nullable=False, info={"min": 1, "max": 99})
hit_points = Column(Integer, default=1, nullable=False, info={"min": 0, "max": 999})
max_hit_points = Column(Integer, default=1, nullable=False, info={"min": 0, "max": 999})
temp_hit_points = Column(Integer, default=0, nullable=False, info={"min": 0, "max": 999})
speed = Column(Integer, nullable=False, default=30, info={"min": 0, "max": 99})
str = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
dex = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
con = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
int = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
wis = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
cha = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
proficiencies = Column(String)
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
classes = association_proxy("class_map", "id", creator=class_map_creator)
character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan")
class_attributes = association_proxy("character_class_attribute_map", "id", creator=attr_map_creator)
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry = relationship("Ancestry", uselist=False)
@property
def traits(self):
return [mapping.trait for mapping in self.ancestry.traits]
@property
def level(self):
return sum(mapping.level for mapping in self.class_map)
@property
def levels(self):
return dict([(mapping.character_class.name, mapping.level) for mapping in self.class_map])
def add_class(self, newclass, level=1):
if level == 0:
return self.remove_class(newclass)
level_in_class = [mapping for mapping in self.class_map if mapping.character_class_id == newclass.id]
if level_in_class:
level_in_class = level_in_class[0]
level_in_class.level = level
return
self.classes.append(CharacterClassMap(character_id=self.id, character_class_id=newclass.id, level=level))
def remove_class(self, target):
self.class_map = [m for m in self.class_map if m.id != target.id]
+45
View File
@@ -0,0 +1,45 @@
from sqlalchemy import Column, Enum, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from ttfrog.db.base import BaseObject, Bases, IterableMixin, SavingThrowsMixin, SkillsMixin, StatsEnum
__all__ = [
"ClassAttributeMap",
"ClassAttribute",
"ClassAttributeOption",
"CharacterClass",
]
class ClassAttributeMap(BaseObject, IterableMixin):
__tablename__ = "class_attribute_map"
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), primary_key=True)
character_class_id = Column(Integer, ForeignKey("character_class.id"), primary_key=True)
level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1)
class ClassAttribute(BaseObject, IterableMixin):
__tablename__ = "class_attribute"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
def __repr__(self):
return f"{self.id}: {self.name}"
class ClassAttributeOption(BaseObject, IterableMixin):
__tablename__ = "class_attribute_option"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
# attribute = relationship("ClassAttribute", uselist=False)
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)
attributes = relationship("ClassAttributeMap")
+39
View File
@@ -0,0 +1,39 @@
from sqlalchemy import Column, Integer, String, Text, UniqueConstraint
from ttfrog.db.base import BaseObject, Bases, IterableMixin
__all__ = [
"Skill",
"Proficiency",
"Modifier",
]
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 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 Modifier(BaseObject, IterableMixin):
__tablename__ = "modifier"
__table_args__ = (UniqueConstraint("source_table_name", "source_table_id", "value", "type", "target"),)
id = Column(Integer, primary_key=True, autoincrement=True)
source_table_name = Column(String, index=True, nullable=False)
source_table_id = Column(Integer, index=True, nullable=False)
value = Column(String, nullable=False)
type = Column(String, nullable=False)
target = Column(String, nullable=False)
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Text
from ttfrog.db.base import BaseObject, IterableMixin
__all__ = ["TransactionLog"]
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)
+37
View File
@@ -0,0 +1,37 @@
import json
import logging
from ttfrog.db.manager import db
from ttfrog.db.schema import TransactionLog
def record(previous, new):
logging.debug(f"{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)
+29
View File
@@ -0,0 +1,29 @@
import os
from pathlib import Path
_setup_hint = "You may be able to solve this error by running 'ttfrog setup' or specifying the --root parameter."
def database():
path = Path(os.environ["DATA_PATH"]).expanduser()
if not path.exists() or not path.is_dir():
raise RuntimeError(f"DATA_PATH {path} doesn't exist or isn't a directory.\n\n{_setup_hint}")
return path / Path("tabletop-frog.db")
def assets():
return Path(__file__).parent / "assets"
def templates():
try:
return Path(os.environ["TEMPLATES_PATH"])
except KeyError:
return assets() / "templates"
def static_files():
try:
return Path(os.environ["STATIC_FILES_PATH"])
except KeyError:
return assets() / "public"
View File
+26
View File
@@ -0,0 +1,26 @@
import logging
from wsgiref.simple_server import make_server
from pyramid.config import Configurator
from ttfrog.db.manager import db
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
def start(host: str, port: int, debug: bool = False) -> None:
logging.debug(f"Configuring webserver with {host=}, {port=}, {debug=}")
config = configuration()
config.include(routes)
config.scan("ttfrog.webserver.views")
make_server(host, int(port), config.make_wsgi_app()).serve_forever()
@@ -0,0 +1,5 @@
from .base import BaseController
from .character_sheet import CharacterSheet
from .json_data import JsonData
__all__ = [BaseController, CharacterSheet, JsonData]
@@ -0,0 +1,13 @@
from wtforms_alchemy import ModelForm
from ttfrog.db.manager import db
from ttfrog.db.schema import Ancestry
class AncestryForm(ModelForm):
class Meta:
model = Ancestry
exclude = ["slug"]
def get_session():
return db.session
+149
View File
@@ -0,0 +1,149 @@
import logging
import re
from collections import defaultdict
from pyramid.httpexceptions import HTTPFound
from pyramid.interfaces import IRoutesMapper
from ttfrog.db.manager import db
def get_all_routes(request):
routes = {
"static": "/static",
}
uri_pattern = re.compile(r"^([^\{\*]+)")
mapper = request.registry.queryUtility(IRoutesMapper)
for route in mapper.get_routes():
if route.name.startswith("__"):
continue
m = uri_pattern.search(route.pattern)
if m:
routes[route.name] = m.group(0)
return routes
class BaseController:
model = None
model_form = None
def __init__(self, request):
self.request = request
self.attrs = defaultdict(str)
self._slug = None
self._record = None
self._form = None
self.config = {"static_url": "/static", "project_name": "TTFROG"}
self.configure_for_model()
@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:
return
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)
if not self.record.id:
# apply the db schema defaults
self._form.process()
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()
def template_context(self, **kwargs) -> dict:
return dict(
config=self.config,
request=self.request,
form=self.form,
record=self.record,
routes=get_all_routes(self.request),
resources=self.resources,
**self.attrs,
**kwargs,
)
def populate(self):
self.form.populate_obj(self.record)
def populate_association(self, key, formdata):
populated = []
for field in formdata:
map_id = field.pop("id")
map_id = int(map_id) if map_id else 0
if not field[key]:
continue
elif not map_id:
populated.append(field)
else:
field["id"] = map_id
populated.append(field)
return populated
def validate(self):
return self.form.validate()
def save(self):
if not self.form.save.data:
return
if not self.validate():
return
logging.debug(f"{self.form.data = }")
# previous = dict(self.record)
logging.debug(f"{self.record = }")
self.populate()
# transaction_log.record(previous, self.record)
with db.transaction():
db.add(self.record)
self.save_callback()
logging.debug(f"Saved {self.record = }")
location = self.request.current_route_path()
if self.record.slug not in location:
location = f"{location}/{self.record.uri}"
logging.debug(f"Redirecting to {location}")
return HTTPFound(location=location)
def delete(self):
if not self.record.id:
return
with db.transaction():
db.query(self.model).filter_by(id=self.record.id).delete()
location = self.request.current_route_path()
return HTTPFound(location=location)
def response(self):
if not self.form:
return
elif self.form.save.data:
return self.save()
elif self.form.delete.data:
return self.delete()
@@ -0,0 +1,191 @@
import logging
from markupsafe import Markup
from wtforms import ValidationError
from wtforms.fields import FieldList, FormField, HiddenField, SelectField, SelectMultipleField, SubmitField
from wtforms.validators import Optional
from wtforms.widgets import ListWidget, Select
from wtforms.widgets.core import html_params
from wtforms_alchemy import ModelForm
from ttfrog.db.base import STATS
from ttfrog.db.manager import db
from ttfrog.db.schema import (
Ancestry,
Character,
CharacterClass,
CharacterClassAttributeMap,
CharacterClassMap,
ClassAttributeOption,
)
from ttfrog.webserver.controllers.base import BaseController
from ttfrog.webserver.forms import DeferredSelectField, NullableDeferredSelectField
VALID_LEVELS = range(1, 21)
class ClassAttributeWidget:
def __call__(self, field, **kwargs):
kwargs.setdefault("id", field.id)
html = [
f"<span {html_params(**kwargs)}>{field.character_class_map.class_attribute.name}</span>",
"<span>",
]
for subfield in field:
html.append(subfield())
html.append("</span>")
return Markup("".join(html))
class ClassAttributesFormField(FormField):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.character_class_map = None
def process(self, *args, **kwargs):
super().process(*args, **kwargs)
self.character_class_map = db.query(CharacterClassAttributeMap).get(self.data["id"])
self.label.text = self.character_class_map.character_class[0].name
class ClassAttributesForm(ModelForm):
id = HiddenField()
class_attribute_id = HiddenField()
option_id = SelectField(widget=Select(), choices=[], validators=[Optional()], coerce=int)
def __init__(self, formdata=None, obj=None, prefix=None):
if obj:
obj = db.query(CharacterClassAttributeMap).get(obj)
super().__init__(formdata=formdata, obj=obj, prefix=prefix)
if obj:
options = db.query(ClassAttributeOption).filter_by(attribute_id=obj.class_attribute.id)
self.option_id.choices = [(rec.id, rec.name) for rec in options.all()]
class MulticlassForm(ModelForm):
id = HiddenField()
character_class_id = NullableDeferredSelectField(
model=CharacterClass, validate_choice=True, widget=Select(), coerce=int
)
level = SelectField(choices=VALID_LEVELS, default=1, coerce=int, validate_choice=True, widget=Select())
def __init__(self, formdata=None, obj=None, prefix=None):
"""
Populate the form field with a CharacterClassMap object by converting the object ID
to an instance. This will ensure that the rendered field is populated with the current
value of the class_map.
"""
if obj:
obj = db.query(CharacterClassMap).get(obj)
super().__init__(formdata=formdata, obj=obj, prefix=prefix)
class CharacterForm(ModelForm):
class Meta:
model = Character
exclude = ["slug"]
save = SubmitField()
delete = SubmitField()
ancestry_id = DeferredSelectField("Ancestry", model=Ancestry, default=1, validate_choice=True, widget=Select())
classes = FieldList(FormField(MulticlassForm, label=None, widget=ListWidget()), min_entries=0)
newclass = FormField(MulticlassForm, widget=ListWidget())
class_attributes = FieldList(
ClassAttributesFormField(ClassAttributesForm, widget=ClassAttributeWidget()), min_entries=1
)
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"},
]
def validate_callback(self):
"""
Validate multiclass fields in form data.
"""
ret = super().validate()
if not self.form.data["classes"]:
return ret
err = ""
total_level = 0
for field in self.form.data["classes"]:
level = field.get("level")
total_level += level
if level not in VALID_LEVELS:
err = f"Multiclass form field {field = } level is outside possible range."
break
if total_level not in VALID_LEVELS:
err = f"Total level for all multiclasses ({total_level}) is outside possible range."
if err:
logging.error(err)
raise ValidationError(err)
return ret and True
def add_class_attributes(self):
# prefetch the records for each of the character's classes
classes_by_id = {
c.id: c
for c in db.query(CharacterClass)
.filter(CharacterClass.id.in_(c.character_class_id for c in self.record.class_map))
.all()
}
assigned = [int(m.class_attribute_id) for m in self.record.character_class_attribute_map]
logging.debug(f"{assigned = }")
# step through the list of class mappings for this character
for class_map in self.record.class_map:
thisclass = classes_by_id[class_map.character_class_id]
# assign each class attribute available at the character's current
# level to the list of the character's class attributes
for attr_map in [a for a in thisclass.attributes if a.level <= class_map.level]:
# when creating a record, assign the first of the available
# options to the character's class attribute.
default_option = (
db.query(ClassAttributeOption).filter_by(attribute_id=attr_map.class_attribute_id).first()
)
if attr_map.class_attribute_id not in assigned:
self.record.class_attributes.append(
{
"class_attribute_id": attr_map.class_attribute_id,
"option_id": default_option.id,
}
)
def save_callback(self):
self.add_class_attributes()
def populate(self):
"""
Delete the association proxies' form data before calling form.populate_obj(),
and instead use our own methods for populating the fieldlist.
"""
# multiclass form
classes_formdata = self.form.data["classes"]
classes_formdata.append(self.form.data["newclass"])
del self.form.classes
del self.form.newclass
# class attributes
attrs_formdata = self.form.data["class_attributes"]
del self.form.class_attributes
super().populate()
self.record.classes = self.populate_association("character_class_id", classes_formdata)
self.record.class_attributes = self.populate_association("class_attribute_id", attrs_formdata)
@@ -0,0 +1,21 @@
from pyramid.httpexceptions import exception_response
from ttfrog.db import schema
from ttfrog.db.manager import db
from .base import BaseController
class JsonData(BaseController):
model = None
model_form = None
def configure_for_model(self):
try:
self.model = getattr(schema, self.request.matchdict.get("table_name"))
except AttributeError:
raise exception_response(404)
def response(self):
query = db.query(self.model).filter_by(**self.request.params)
return {"table_name": self.model.__tablename__, "records": query.all()}
+21
View File
@@ -0,0 +1,21 @@
from wtforms.fields import SelectField, SelectMultipleField
from ttfrog.db.manager import db
class DeferredSelectMultipleField(SelectMultipleField):
def __init__(self, *args, model=None, **kwargs):
super().__init__(*args, **kwargs)
self.choices = [(rec.id, rec.name) for rec in db.query(model).all()]
class DeferredSelectField(SelectField):
def __init__(self, *args, model=None, **kwargs):
super().__init__(*args, **kwargs)
self.choices = [(rec.id, getattr(rec, "name", str(rec))) for rec in db.query(model).all()]
class NullableDeferredSelectField(DeferredSelectField):
def __init__(self, *args, model=None, label="---", **kwargs):
super().__init__(*args, model=model, **kwargs)
self.choices = [(0, label)] + self.choices
+4
View File
@@ -0,0 +1,4 @@
def routes(config):
config.add_route("index", "/")
config.add_route("sheet", "/c{uri:.*}", factory="ttfrog.webserver.controllers.CharacterSheet")
config.add_route("data", "/_/{table_name}{uri:.*}", factory="ttfrog.webserver.controllers.JsonData")
+26
View File
@@ -0,0 +1,26 @@
from pyramid.response import Response
from pyramid.view import view_config
from ttfrog.attribute_map import AttributeMap
from ttfrog.db.manager import db
from ttfrog.db.schema import Ancestry
def response_from(controller):
return controller.response() or AttributeMap.from_dict({"c": controller.template_context()})
@view_config(route_name="index")
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):
return response_from(request.context)
@view_config(route_name="data", renderer="json")
def data(request):
return response_from(request.context)