modeling many-to-many relationships

This commit is contained in:
evilchili 2024-02-18 19:30:41 -08:00
parent e231828425
commit ba0e66f9af
12 changed files with 307 additions and 184 deletions

View File

@ -15,7 +15,12 @@
<div><img id='portrait' /></div> <div><img id='portrait' /></div>
<div> <div>
{{ field('name') }} {{ field('name') }}
{{ field('ancestry') }} {{ c.record.character_class|join(' / ') }} &nbsp; Level {{ field('level') }} {{ field('ancestry') }}
{% for rec in c.record.classes %}
{{ rec.character_class.name }} {{ rec.level }}
{% endfor %}
{{ c.record.character_class|join(' / ') }} &nbsp;
<div id='controls'> <div id='controls'>
{{ c.form.save }} &nbsp; {{ c.form.delete }} {{ c.form.save }} &nbsp; {{ c.form.delete }}
</div> </div>
@ -135,6 +140,14 @@
<ul> <ul>
</ul> </ul>
</div> </div>
<div class='card'>
<div class='label'>Attributes</div>
<ul>
{% for rec in c.record.attributes %}
<li>{{ rec.attribute.name }}: {{ rec.attribute.value }}</li>
{% endfor %}
</ul>
</div>
<div class='card'> <div class='card'>
<div class='label'>Defenses</div> <div class='label'>Defenses</div>
<ul> <ul>
@ -159,7 +172,7 @@
<code> <code>
{{ DISABLED }} {{ DISABLED }}
<code> <code>
{{ c }} {{ c.record }}
</code> </code>
{% endblock %} {% endblock %}
@ -170,7 +183,7 @@
console.log("{{ field }}: {{ msg }}"); console.log("{{ field }}: {{ msg }}");
{% endfor %} {% endfor %}
const TRAITS = { const TRAITS = {
{% for trait_desc, traits in c.traits.items() %} {% for trait_desc, traits in [] %}
'{{ trait_desc }}': [ '{{ trait_desc }}': [
{% for trait in traits %} {% for trait in traits %}
{ {

View File

@ -1,3 +1,4 @@
import enum
import logging import logging
import nanoid import nanoid
from nanoid_dictionary import human_alphabet from nanoid_dictionary import human_alphabet
@ -33,7 +34,11 @@ class IterableMixin:
yield attr, values[attr] yield attr, values[attr]
for relname in self.__mapper__.relationships.keys(): for relname in self.__mapper__.relationships.keys():
relvals = [] relvals = []
for rel in self.__getattribute__(relname): reliter = self.__getattribute__(relname)
if not reliter:
yield relname, relvals
continue
for rel in reliter:
try: 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: except TypeError:
@ -49,6 +54,9 @@ class IterableMixin:
serialized[key] = value serialized[key] = value
return serialized return serialized
def __repr__(self):
return str(dict(self))
def multivalue_string_factory(name, column=Column(String), separator=';'): def multivalue_string_factory(name, column=Column(String), separator=';'):
""" """
@ -76,5 +84,22 @@ def multivalue_string_factory(name, column=Column(String), separator=';'):
}) })
class EnumField(enum.Enum):
"""
A serializable enum.
"""
def __json__(self, request):
return self.value
SavingThrowsMixin = multivalue_string_factory('saving_throws')
SkillsMixin = multivalue_string_factory('skills')
STATS = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']
CREATURE_TYPES = ['aberation', 'beast', 'celestial', 'construct', 'dragon', 'elemental', 'fey', 'fiend', 'Giant',
'humanoid', 'monstrosity', 'ooze', 'plant', 'undead']
CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES))
StatsEnum = EnumField("StatsEnum", ((k, k) for k in STATS))
# class Table(*Bases): # class Table(*Bases):
Bases = [BaseObject, IterableMixin, SlugMixin] Bases = [BaseObject, IterableMixin, SlugMixin]

View File

