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

@ -5,7 +5,7 @@ description = ""
authors = ["evilchili <evilchili@gmail.com>"]
readme = "README.md"
packages = [
{ include = 'ttfrog' },
{include = "*", from = "src"},
]
[tool.poetry.dependencies]
@ -26,9 +26,9 @@ 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]
requires = ["poetry-core"]
@ -37,3 +37,30 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
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

View File

@ -41,6 +41,7 @@ class AttributeMap(Mapping):
"""
attributes: field(default_factory=dict)
def __getattr__(self, attr):

View File

@ -2,8 +2,8 @@ import io
import logging
import os
from pathlib import Path
from typing import Optional
from textwrap import dedent
from typing import Optional
import typer
from dotenv import load_dotenv
@ -12,9 +12,8 @@ from rich.logging import RichHandler
from ttfrog.path import assets
default_data_path = Path("~/.dnd/ttfrog")
default_host = '127.0.0.1'
default_host = "127.0.0.1"
default_port = 2323
SETUP_HELP = f"""
@ -47,18 +46,16 @@ def main(
root: Optional[Path] = typer.Option(
default_data_path,
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(app_state['env'])
debug = os.getenv('DEBUG', None)
load_dotenv(app_state["env"])
debug = os.getenv("DEBUG", None)
logging.basicConfig(
format='%(message)s',
format="%(message)s",
level=logging.DEBUG if debug else logging.INFO,
handlers=[
RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])
]
handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])],
)
@ -68,9 +65,10 @@ 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))
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()
@ -86,23 +84,20 @@ def serve(
default_port,
help="bind port",
),
debug: bool = typer.Option(
False,
help='Enable debugging output'
),
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.webserver import application
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__':
if __name__ == "__main__":
app()

View File

@ -1,11 +1,10 @@
import enum
import logging
import nanoid
from nanoid_dictionary import human_alphabet
from sqlalchemy import Column
from sqlalchemy import String
from pyramid_sqlalchemy import BaseObject
from slugify import slugify
from sqlalchemy import Column, String
def genslug():
@ -17,16 +16,14 @@ class SlugMixin:
@property
def uri(self):
return '-'.join([
self.slug,
slugify(self.name.title().replace(' ', ''), ok='', only_ascii=True, lower=False)
])
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():
@ -40,16 +37,16 @@ class IterableMixin:
continue
for rel in reliter:
try:
relvals.append({k: v for k, v in vars(rel).items() if not k.startswith('_')})
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:
for key, value in self:
try:
serialized[key] = getattr(self.value, '__json__')(request)
serialized[key] = getattr(self.value, "__json__")(request)
except AttributeError:
serialized[key] = value
return serialized
@ -58,7 +55,7 @@ class IterableMixin:
return str(dict(self))
def multivalue_string_factory(name, column=Column(String), separator=';'):
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:
@ -77,27 +74,46 @@ def multivalue_string_factory(name, column=Column(String), separator=';'):
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, ), {
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')
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']
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))

192
src/ttfrog/db/bootstrap.py Normal file
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

View File

@ -1,20 +1,19 @@
import os
import transaction
import base64
import hashlib
import os
from contextlib import contextmanager
from functools import cached_property
from pyramid_sqlalchemy import Session
from pyramid_sqlalchemy import init_sqlalchemy
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
from ttfrog.path import database
import ttfrog.db.schema
ttfrog.db.schema
@ -23,9 +22,10 @@ class SQLDatabaseManager:
"""
A context manager for working with sqllite database.
"""
@cached_property
def url(self):
return os.environ.get('DATABASE_URL', f"sqlite:///{database()}")
return os.environ.get("DATABASE_URL", f"sqlite:///{database()}")
@cached_property
def engine(self):
@ -64,7 +64,7 @@ class SQLDatabaseManager:
"""
Create a uniquish slug from a dictionary.
"""
sha1bytes = hashlib.sha1(str(rec['id']).encode())
sha1bytes = hashlib.sha1(str(rec["id"]).encode())
return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10]
def init(self):
@ -73,7 +73,7 @@ class SQLDatabaseManager:
def dump(self):
results = {}
for (table_name, table) in self.tables.items():
for table_name, table in self.tables.items():
results[table_name] = [row for row in self.query(table).all()]
return results

View File

