Compare commits

..
35 Commits
Author SHA1 Message Date
evilchili 1ff0e5ca7d fixing modifier bugs, fixing traits, adding speed attrs 2024-04-23 00:15:13 -07:00
evilchili 5db6e40eae refactor modifiers 2024-04-21 21:30:24 -07:00
evilchili 36f6f831d9 implemented modifiers on char stats 2024-04-21 02:17:47 -07:00
evilchili 5ec27e9344 default attributes on db object are now queries 2024-04-20 23:40:28 -07:00
evilchili 12c643c542 adding size/speed to ancestry 2024-04-20 23:33:36 -07:00
evilchili a520ea249e adding tests for ancestries 2024-04-20 23:27:47 -07:00
evilchili 46ef48669d typo 2024-04-20 20:39:13 -07:00
evilchili a9593e83a2 formatting 2024-04-20 20:35:24 -07:00
evilchili 412efe2aec Make objects iterable by default, add tests, refactoring 2024-04-20 20:35:07 -07:00
evilchili 44cd8fe9c9 wip 2024-04-14 11:37:34 -07:00
evilchili dbb9461b7a adding tests and helper UX to schema 2024-03-26 21:58:04 -07:00
evilchili 78115023bb restructuring for poetry-slam 2024-03-26 00:53:21 -07:00
evilchili b1d7639a62 Adding tests of character schema 2024-03-24 16:56:13 -07:00
evilchili dba8bb315a Fixing UX of class attributes 2024-03-24 16:55:51 -07:00
evilchili b92ff868a5 fix multiclass submissions 2024-03-23 13:46:56 -07:00
evilchili 304a4d9c79 adding support for managing class attributes 2024-02-26 01:12:45 -08:00
evilchili 75b9aec28e fix deletes 2024-02-23 11:02:36 -08:00
evilchili 9757d3bee0 fix multiclassing 2024-02-23 10:45:38 -08:00
evilchili ba0e66f9af modeling many-to-many relationships 2024-02-18 19:30:41 -08:00
evilchili e231828425 layout updates, added json views, fixed relationships in schema 2024-02-16 01:19:25 -08:00
evilchili 1baf73a338 fixed edit bug 2024-02-08 23:26:19 -08:00
evilchili 8bde2ab5f3 adding defaults, modifiers, traits and attributes 2024-02-08 23:04:28 -08:00
evilchili da6255a86a added transaction log, UX scaffolding 2024-02-08 01:14:35 -08:00
evilchili 2dcaa3fac6 Switching from broken QuerySelectFields 2024-02-04 16:12:03 -08:00
evilchili 99ef4d61f9 adding deletes 2024-02-04 15:22:54 -08:00
evilchili 669c9b46d6 fixing form handling for relationships 2024-02-04 11:40:30 -08:00
evilchili 32d9c42847 fixing slugs 2024-02-02 15:40:45 -08:00
evilchili 9cdf28502a sample macro 2024-02-01 00:28:35 -08:00
evilchili 9277494a05 adding create 2024-02-01 00:28:17 -08:00
evilchili 5de3f74a88 simplified post handling 2024-01-31 23:05:46 -08:00
evilchili 3444f83c91 rewrite using pyramid and wtforms 2024-01-31 22:39:54 -08:00
evilchili 5faf5c97c1 rewrite in pyramid 2024-01-30 01:25:02 -08:00
evilchili 8f17ddfb05 Adding slugs, refactoring 2024-01-28 22:14:50 -08:00
evilchili 64451ddf8b adding character sheets 2024-01-28 14:31:50 -08:00
evilchiliandGitHub 17da4a73ee Merge pull request #1 from evilchili/mainline
Initial import
2024-01-28 11:02:39 -08:00
48 changed files with 2303 additions and 282 deletions
+42 -11
View File
@@ -5,24 +5,30 @@ description = ""
authors = ["evilchili <evilchili@gmail.com>"] authors = ["evilchili <evilchili@gmail.com>"]
readme = "README.md" readme = "README.md"
packages = [ packages = [
{ include = 'ttfrog' }, {include = "*", from = "src"},
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
TurboGears2 = "^2.4.3"
sqlalchemy = "^2.0.25"
tgext-admin = "^0.7.4"
webhelpers2 = "^2.0"
typer = "^0.9.0"
python-dotenv = "^0.21.0" python-dotenv = "^0.21.0"
typer = "^0.9.0"
rich = "^13.7.0" rich = "^13.7.0"
jinja2 = "^3.1.3" sqlalchemy = "^2.0.25"
pyramid = "^2.0.2"
#"tg.devtools" = "^2.4.3" pyramid-tm = "^2.5"
#repoze-who = "^3.0.0" pyramid-jinja2 = "^2.10"
# tw2-forms = "^2.2.6" pyramid-sqlalchemy = "^1.6"
wtforms-sqlalchemy = "^0.4.1"
transaction = "^4.0"
unicode-slugify = "^0.1.5"
nanoid = "^2.0.0"
nanoid-dictionary = "^2.4.0"
wtforms-alchemy = "^0.18.0"
sqlalchemy-serializer = "^1.4.1"
[tool.poetry.group.dev.dependencies]
pytest = "^8.1.1"
pytest-cov = "^5.0.0"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
@@ -33,3 +39,28 @@ build-backend = "poetry.core.masonry.api"
ttfrog = "ttfrog.cli:app" ttfrog = "ttfrog.cli:app"
### SLAM
[tool.black]
line-length = 120
target-version = ['py310']
[tool.isort]
multi_line_output = 3
line_length = 120
include_trailing_comma = true
[tool.autoflake]
check = false # return error code if changes are needed
in-place = true # make changes to files instead of printing diffs
recursive = true # drill down directories recursively
remove-all-unused-imports = true # remove all unused imports (not just those from the standard library)
ignore-init-module-imports = true # exclude __init__.py when removing unused imports
remove-duplicate-keys = true # remove all duplicate keys in objects
remove-unused-variables = true # remove unused variables
[tool.pytest.ini_options]
log_cli_level = "DEBUG"
addopts = "--cov=src --cov-report=term-missing"
### ENDSLAM
+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['class_list'] %}
{{ 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.attribute_list %}
{{ field('attribute_list') }}
{% 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 }}
</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 %}
+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)
+47 -30
View File
@@ -2,8 +2,8 @@ import io
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional
from textwrap import dedent from textwrap import dedent
from typing import Optional
import typer import typer
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -12,9 +12,8 @@ from rich.logging import RichHandler
from ttfrog.path import assets from ttfrog.path import assets
default_data_path = Path("~/.dnd/ttfrog") default_data_path = Path("~/.dnd/ttfrog")
default_host = '127.0.0.1' default_host = "127.0.0.1"
default_port = 2323 default_port = 2323
SETUP_HELP = f""" SETUP_HELP = f"""
@@ -37,45 +36,32 @@ HOST={default_host}
PORT={default_port} PORT={default_port}
""" """
db_app = typer.Typer()
app = typer.Typer() app = typer.Typer()
app.add_typer(db_app, name="db", help="Manage the database.")
app_state = dict() app_state = dict()
@app.callback() @app.callback()
@db_app.callback()
def main( def main(
context: typer.Context, context: typer.Context,
root: Optional[Path] = typer.Option( root: Optional[Path] = typer.Option(
default_data_path, default_data_path,
help="Path to the TableTop Frog environment", help="Path to the TableTop Frog environment",
) ),
): ):
app_state['env'] = root.expanduser() / Path('defaults') app_state["env"] = root.expanduser() / Path("defaults")
load_dotenv(stream=io.StringIO(SETUP_HELP)) load_dotenv(stream=io.StringIO(SETUP_HELP))
load_dotenv(app_state['env']) load_dotenv(app_state["env"])
debug = os.getenv('DEBUG', None) debug = os.getenv("DEBUG", None)
logging.basicConfig( logging.basicConfig(
format='%(message)s', format="%(message)s",
level=logging.DEBUG if debug else logging.INFO, level=logging.DEBUG if debug else logging.INFO,
handlers=[ handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])],
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() @app.command()
def serve( def serve(
context: typer.Context, context: typer.Context,
@@ -87,21 +73,52 @@ def serve(
default_port, default_port,
help="bind port", help="bind port",
), ),
debug: bool = typer.Option( debug: bool = typer.Option(False, help="Enable debugging output"),
False,
help='Enable debugging output'
),
): ):
""" """
Start the TableTop Frog server. Start the TableTop Frog server.
""" """
# delay loading the app until we have configured our environment # delay loading the app until we have configured our environment
from ttfrog.db.bootstrap import bootstrap
from ttfrog.webserver import application from ttfrog.webserver import application
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)
if __name__ == '__main__': @db_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()
@db_app.command()
def list(context: typer.Context):
from ttfrog.db.manager import db
print("\n".join(sorted(db.tables.keys())))
@db_app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
def dump(context: typer.Context):
"""
Dump tables (or the entire database) as a JSON blob.
"""
from ttfrog.db.manager import db
setup(context)
print(db.dump(context.args))
if __name__ == "__main__":
app() app()
+120
View File
@@ -0,0 +1,120 @@
import enum
import nanoid
from nanoid_dictionary import human_alphabet
from pyramid_sqlalchemy import BaseObject as _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 BaseObject(_BaseObject):
"""
Allows for iterating over Model objects' column names and values
"""
__abstract__ = True
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):
"""
Provide a custom JSON encoder.
"""
raise NotImplementedError()
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):
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",
]
SIZES = ["Tiny", "Small", "Medium", "Large", "Huge", "Gargantuan"]
CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES))
StatsEnum = EnumField("StatsEnum", ((k, k) for k in STATS))
SizesEnum = EnumField("SizesEnum", ((k, k) for k in SIZES))
+36
View File
@@ -0,0 +1,36 @@
from ttfrog.db import schema
from ttfrog.db.manager import db
def bootstrap():
db.metadata.drop_all(bind=db.engine)
db.init()
with db.transaction():
# ancestries
human = schema.Ancestry(name="human")
tiefling = schema.Ancestry(name="tiefling")
tiefling.add_modifier(schema.Modifier(name="Ability Score Increase", target="intelligence", relative_value=1))
tiefling.add_modifier(schema.Modifier(name="Ability Score Increase", target="charisma", relative_value=2))
darkvision = schema.AncestryTrait(
name="Darkvision",
description=(
"You can see in dim light within 60 feet of you as if it were bright light, and in darkness as if it "
"were dim light. You cant discern color in darkness, only shades of gray."
),
)
darkvision.add_modifier(schema.Modifier(name="Darkvision", target="vision_in_darkness", absolute_value=120))
tiefling.add_trait(darkvision)
# classes
fighter = schema.CharacterClass(name="fighter", hit_dice="1d10", hit_dice_stat="CON")
rogue = schema.CharacterClass(name="rogue", hit_dice="1d8", hit_dice_stat="DEX")
# characters
sabetha = schema.Character(name="Sabetha", ancestry=tiefling)
sabetha.add_class(fighter, level=2)
sabetha.add_class(rogue, level=3)
bob = schema.Character(name="Bob", ancestry=human)
# persist all the records we've created
db.add_or_update([sabetha, bob])
+94
View File
@@ -0,0 +1,94 @@
import base64
import hashlib
import json
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
assert ttfrog.db.schema
class AlchemyEncoder(json.JSONEncoder):
def default(self, obj):
try:
return getattr(obj, "__json__")()
except (AttributeError, NotImplementedError): # pragma: no cover
return super().default(obj)
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: # pragam: no cover
tm.abort()
raise
def add_or_update(self, record, *args, **kwargs):
if not isinstance(record, list):
record = [record]
for rec in record:
self.session.add(rec, *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, names: list = []):
results = {}
for table_name, table in self.tables.items():
if not names or table_name in names:
results[table_name] = [dict(row._mapping) for row in self.query(table).all()]
return json.dumps(results, indent=2, cls=AlchemyEncoder)
def __getattr__(self, name: str):
return self.query(getattr(ttfrog.db.schema, name))
db = SQLDatabaseManager()
+5
View File
@@ -0,0 +1,5 @@
from .character import *
from .classes import *
from .log import *
from .modifiers import *
from .property import *
+303
View File
@@ -0,0 +1,303 @@
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, CreatureTypesEnum, SavingThrowsMixin, SizesEnum, SkillsMixin, SlugMixin
from ttfrog.db.schema.modifiers import Modifier, ModifierMixin
__all__ = [
"Ancestry",
"AncestryTrait",
"AncestryTraitMap",
"CharacterClassMap",
"CharacterClassAttributeMap",
"Character",
"Modifier",
]
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"
__table_args__ = (UniqueConstraint("ancestry_id", "ancestry_trait_id"),)
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", uselist=False, lazy="immediate")
level = Column(Integer, nullable=False, info={"min": 1, "max": 20})
class Ancestry(BaseObject, ModifierMixin):
"""
A character ancestry ("race"), which has zero or more AncestryTraits and Modifiers.
"""
__tablename__ = "ancestry"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
creature_type = Column(Enum(CreatureTypesEnum), nullable=False, default="humanoid")
size = Column(Enum(SizesEnum), nullable=False, default="Medium")
walk_speed = Column(Integer, nullable=False, default=30, info={"min": 0, "max": 99})
_fly_speed = Column(Integer, info={"min": 0, "max": 99})
_climb_speed = Column(Integer, info={"min": 0, "max": 99})
_swim_speed = Column(Integer, info={"min": 0, "max": 99})
_traits = relationship("AncestryTraitMap", cascade="all,delete,delete-orphan", lazy="immediate")
@property
def traits(self):
return [mapping.trait for mapping in self._traits]
@property
def speed(self):
return self.walk_speed
@property
def climb_speed(self):
return self._climb_speed or int(self.speed / 2)
@property
def swim_speed(self):
return self._swim_speed or int(self.speed / 2)
def add_trait(self, trait, level=1):
if trait not in self._traits:
self._traits.append(AncestryTraitMap(ancestry_id=self.id, trait=trait, level=level))
return True
return False
def __repr__(self):
return self.name
class AncestryTrait(BaseObject, ModifierMixin):
"""
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):
__tablename__ = "class_map"
__table_args__ = (UniqueConstraint("character_id", "character_class_id"),)
id = Column(Integer, primary_key=True, autoincrement=True)
character_id = Column(Integer, ForeignKey("character.id"), nullable=False)
character_class_id = Column(Integer, ForeignKey("character_class.id"), nullable=False)
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 "{self.character.name}, {self.character_class.name}, level {self.level}"
class CharacterClassAttributeMap(BaseObject):
__tablename__ = "character_class_attribute_map"
__table_args__ = (UniqueConstraint("character_id", "class_attribute_id"),)
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)
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,
uselist=False,
)
class Character(BaseObject, ModifierMixin, SlugMixin, 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=10, nullable=False, info={"min": 0, "max": 999})
temp_hit_points = Column(Integer, default=0, nullable=False, info={"min": 0, "max": 999})
strength = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
dexterity = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
constitution = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
intelligence = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
wisdom = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
charisma = Column(Integer, nullable=False, default=10, info={"min": 0, "max": 30})
_vision = Column(Integer, info={"min": 0})
proficiencies = Column(String)
class_map = relationship("CharacterClassMap", cascade="all,delete,delete-orphan")
class_list = association_proxy("class_map", "id", creator=class_map_creator)
character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade="all,delete,delete-orphan")
attribute_list = 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 modifiers(self):
unified = {}
unified.update(**self.ancestry.modifiers)
for trait in self.traits:
unified.update(**trait.modifiers)
unified.update(**super().modifiers)
return unified
@property
def classes(self):
return dict([(mapping.character_class.name, mapping.character_class) for mapping in self.class_map])
@property
def traits(self):
return self.ancestry.traits
@property
def AC(self):
return self.apply_modifiers("armor_class", self.armor_class)
@property
def HP(self):
return self.apply_modifiers("max_hit_points", self.max_hit_points)
@property
def STR(self):
return self.apply_modifiers("strength", self.strength)
@property
def DEX(self):
return self.apply_modifiers("dexterity", self.dexterity)
@property
def CON(self):
return self.apply_modifiers("constitution", self.constitution)
@property
def INT(self):
return self.apply_modifiers("intelligence", self.intelligence)
@property
def WIS(self):
return self.apply_modifiers("wisdom", self.wisdom)
@property
def CHA(self):
return self.apply_modifiers("charisma", self.charisma)
@property
def speed(self):
return self.apply_modifiers("speed", self.ancestry.speed)
@property
def climb_speed(self):
return self.apply_modifiers("climb_speed", self.ancestry.climb_speed)
@property
def swim_speed(self):
return self.apply_modifiers("swim_speed", self.ancestry.swim_speed)
@property
def fly_speed(self):
return self.apply_modifiers("fly_speed", self.ancestry._fly_speed)
@property
def size(self):
return self.apply_modifiers("size", self.ancestry.size)
@property
def vision(self):
return self.apply_modifiers("vision", self._vision)
@property
def vision_in_darkness(self):
return self.apply_modifiers("vision_in_darkness", self.vision if self.vision is not None else 0)
@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])
@property
def class_attributes(self):
return dict([(mapping.class_attribute.name, mapping.option) for mapping in self.character_class_attribute_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
else:
self.class_list.append(CharacterClassMap(character_id=self.id, character_class=newclass, level=level))
for lvl in range(1, level + 1):
if not newclass.attributes_by_level[lvl]:
continue
for attr_name, attr in newclass.attributes_by_level[lvl].items():
self.add_class_attribute(attr, attr.options[0])
def remove_class(self, target):
self.class_map = [m for m in self.class_map if m.id != target.id]
for mapping in self.character_class_attribute_map:
if mapping.character_class.id == target.id:
self.remove_class_attribute(mapping.class_attribute)
def remove_class_attribute(self, attribute):
self.character_class_attribute_map = [m for m in self.character_class_attribute_map if m.id != attribute.id]
def add_class_attribute(self, attribute, option):
for thisclass in self.classes.values():
current_level = self.levels[thisclass.name]
current_attributes = thisclass.attributes_by_level.get(current_level, {})
if attribute.name in current_attributes:
if attribute.name in self.class_attributes:
return True
self.attribute_list.append(
CharacterClassAttributeMap(character_id=self.id, class_attribute=attribute, option=option)
)
return True
return False
def apply_modifiers(self, target, initial):
modifiers = list(reversed(self.modifiers.get(target, [])))
if initial is None:
return initial
if isinstance(initial, int):
absolute = [mod for mod in modifiers if mod.absolute_value is not None]
if absolute:
return absolute[0].absolute_value
multiple = [mod for mod in modifiers if mod.multiply_value is not None]
if multiple:
return int(initial * multiple[0].multiply_value + 0.5)
return initial + sum(mod.relative_value for mod in modifiers if mod.relative_value is not None)
new = [mod for mod in modifiers if mod.new_value is not None]
if new:
return new[0].new_value
return initial
+55
View File
@@ -0,0 +1,55 @@
from collections import defaultdict
from sqlalchemy import Column, Enum, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from ttfrog.db.base import BaseObject, SavingThrowsMixin, SkillsMixin, StatsEnum
__all__ = [
"ClassAttributeMap",
"ClassAttribute",
"ClassAttributeOption",
"CharacterClass",
]
class ClassAttributeMap(BaseObject):
__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)
attribute = relationship("ClassAttribute", uselist=False, viewonly=True, lazy="immediate")
class ClassAttribute(BaseObject):
__tablename__ = "class_attribute"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
options = relationship("ClassAttributeOption", cascade="all,delete,delete-orphan", lazy="immediate")
def __repr__(self):
return f"{self.id}: {self.name}"
class ClassAttributeOption(BaseObject):
__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)
class CharacterClass(BaseObject, 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", cascade="all,delete,delete-orphan", lazy="immediate")
@property
def attributes_by_level(self):
by_level = defaultdict(list)
for mapping in self.attributes:
by_level[mapping.level] = {mapping.attribute.name: mapping.attribute}
return by_level
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Text
from ttfrog.db.base import BaseObject
__all__ = ["TransactionLog"]
class TransactionLog(BaseObject):
__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)
+110
View File
@@ -0,0 +1,110 @@
from collections import defaultdict
from sqlalchemy import Column, Float, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import relationship
from ttfrog.db.base import BaseObject
class ModifierMap(BaseObject):
"""
Creates a many-to-many between Modifier and any model inheriting from the ModifierMixin.
"""
__tablename__ = "modifier_map"
__table_args__ = (UniqueConstraint("primary_table_name", "primary_table_id", "modifier_id"),)
id = Column(Integer, primary_key=True, autoincrement=True)
modifier_id = Column(Integer, ForeignKey("modifier.id"), nullable=False)
modifier = relationship("Modifier", uselist=False, lazy="immediate")
primary_table_name = Column(String, nullable=False)
primary_table_id = Column(Integer, nullable=False)
class Modifier(BaseObject):
"""
Modifiers modify the base value of an existing attribute on another table.
Modifiers are applied by the Character class, but may be associated with any model via the
ModifierMixIn model; refer to the Ancestry class for an example.
"""
__tablename__ = "modifier"
id = Column(Integer, primary_key=True, autoincrement=True)
target = Column(String, nullable=False)
absolute_value = Column(Integer)
relative_value = Column(Integer)
multiply_value = Column(Float)
new_value = Column(String)
name = Column(String, nullable=False)
description = Column(String)
class ModifierMixin:
"""
Add modifiers to an existing class.
Attributes:
modifier_map - get/set a list of Modifier records associated with the parent
modifiers - read-only dict of lists of modifiers keyed on Modifier.target
Methods:
add_modifier - Add a Modifier association to the modifier_map
remove_modifier - Remove a modifier association from the modifier_map
Example:
>>> class Item(BaseObject, ModifierMixin):
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
>>> dwarven_belt = Item(name="Dwarven Belt")
>>> dwarven_belt.add_modifier(Modifier(name="STR+1", target="strength", relative_value=1))
>>> dwarven_belt.modifiers
{'strength': [Modifier(id=1, target='strength', name='STR+1', relative_value=1 ... ]}
"""
@declared_attr
def modifier_map(cls):
return relationship(
"ModifierMap",
primaryjoin=(
"and_("
f"foreign(ModifierMap.primary_table_name)=='{cls.__tablename__}', "
f"foreign(ModifierMap.primary_table_id)=={cls.__name__}.id"
")"
),
cascade="all,delete,delete-orphan",
overlaps="modifier_map,modifier_map",
single_parent=True,
uselist=True,
lazy="immediate",
)
@property
def modifiers(self):
all_modifiers = defaultdict(list)
for mapping in self.modifier_map:
all_modifiers[mapping.modifier.target].append(mapping.modifier)
return all_modifiers
def add_modifier(self, modifier):
if modifier.absolute_value is not None and modifier.relative_value is not None and modifier.multiple_value:
raise AttributeError(f"You must provide only one of absolute, relative, and multiple values {modifier}.")
if [mod for mod in self.modifier_map if mod.modifier == modifier]:
return False
self.modifier_map.append(
ModifierMap(
primary_table_name=self.__tablename__,
primary_table_id=self.id,
modifier=modifier,
)
)
return True
def remove_modifier(self, modifier):
if modifier.id not in [mod.modifier_id for mod in self.modifier_map]:
return False
self.modifier_map = [mapping for mapping in self.modifier_map if mapping.modifier != modifier]
return True
+27
View File
@@ -0,0 +1,27 @@
from sqlalchemy import Column, Integer, String, Text
from ttfrog.db.base import BaseObject
__all__ = [
"Skill",
"Proficiency",
]
class Skill(BaseObject):
__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(BaseObject):
__tablename__ = "proficiency"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
def __repr__(self):
return str(self.name)
+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"
+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,170 @@
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"])
if self.character_class_map:
self.label.text = self.character_class_map.character_class.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:
logging.debug(f"Loading existing attribute {self = } {formdata = } {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.
"""
logging.debug(f"Loading existing class {self = } {formdata = } {obj = }")
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())
class_list = FieldList(FormField(MulticlassForm, label=None, widget=ListWidget()), min_entries=0)
newclass = FormField(MulticlassForm, widget=ListWidget())
attribute_list = 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["class_list"]:
return ret
err = ""
total_level = 0
for field in self.form.data["class_list"]:
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):
# step through the list of class mappings for this character
for class_name, class_def in self.record.classes.items():
logging.error(f"{class_name = }, {class_def = }")
for level in range(1, self.record.levels[class_name] + 1):
for attr in class_def.attributes_by_level.get(level, None):
self.record.add_class_attribute(attr, attr.options[0])
def save_callback(self):
# self.add_class_attributes()
pass
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["class_list"]
classes_formdata.append(self.form.data["newclass"])
del self.form.class_list
del self.form.newclass
# class attributes
attrs_formdata = self.form.data["attribute_list"]
del self.form.attribute_list
super().populate()
self.record.class_list = self.populate_association("character_class_id", classes_formdata)
self.record.attribute_list = 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)
+50
View File
@@ -0,0 +1,50 @@
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from ttfrog.db import schema
from ttfrog.db.manager import db as _db
FIXTURE_PATH = Path(__file__).parent / "fixtures"
def load_fixture(db, fixture_name):
with db.transaction():
data = json.loads((FIXTURE_PATH / f"{fixture_name}.json").read_text())
for schema_name in data:
for record in data[schema_name]:
print(f"Loading {schema_name} {record = }")
obj = getattr(schema, schema_name)(**record)
db.session.add(obj)
@pytest.fixture(autouse=True)
def db(monkeypatch):
monkeypatch.setattr("ttfrog.db.manager.database", MagicMock(return_value=""))
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
monkeypatch.setenv("DEBUG", "1")
_db.init()
yield _db
_db.metadata.drop_all(bind=_db.engine)
@pytest.fixture
def classes_factory(db):
load_fixture(db, "classes")
def factory():
return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all())
return factory
@pytest.fixture
def ancestries_factory(db):
load_fixture(db, "ancestry")
def factory():
return dict((rec.name, rec) for rec in db.session.query(schema.Ancestry).all())
return factory
+19
View File
@@ -0,0 +1,19 @@
{
"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},
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1},
{"ancestry_id": 2, "ancestry_trait_id": 3, "level": 1},
{"ancestry_id": 3, "ancestry_trait_id": 3, "level": 1}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"Ancestry": [
{"id": 1, "name": "+1 to All Ability Scores"},
{"id": 2, "name": "Breath Weapon"},
{"id": 3, "name": "Darkvision"}
]
}
+45
View File
@@ -0,0 +1,45 @@
{
"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"]
}
],
"ClassAttribute": [
{
"id": 1,
"name": "Fighting Style"
}
],
"ClassAttributeMap": [
{
"class_attribute_id": 1,
"character_class_id": 1,
"level": 2
}
],
"ClassAttributeOption": [
{
"attribute_id": 1,
"name": "Archery"
},
{
"attribute_id": 1,
"name": "Battlemaster"
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "multiclass_pc",
"classes": [
"fighter": 5,
"rogue": 3
]
}
+16
View File
@@ -0,0 +1,16 @@
import pytest
from ttfrog.db import schema
@pytest.mark.skip
def test_many_records(db):
with db.transaction():
for i in range(1, 1000):
obj = schema.Ancestry(name=f"{i}-ancestry")
db.add_or_update(obj)
assert obj.id == i
for i in range(1, 1000):
obj = schema.Character(name=f"{i}-char")
db.add_or_update(obj)
+185
View File
@@ -0,0 +1,185 @@
import json
from ttfrog.db import schema
def test_manage_character(db, classes_factory, ancestries_factory):
with db.transaction():
# load the fixtures so they are bound to the current session
classes = classes_factory()
ancestries = ancestries_factory()
darkvision = db.AncestryTrait.filter_by(name="Darkvision")[0]
# create a human character (the default)
char = schema.Character(name="Test Character")
db.add_or_update(char)
assert char.id == 1
assert char.name == "Test Character"
assert char.ancestry.name == "human"
assert char.AC == 10
assert char.HP == 10
assert char.STR == 10
assert char.DEX == 10
assert char.CON == 10
assert char.INT == 10
assert char.WIS == 10
assert char.CHA == 10
assert darkvision not in char.traits
# switch ancestry to tiefling
char.ancestry = ancestries["tiefling"]
db.add_or_update(char)
char = db.session.get(schema.Character, 1)
assert char.ancestry.name == "tiefling"
assert darkvision in char.traits
# switch ancestry to dragonborn and assert darkvision persists
char.ancestry = ancestries["dragonborn"]
db.add_or_update(char)
assert darkvision in char.traits
# switch ancestry to human and assert darkvision is removed
char.ancestry = ancestries["human"]
db.add_or_update(char)
assert darkvision not in char.traits
# assign a class and level
char.add_class(classes["fighter"], level=1)
db.add_or_update(char)
assert char.levels == {"fighter": 1}
assert char.level == 1
assert char.class_attributes == {}
# 'fighting style' is available, but not at this level
fighter = classes["fighter"]
fighting_style = fighter.attributes_by_level[2]["Fighting Style"]
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is False
db.add_or_update(char)
assert char.class_attributes == {}
# level up
char.add_class(classes["fighter"], level=2)
db.add_or_update(char)
assert char.levels == {"fighter": 2}
assert char.level == 2
# Assert the fighting style is added automatically and idempotent...ly?
assert char.class_attributes[fighting_style.name] == fighting_style.options[0]
assert char.add_class_attribute(fighting_style, fighting_style.options[0]) is True
db.add_or_update(char)
# classes
char.add_class(classes["rogue"], level=1)
db.add_or_update(char)
assert char.level == 3
assert char.levels == {"fighter": 2, "rogue": 1}
# remove a class
char.remove_class(classes["rogue"])
db.add_or_update(char)
assert char.levels == {"fighter": 2}
assert char.level == 2
# remove remaining class by setting level to zero
char.add_class(classes["fighter"], level=0)
db.add_or_update(char)
assert char.levels == {}
# ensure we're not persisting any orphan records in the map tables
dump = json.loads(db.dump())
assert dump["class_map"] == []
assert dump["character_class_attribute_map"] == []
def test_ancestries(db):
with db.transaction():
# create the Pygmy Orc ancestry
porc = schema.Ancestry(
name="Pygmy Orc",
size="Small",
walk_speed=25,
)
db.add_or_update(porc)
assert porc.name == "Pygmy Orc"
assert porc.creature_type == "humanoid"
assert porc.size == "Small"
assert porc.speed == 25
# create the Relentless Endurance trait and add it to the Orc
endurance = schema.AncestryTrait(name="Relentless Endurance")
db.add_or_update(endurance)
porc.add_trait(endurance, level=1)
db.add_or_update(porc)
assert endurance in porc.traits
# add a +1 STR modifier
str_plus_one = schema.Modifier(
name="STR+1 (Pygmy Orc)",
target="strength",
relative_value=1,
description="Your Strength score is increased by 1.",
)
assert porc.add_modifier(str_plus_one) is True
assert porc.add_modifier(str_plus_one) is False # test idempotency
assert str_plus_one in porc.modifiers["strength"]
# now create an orc character and assert it gets traits and modifiers
grognak = schema.Character(name="Grognak the Mighty", ancestry=porc)
db.add_or_update(grognak)
assert endurance in grognak.traits
# verify the strength bonus is applied
assert grognak.strength == 10
assert str_plus_one in grognak.modifiers["strength"]
assert grognak.STR == 11
def test_modifiers(db, classes_factory, ancestries_factory):
with db.transaction():
classes_factory()
ancestries = ancestries_factory()
# no modifiers; speed is ancestry speed
carl = schema.Character(name="Carl", ancestry=ancestries["elf"])
marx = schema.Character(name="Marx", ancestry=ancestries["human"])
db.add_or_update([carl, marx])
assert carl.speed == carl.ancestry.speed == 30
cold = schema.Modifier(target="speed", relative_value=-10, name="Cold")
hasted = schema.Modifier(target="speed", multiply_value=2.0, name="Hasted")
slowed = schema.Modifier(target="speed", multiply_value=0.5, name="Slowed")
restrained = schema.Modifier(target="speed", absolute_value=0, name="Restrained")
reduced = schema.Modifier(target="size", new_value="Tiny", name="Reduced")
# reduce speed by 10
assert carl.add_modifier(cold)
assert carl.speed == 20
# make sure modifiers only apply to carl. Carl is having a bad day.
assert marx.speed == 30
# speed is doubled
assert carl.remove_modifier(cold)
assert carl.add_modifier(hasted)
assert carl.speed == 60
# speed is halved
assert carl.remove_modifier(hasted)
assert carl.add_modifier(slowed)
assert carl.speed == 15
# speed is 0
assert carl.add_modifier(restrained)
assert carl.speed == 0
# no longer restrained, but still slowed
assert carl.remove_modifier(restrained)
assert carl.speed == 15
# back to normal
assert carl.remove_modifier(slowed)
assert carl.speed == carl.ancestry.speed
# modifiers can modify string values too
assert carl.add_modifier(reduced)
assert carl.size == "Tiny"
-4
View File
@@ -1,4 +0,0 @@
from .manager import db, session
__ALL__ = [db, session]
-47
View File
@@ -1,47 +0,0 @@
import base64
import hashlib
import logging
from ttfrog.db import db, session
# move this to json or whatever
data = {
'ancestry': [
{'name': 'human'},
{'name': 'dragonborn'},
],
}
def slug_from_rec(rec):
"""
Create a uniquish slug from a dictionary.
"""
sha1bytes = hashlib.sha1(str(rec).encode())
return '-'.join([
base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10],
rec.get('name', '') # will need to normalize this for URLs
])
def bootstrap():
"""
Initialize the database with source data. Idempotent; will skip anything that already exists.
"""
db.init_model()
for table_name, table in db.tables.items():
if table_name not in data:
logging.debug("No bootstrap data for table {table_name}; skipping.")
continue
for rec in data[table_name]:
if 'slug' in table.columns:
rec['slug'] = slug_from_rec(rec)
stmt = table.insert().values(**rec).prefix_with("OR IGNORE")
result = session.execute(stmt)
session.commit()
last_id = result.inserted_primary_key[0]
if last_id == 0:
logging.info(f"Skipped existing {table_name} {rec}")
else:
logging.info(f"Created {table_name} {result.inserted_primary_key[0]}: {rec}")
-54
View File
@@ -1,54 +0,0 @@
from functools import cached_property
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from ttfrog.path import database
from ttfrog.db.schema import metadata
class SQLDatabaseManager:
"""
A context manager for working with sqllite database.
"""
@cached_property
def url(self):
return f"sqlite:///{database()}"
@cached_property
def engine(self):
return create_engine(self.url, future=True)
@cached_property
def DBSession(self):
maker = sessionmaker(bind=self.engine, future=True, autoflush=True)
return scoped_session(maker)
@cached_property
def tables(self):
return dict((t.name, t) for t in metadata.sorted_tables)
def query(self, *args, **kwargs):
return self.DBSession.query(*args, **kwargs)
def init_model(self, engine=None):
metadata.create_all(bind=engine or self.engine)
return self.DBSession
def __getattr__(self, name: str):
try:
return self.tables[name]
except KeyError:
raise AttributeError(f"{self} does not contain the attribute '{name}'.")
def __enter__(self):
self.init_model(self.engine)
return self
def __exit__(self, exc_type, exc_value, traceback):
if self.DBSession:
self.DBSession.close()
db = SQLDatabaseManager()
session = db.DBSession
-30
View File
@@ -1,30 +0,0 @@
from sqlalchemy import MetaData
from sqlalchemy import Table
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import UnicodeText
from sqlalchemy import ForeignKey
# from sqlalchemy import PrimaryKeyConstraint
# from sqlalchemy import DateTime
metadata = MetaData()
Ancestry = Table(
"ancestry",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("slug", String, index=True, unique=True),
Column("name", String, index=True, unique=True),
Column("description", UnicodeText),
)
Character = Table(
"character",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("slug", String, index=True, unique=True),
Column("name", String),
Column("ancestry_id", Integer, ForeignKey("ancestry.id")),
)
-31
View File
@@ -1,31 +0,0 @@
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'
-59
View File
@@ -1,59 +0,0 @@
import logging
from tg import MinimalApplicationConfigurator
from tg.configurator.components.statics import StaticsConfigurationComponent
from tg.configurator.components.sqlalchemy import SQLAlchemyConfigurationComponent
from tg.util.bunch import Bunch
from wsgiref.simple_server import make_server
import webhelpers2
from ttfrog.webserver import controllers
from ttfrog.db import db
import ttfrog.path
def app_globals():
return Bunch
def application():
"""
Create a TurboGears2 application
"""
config = MinimalApplicationConfigurator()
config.register(StaticsConfigurationComponent)
config.register(SQLAlchemyConfigurationComponent)
config.update_blueprint({
# rendering
'root_controller': controllers.RootController(),
'default_renderer': 'jinja',
'renderers': ['jinja'],
'tg.jinja_filters': {},
'auto_reload_templates': True,
# helpers
'app_globals': app_globals,
'helpers': webhelpers2,
'tw2.enabled': True,
# assets
'serve_static': True,
'paths': {
'static_files': ttfrog.path.static_files(),
'templates': [ttfrog.path.templates()],
},
# db
'use_sqlalchemy': True,
'sqlalchemy.url': db.url,
'model': db,
})
return config.make_wsgi_app()
def start(host: str, port: int, debug: bool = False) -> None:
logging.debug(f"Configuring webserver with {host=}, {port=}, {debug=}")
make_server(host, int(port), application()).serve_forever()
-16
View File
@@ -1,16 +0,0 @@
from tg import expose
from tg import TGController
from tg import tmpl_context
from ttfrog.db import db
from ttfrog.db.schema import Character
class RootController(TGController):
def _before(self, *args, **kwargs):
tmpl_context.project_name = 'TableTop Frog'
@expose('index.html')
def index(self):
ancestries = [row._mapping for row in db.query(db.ancestry).all()]
return dict(page='index', content=str(ancestries))