@ -49,15 +49,40 @@ data = {
{'id': 1, 'name': 'human', 'creature_type': 'humanoid'}, {'id': 1, 'name': 'human', 'creature_type': 'humanoid'},
{'id': 2, 'name': 'dragonborn', 'creature_type': 'humanoid'}, {'id': 2, 'name': 'dragonborn', 'creature_type': 'humanoid'},
{'id': 3, 'name': 'tiefling', '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': [ 'Character': [
{ {
'id': 1, 'id': 1,
'name': 'Sabetha', 'name': 'Sabetha',
'ancestry': 'human', 'ancestry_id': 1,
'character_class': ['fighter', 'rogue'],
'level': 1,
'armor_class': 10, 'armor_class': 10,
'max_hit_points': 14, 'max_hit_points': 14,
'hit_points': 14, 'hit_points': 14,
@ -76,29 +101,18 @@ data = {
], ],
'ClassAttribute': [ 'ClassAttribute': [
{ {'id': 1, 'name': 'Fighting Style', 'value': 'Archery'},
'character_class_id': 1,
'name': 'Fighting Style',
'value': 'Archery',
'level': 1,
},
], ],
'AncestryTrait': [ 'ClassAttributeMap': [
{ {'class_attribute_id': 1, 'character_class_id': 1, 'level': 2}, # Fighter: Archery fighting style
'id': 1,
'ancestry_id': 1,
'name': '+1 to All Ability Scores',
'level': 1,
},
{
'id': 2,
'ancestry_id': 2,
'name': 'Breath Weapon',
'level': 1,
},
], ],
'CharacterClassAttributeMap': [
{'class_attribute_id': 1, 'character_id': 1}, # Sabetha: Archery fighting style
],
'Modifier': [ 'Modifier': [
# Humans # 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': 'str'},

View File

@ -1,141 +0,0 @@
import enum
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import ForeignKey
from sqlalchemy import Enum
from sqlalchemy import Text
from sqlalchemy import UniqueConstraint
from sqlalchemy.orm import relationship
from ttfrog.db.base import Bases, BaseObject, IterableMixin
from ttfrog.db.base import multivalue_string_factory
STATS = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']
CREATURE_TYPES = ['aberation', 'beast', 'celestial', 'construct', 'dragon', 'elemental', 'fey', 'fiend', 'Giant',
'humanoid', 'monstrosity', 'ooze', 'plant', 'undead']
class EnumField(enum.Enum):
"""
A serializable enum.
"""
def __json__(self, request):
return self.value
# enums for db schemas
StatsEnum = EnumField("StatsEnum", ((k, k) for k in STATS))
CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES))
CharacterClassMixin = multivalue_string_factory('character_class', Column(String, nullable=False))
SavingThrowsMixin = multivalue_string_factory('saving_throws')
SkillsMixin = multivalue_string_factory('skills')
class Skill(*Bases):
__tablename__ = "skill"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
description = Column(Text)
def __repr__(self):
return str(self.name)
class Proficiency(*Bases):
__tablename__ = "proficiency"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
def __repr__(self):
return str(self.name)
class Ancestry(*Bases):
__tablename__ = "ancestry"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
creature_type = Column(Enum(CreatureTypesEnum))
traits = relationship("AncestryTrait")
def __repr__(self):
return str(self.name)
class AncestryTrait(BaseObject, IterableMixin):
__tablename__ = "ancestry_trait"
id = Column(Integer, primary_key=True, autoincrement=True)
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False)
name = Column(String, nullable=False)
description = Column(Text)
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20})
class CharacterClass(*Bases, SavingThrowsMixin, SkillsMixin):
__tablename__ = "character_class"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
hit_dice = Column(String, default='1d6')
hit_dice_stat = Column(Enum(StatsEnum))
proficiencies = Column(String)
def __repr__(self):
return str(self.name)
class ClassAttribute(BaseObject, IterableMixin):
__tablename__ = "class_attribute"
id = Column(Integer, primary_key=True, autoincrement=True)
character_class_id = Column(Integer, ForeignKey("character_class.id"), nullable=False)
name = Column(String, nullable=False)
value = Column(String, nullable=False)
description = Column(Text)
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20})
def __repr__(self):
return str(self.name)
class Character(*Bases, CharacterClassMixin, SavingThrowsMixin, SkillsMixin):
__tablename__ = "character"
id = Column(Integer, primary_key=True, autoincrement=True)
ancestry = Column(String, ForeignKey("ancestry.name"), nullable=False, default='human')
name = Column(String, default='New Character', nullable=False)
level = Column(Integer, default=1, nullable=False, info={'min': 1, 'max': 20})
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 Modifier(BaseObject, IterableMixin):
__tablename__ = "modifier"
__table_args__ = (
UniqueConstraint('source_table_name', 'source_table_id', 'value', 'type', 'target'),
)
id = Column(Integer, primary_key=True, autoincrement=True)
source_table_name = Column(String, index=True, nullable=False)
source_table_id = Column(Integer, index=True, nullable=False)
value = Column(String, nullable=False)
type = Column(String, nullable=False)
target = Column(String, nullable=False)
class TransactionLog(BaseObject, IterableMixin):
__tablename__ = "transaction_log"
id = Column(Integer, primary_key=True, autoincrement=True)
source_table_name = Column(String, index=True, nullable=False)
primary_key = Column(Integer, index=True)
diff = Column(Text)