@ -1,24 +1,16 @@
from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin
from ttfrog.db.base import CreatureTypesEnum
from sqlalchemy import Column
from sqlalchemy import Enum
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Text
from sqlalchemy import ForeignKey
from sqlalchemy import UniqueConstraint
from sqlalchemy.orm import relationship
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',
"Ancestry",
"AncestryTrait",
"AncestryTraitMap",
"CharacterClassMap",
"CharacterClassAttributeMap",
"Character",
]
@ -39,19 +31,20 @@ class AncestryTraitMap(BaseObject):
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})
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')
traits = relationship("AncestryTraitMap", lazy="immediate")
def __repr__(self):
return self.name
@ -61,6 +54,7 @@ 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)
@ -76,9 +70,9 @@ class CharacterClassMap(BaseObject, IterableMixin):
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)
level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1)
character_class = relationship("CharacterClass", lazy='immediate')
character_class = relationship("CharacterClass", lazy="immediate")
character = relationship("Character", uselist=False, viewonly=True)
def __repr__(self):
@ -93,42 +87,42 @@ class CharacterClassAttributeMap(BaseObject, IterableMixin):
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')
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
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})
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)
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)
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_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry = relationship("Ancestry", uselist=False)
@property
@ -151,11 +145,7 @@ class Character(*Bases, SavingThrowsMixin, SkillsMixin):
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
))
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]

View File

@ -1,20 +1,13 @@
from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin
from ttfrog.db.base import StatsEnum
from sqlalchemy import Column
from sqlalchemy import Enum
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Text
from sqlalchemy import ForeignKey
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',
"ClassAttributeMap",
"ClassAttribute",
"ClassAttributeOption",
"CharacterClass",
]
@ -22,7 +15,7 @@ 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)
level = Column(Integer, nullable=False, info={"min": 1, "max": 20}, default=1)
class ClassAttribute(BaseObject, IterableMixin):
@ -46,7 +39,7 @@ 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 = Column(String, default="1d6")
hit_dice_stat = Column(Enum(StatsEnum))
proficiencies = Column(String)
attributes = relationship("ClassAttributeMap")

View File

@ -1,16 +1,11 @@
from ttfrog.db.base import Bases, BaseObject, IterableMixin
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Text
from sqlalchemy import UniqueConstraint
from sqlalchemy import Column, Integer, String, Text, UniqueConstraint
from ttfrog.db.base import BaseObject, Bases, IterableMixin
__all__ = [
'Skill',
'Proficiency',
'Modifier',
"Skill",
"Proficiency",
"Modifier",
]
@ -35,9 +30,7 @@ class Proficiency(*Bases):
class Modifier(BaseObject, IterableMixin):
__tablename__ = "modifier"
__table_args__ = (
UniqueConstraint('source_table_name', 'source_table_id', 'value', 'type', 'target'),
)
__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)

View File

@ -1,10 +1,9 @@
from ttfrog.db.base import BaseObject, IterableMixin
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Text
from sqlalchemy import Column, Integer, String, Text
from ttfrog.db.base import BaseObject, IterableMixin
__all__ = ["TransactionLog"]
__all__ = ['TransactionLog']
class TransactionLog(BaseObject, IterableMixin):
__tablename__ = "transaction_log"

29
src/ttfrog/path.py Normal file
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

@ -1,6 +1,6 @@
import logging
from wsgiref.simple_server import make_server
from pyramid.config import Configurator
from ttfrog.db.manager import db
@ -8,15 +8,12 @@ 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.')
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
@ -25,5 +22,5 @@ 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')
config.scan("ttfrog.webserver.views")
make_server(host, int(port), config.make_wsgi_app()).serve_forever()

View File

@ -1,12 +1,13 @@
from ttfrog.db.schema import Ancestry
from ttfrog.db.manager import db
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']
exclude = ["slug"]
def get_session():
return db.session

View File