View File

@ -0,0 +1,4 @@
from .character import *
from .classes import *
from .property import *
from .transaction import *

View File

@ -0,0 +1,91 @@
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.orm import relationship
__all__ = [
'Ancestry',
'AncestryTrait',
'AncestryTraitMap',
'CharacterClassMap',
'CharacterClassAttributeMap',
'Character',
]
class AncestryTraitMap(BaseObject):
__tablename__ = "trait_map"
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), primary_key=True)
ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id"), primary_key=True)
trait = relationship("AncestryTrait", lazy='immediate')
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20})
class Ancestry(*Bases):
"""
A character ancestry ("race"), which has zero or more AncestryTraits.
"""
__tablename__ = "ancestry"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
creature_type = Column(Enum(CreatureTypesEnum))
traits = relationship("AncestryTraitMap", lazy='immediate')
def __repr__(self):
return self.name
class AncestryTrait(BaseObject, IterableMixin):
"""
A trait granted to a character via its Ancestry.
"""
__tablename__ = "ancestry_trait"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
description = Column(Text)
class CharacterClassMap(BaseObject):
__tablename__ = "class_map"
character_id = Column(Integer, ForeignKey("character.id"), primary_key=True)
character_class_id = Column(Integer, ForeignKey("character_class.id"), primary_key=True)
character_class = relationship("CharacterClass", lazy='immediate')
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1)
class CharacterClassAttributeMap(BaseObject):
__tablename__ = "character_class_attribute_map"
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), primary_key=True)
character_id = Column(Integer, ForeignKey("character.id"), primary_key=True)
attribute = relationship("ClassAttribute", lazy='immediate')
class Character(*Bases, SavingThrowsMixin, SkillsMixin):
__tablename__ = "character"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, default='New Character', nullable=False)
armor_class = Column(Integer, default=10, nullable=False, info={'min': 1, 'max': 99})
hit_points = Column(Integer, default=1, nullable=False, info={'min': 0, 'max': 999})
max_hit_points = Column(Integer, default=1, nullable=False, info={'min': 0, 'max': 999})
temp_hit_points = Column(Integer, default=0, nullable=False, info={'min': 0, 'max': 999})
speed = Column(Integer, nullable=False, default=30, info={'min': 0, 'max': 99})
str = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30})
dex = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30})
con = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30})
int = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30})
wis = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30})
cha = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30})
proficiencies = Column(String)
classes = relationship("CharacterClassMap")
attributes = relationship("CharacterClassAttributeMap")
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1')
ancestry = relationship("Ancestry", uselist=False)

View File

@ -0,0 +1,43 @@
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.orm import relationship
__all__ = [
'ClassAttributeMap',
'ClassAttribute',
'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)
attribute = relationship("ClassAttribute", lazy='immediate')
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1)
class ClassAttribute(BaseObject, IterableMixin):
__tablename__ = "class_attribute"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
value = Column(String, nullable=False)
description = Column(Text)
class CharacterClass(*Bases, SavingThrowsMixin, SkillsMixin):
__tablename__ = "character_class"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
hit_dice = Column(String, default='1d6')
hit_dice_stat = Column(Enum(StatsEnum))
proficiencies = Column(String)
attributes = relationship("ClassAttributeMap")

View File