@ -1,23 +1,21 @@
import logging
import re
from collections import defaultdict
from pyramid.httpexceptions import HTTPFound
from pyramid.interfaces import IRoutesMapper
from ttfrog.db.manager import db
from ttfrog.db import transaction_log
def get_all_routes(request):
routes = {
'static': '/static',
"static": "/static",
}
uri_pattern = re.compile(r"^([^\{\*]+)")
mapper = request.registry.queryUtility(IRoutesMapper)
for route in mapper.get_routes():
if route.name.startswith('__'):
if route.name.startswith("__"):
continue
m = uri_pattern.search(route.pattern)
if m:
@ -36,17 +34,14 @@ class BaseController:
self._record = None
self._form = None
self.config = {
'static_url': '/static',
'project_name': 'TTFROG'
}
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('/', '')
parts = self.request.matchdict.get("uri", "").split("-")
self._slug = parts[0].replace("/", "")
return self._slug
@property
@ -78,12 +73,12 @@ class BaseController:
@property
def resources(self):
return [
{'type': 'style', 'uri': 'css/styles.css'},
{"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()
if "all_records" not in self.attrs:
self.attrs["all_records"] = db.query(self.model).all()
def template_context(self, **kwargs) -> dict:
return dict(
@ -103,14 +98,14 @@ class BaseController:
def populate_association(self, key, formdata):
populated = []
for field in formdata:
map_id = field.pop('id')
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
field["id"] = map_id
populated.append(field)
return populated

View File

@ -1,29 +1,25 @@
import logging
from ttfrog.webserver.controllers.base import BaseController
from ttfrog.webserver.forms import DeferredSelectField
from ttfrog.webserver.forms import NullableDeferredSelectField
from ttfrog.db.schema import (
Character,
Ancestry,
CharacterClass,
CharacterClassMap,
ClassAttributeOption,
CharacterClassAttributeMap
)
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 wtforms_alchemy import ModelForm
from wtforms.fields import SubmitField, SelectField, SelectMultipleField, FieldList, FormField, HiddenField
from wtforms.widgets import Select, ListWidget
from wtforms import ValidationError
from wtforms.validators import Optional
from wtforms.widgets.core import html_params
from markupsafe import Markup
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)
@ -48,7 +44,7 @@ class ClassAttributesFormField(FormField):
def process(self, *args, **kwargs):
super().process(*args, **kwargs)
self.character_class_map = db.query(CharacterClassAttributeMap).get(self.data['id'])
self.character_class_map = db.query(CharacterClassAttributeMap).get(self.data["id"])
self.label.text = self.character_class_map.character_class[0].name
@ -56,12 +52,7 @@ class ClassAttributesForm(ModelForm):
id = HiddenField()
class_attribute_id = HiddenField()
option_id = SelectField(
widget=Select(),
choices=[],
validators=[Optional()],
coerce=int
)
option_id = SelectField(widget=Select(), choices=[], validators=[Optional()], coerce=int)
def __init__(self, formdata=None, obj=None, prefix=None):
if obj:
@ -74,13 +65,9 @@ class ClassAttributesForm(ModelForm):
class MulticlassForm(ModelForm):
id = HiddenField()
character_class_id = NullableDeferredSelectField(
model=CharacterClass,
validate_choice=True,
widget=Select(),
coerce=int
model=CharacterClass, validate_choice=True, widget=Select(), coerce=int
)
level = SelectField(choices=VALID_LEVELS, default=1, coerce=int, validate_choice=True, widget=Select())
@ -98,20 +85,19 @@ class MulticlassForm(ModelForm):
class CharacterForm(ModelForm):
class Meta:
model = Character
exclude = ['slug']
exclude = ["slug"]
save = SubmitField()
delete = SubmitField()
ancestry_id = DeferredSelectField('Ancestry', model=Ancestry, default=1, validate_choice=True, widget=Select())
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
ClassAttributesFormField(ClassAttributesForm, widget=ClassAttributeWidget()), min_entries=1
)
saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS)
saving_throws = SelectMultipleField("Saving Throws", validate_choice=True, choices=STATS)
class CharacterSheet(BaseController):
@ -121,7 +107,7 @@ class CharacterSheet(BaseController):
@property
def resources(self):
return super().resources + [
{'type': 'script', 'uri': 'js/character_sheet.js'},
{"type": "script", "uri": "js/character_sheet.js"},
]
def validate_callback(self):
@ -129,13 +115,13 @@ class CharacterSheet(BaseController):
Validate multiclass fields in form data.
"""
ret = super().validate()
if not self.form.data['classes']:
if not self.form.data["classes"]:
return ret
err = ""
total_level = 0
for field in self.form.data['classes']:
level = field.get('level')
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."
@ -150,9 +136,10 @@ class CharacterSheet(BaseController):
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()
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]
@ -165,18 +152,19 @@ class CharacterSheet(BaseController):
# 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()
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,
})
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()
@ -188,16 +176,16 @@ class CharacterSheet(BaseController):
"""
# multiclass form
classes_formdata = self.form.data['classes']
classes_formdata.append(self.form.data['newclass'])
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']
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)
self.record.classes = self.populate_association("character_class_id", classes_formdata)
self.record.class_attributes = self.populate_association("class_attribute_id", attrs_formdata)

View File

@ -1,10 +1,9 @@
import logging
from pyramid.httpexceptions import exception_response
from ttfrog.db import schema
from ttfrog.db.manager import db
from .base import BaseController
from pyramid.httpexceptions import exception_response
from .base import BaseController
class JsonData(BaseController):
@ -13,13 +12,10 @@ class JsonData(BaseController):
def configure_for_model(self):
try:
self.model = getattr(schema, self.request.matchdict.get('table_name'))
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()
}
return {"table_name": self.model.__tablename__, "records": query.all()}

View File

@ -1,6 +1,7 @@
from ttfrog.db.manager import db
from wtforms.fields import SelectField, SelectMultipleField
from ttfrog.db.manager import db
class DeferredSelectMultipleField(SelectMultipleField):
def __init__(self, *args, model=None, **kwargs):
@ -11,10 +12,10 @@ class DeferredSelectMultipleField(SelectMultipleField):
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()]
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):
def __init__(self, *args, model=None, label="---", **kwargs):
super().__init__(*args, model=model, **kwargs)
self.choices = [(0, label)] + self.choices

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")

View File

@ -1,23 +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
from ttfrog.attribute_map import AttributeMap
def response_from(controller):
return controller.response() or AttributeMap.from_dict({'c': controller.template_context()})
return controller.response() or AttributeMap.from_dict({"c": controller.template_context()})
@view_config(route_name='index')
@view_config(route_name="index")
def index(request):
ancestries = [a.name for a in db.session.query(Ancestry).all()]
return Response(','.join(ancestries))
return Response(",".join(ancestries))
@view_config(route_name='sheet', renderer='character_sheet.html')
@view_config(route_name="sheet", renderer="character_sheet.html")
def sheet(request):
return response_from(request.context)
@view_config(route_name='data', renderer='json')
@view_config(route_name="data", renderer="json")
def data(request):
return response_from(request.context)

View File

@ -7,8 +7,7 @@ import pytest
from ttfrog.db import schema
from ttfrog.db.manager import db as _db
FIXTURE_PATH = Path(__file__).parent / 'fixtures'
FIXTURE_PATH = Path(__file__).parent / "fixtures"
def load_fixture(db, fixture_name):
@ -23,20 +22,20 @@ def load_fixture(db, fixture_name):
@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')
monkeypatch.setattr("ttfrog.db.manager.database", MagicMock(return_value=""))
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
monkeypatch.setenv("DEBUG", "1")
_db.init()
return _db
@pytest.fixture
def classes(db):
load_fixture(db, 'classes')
load_fixture(db, "classes")
return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all())
@pytest.fixture
def ancestries(db):
load_fixture(db, 'ancestry')
load_fixture(db, "ancestry")
return dict((rec.name, rec) for rec in db.session.query(schema.Ancestry).all())

View File

@ -3,54 +3,54 @@ from ttfrog.db import schema
def test_create_character(db, classes, ancestries):
with db.transaction():
darkvision = db.session.query(schema.AncestryTrait).filter_by(name='Darkvision')[0]
darkvision = db.session.query(schema.AncestryTrait).filter_by(name="Darkvision")[0]
# create a human character (the default)
char = schema.Character(name='Test Character')
char = schema.Character(name="Test Character")
db.add(char)
assert char.id == 1
assert char.armor_class == 10
assert char.name == 'Test Character'
assert char.ancestry.name == 'human'
assert char.name == "Test Character"
assert char.ancestry.name == "human"
assert darkvision not in char.traits
# switch ancestry to tiefling
char.ancestry = ancestries['tiefling']
char.ancestry = ancestries["tiefling"]
db.add(char)
char = db.session.get(schema.Character, 1)
assert char.ancestry.name == 'tiefling'
assert char.ancestry.name == "tiefling"
assert darkvision in char.traits
# assign a class and level
char.add_class(classes['fighter'], level=1)
char.add_class(classes["fighter"], level=1)
db.add(char)
assert char.levels == {'fighter': 1}
assert char.levels == {"fighter": 1}
assert char.level == 1
assert char.class_attributes == []
# level up
char.add_class(classes['fighter'], level=2)
char.add_class(classes["fighter"], level=2)
db.add(char)
assert char.levels == {'fighter': 2}
assert char.levels == {"fighter": 2}
assert char.level == 2
assert char.class_attributes == []
# multiclass
char.add_class(classes['rogue'], level=1)
char.add_class(classes["rogue"], level=1)
db.add(char)
assert char.level == 3
assert char.levels == {'fighter': 2, 'rogue': 1}
assert char.levels == {"fighter": 2, "rogue": 1}
# remove a class
char.remove_class(classes['rogue'])
char.remove_class(classes["rogue"])
db.add(char)
assert char.levels == {'fighter': 2}
assert char.levels == {"fighter": 2}
assert char.level == 2
# remove all remaining classes
char.remove_class(classes['fighter'])
char.remove_class(classes["fighter"])
db.add(char)
# ensure we're not persisting any orphan records in the map table
dump = db.dump()
assert dump['class_map'] == []
assert dump["class_map"] == []

View File

@ -1,166 +0,0 @@
import logging
from ttfrog.db.manager import db
from ttfrog.db import schema
from sqlalchemy.exc import IntegrityError
# 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

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'

View File

@ -1,4 +0,0 @@
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')