@ -0,0 +1,46 @@
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
__all__ = [
'Skill',
'Proficiency',
'Modifier',
]
class Skill(*Bases):
__tablename__ = "skill"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
description = Column(Text)
def __repr__(self):
return str(self.name)
class Proficiency(*Bases):
__tablename__ = "proficiency"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, unique=True)
def __repr__(self):
return str(self.name)
class Modifier(BaseObject, IterableMixin):
__tablename__ = "modifier"
__table_args__ = (
UniqueConstraint('source_table_name', 'source_table_id', 'value', 'type', 'target'),
)
id = Column(Integer, primary_key=True, autoincrement=True)
source_table_name = Column(String, index=True, nullable=False)
source_table_id = Column(Integer, index=True, nullable=False)
value = Column(String, nullable=False)
type = Column(String, nullable=False)
target = Column(String, nullable=False)

View File

@ -0,0 +1,14 @@
from ttfrog.db.base import BaseObject, IterableMixin
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Text
__all__ = ['TransactionLog']
class TransactionLog(BaseObject, IterableMixin):
__tablename__ = "transaction_log"
id = Column(Integer, primary_key=True, autoincrement=True)
source_table_name = Column(String, index=True, nullable=False)
primary_key = Column(Integer, index=True)
diff = Column(Text)

View File

@ -1,8 +1,7 @@
from ttfrog.webserver.controllers.base import BaseController from ttfrog.webserver.controllers.base import BaseController
from ttfrog.webserver.forms import DeferredSelectField, DeferredSelectMultipleField from ttfrog.webserver.forms import DeferredSelectField, DeferredSelectMultipleField
from ttfrog.db.schema import Character, Ancestry, CharacterClass, AncestryTrait, Modifier, STATS from ttfrog.db.schema import Character, Ancestry, CharacterClass
from ttfrog.db.manager import db from ttfrog.db.base import STATS
from ttfrog.attribute_map import AttributeMap
from wtforms_alchemy import ModelForm from wtforms_alchemy import ModelForm
from wtforms.fields import SubmitField, SelectMultipleField from wtforms.fields import SubmitField, SelectMultipleField
@ -17,7 +16,7 @@ class CharacterForm(ModelForm):
save = SubmitField() save = SubmitField()
delete = SubmitField() delete = SubmitField()
ancestry = DeferredSelectField('Ancestry', model=Ancestry, default='human', validate_choice=True, widget=Select()) ancestry = DeferredSelectField('Ancestry', model=Ancestry, default=1, validate_choice=True, widget=Select())
character_class = DeferredSelectMultipleField( character_class = DeferredSelectMultipleField(
'CharacterClass', 'CharacterClass',
@ -38,14 +37,3 @@ class CharacterSheet(BaseController):
return super().resources + [ return super().resources + [
{'type': 'script', 'uri': 'js/character_sheet.js'}, {'type': 'script', 'uri': 'js/character_sheet.js'},
] ]
def template_context(self, **kwargs) -> dict:
ctx = super().template_context(**kwargs)
if self.record.ancestry:
ancestry = db.query(Ancestry).filter_by(name=self.record.ancestry).one()
ctx['traits'] = {}
for trait in db.query(AncestryTrait).filter_by(ancestry_id=ancestry.id).all():
ctx['traits'][trait.description] = db.query(Modifier).filter_by(source_table_name=trait.__tablename__, source_table_id=trait.id).all()
else:
ctx['traits'] = {};
return ctx

View File

@ -0,0 +1,25 @@
import logging
from ttfrog.db import schema
from ttfrog.db.manager import db
from .base import BaseController
from pyramid.httpexceptions import exception_response
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()
}

View File

@ -4,9 +4,10 @@ from wtforms.fields import SelectField, SelectMultipleField
class DeferredSelectMultipleField(SelectMultipleField): class DeferredSelectMultipleField(SelectMultipleField):
def __init__(self, *args, model=None, **kwargs): def __init__(self, *args, model=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.choices = db.query(model).all() self.choices = [(rec.id, rec.name) for rec in db.query(model).all()]
class DeferredSelectField(SelectField): class DeferredSelectField(SelectField):
def __init__(self, *args, model=None, **kwargs): def __init__(self, *args, model=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.choices = db.query(model).all() self.choices = [(rec.id, rec.name) for rec in db.query(model).all()]