diff --git a/ttfrog/assets/static/css/styles.css b/ttfrog/assets/static/css/styles.css
index 3e83d4e..58eea79 100644
--- a/ttfrog/assets/static/css/styles.css
+++ b/ttfrog/assets/static/css/styles.css
@@ -211,3 +211,17 @@ table td {
padding: 0;
margin: 0;
}
+
+
+ul.multiclass {
+ display: inline;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.multiclass li {
+ display: inline;
+}
+.multiclass label {
+ display: none;
+}
diff --git a/ttfrog/assets/templates/character_sheet.html b/ttfrog/assets/templates/character_sheet.html
index 5b68859..2323345 100644
--- a/ttfrog/assets/templates/character_sheet.html
+++ b/ttfrog/assets/templates/character_sheet.html
@@ -15,12 +15,13 @@
{{ field('name') }}
- {{ field('ancestry') }}
- {% for rec in c.record.classes %}
- {{ rec.character_class.name }} {{ rec.level }}
+ {{ field('ancestry_id') }}
+ {% for obj in c.form['classes'] %}
+ {{ obj(class='multiclass') }}
{% endfor %}
+ {{ c.form['newclass'](class='multiclass') }}
+
- {{ c.record.character_class|join(' / ') }}
{{ c.form.save }} {{ c.form.delete }}
diff --git a/ttfrog/db/schema/character.py b/ttfrog/db/schema/character.py
index 05a2db5..8ba7441 100644
--- a/ttfrog/db/schema/character.py
+++ b/ttfrog/db/schema/character.py
@@ -1,3 +1,6 @@
+import logging
+
+
from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin
from ttfrog.db.base import CreatureTypesEnum
@@ -7,7 +10,9 @@ 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.ext.associationproxy import association_proxy
__all__ = [
@@ -19,6 +24,11 @@ __all__ = [
'Character',
]
+def class_map_creator(fields):
+ if isinstance(fields, CharacterClassMap):
+ return fields
+ return CharacterClassMap(**fields)
+
class AncestryTraitMap(BaseObject):
__tablename__ = "trait_map"
@@ -54,8 +64,11 @@ class AncestryTrait(BaseObject, IterableMixin):
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)
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ character_id = Column(Integer, ForeignKey("character.id"))
+ character_class_id = Column(Integer, ForeignKey("character_class.id"))
+ mapping = UniqueConstraint(character_id, character_class_id)
+
character_class = relationship("CharacterClass", lazy='immediate')
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1)
@@ -84,7 +97,9 @@ class Character(*Bases, SavingThrowsMixin, SkillsMixin):
cha = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30})
proficiencies = Column(String)
- classes = relationship("CharacterClassMap")
+ class_map = relationship("CharacterClassMap", cascade='all,delete,delete-orphan')
+ classes = association_proxy('class_map', 'id', creator=class_map_creator)
+
attributes = relationship("CharacterClassAttributeMap")
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1')
diff --git a/ttfrog/db/transaction_log.py b/ttfrog/db/transaction_log.py
index 4bbfd28..6d5bd06 100644
--- a/ttfrog/db/transaction_log.py
+++ b/ttfrog/db/transaction_log.py
@@ -6,6 +6,7 @@ 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
diff --git a/ttfrog/webserver/controllers/base.py b/ttfrog/webserver/controllers/base.py
index 03f2a35..101ab5b 100644
--- a/ttfrog/webserver/controllers/base.py
+++ b/ttfrog/webserver/controllers/base.py
@@ -71,6 +71,7 @@ class BaseController:
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
@@ -96,14 +97,19 @@ class BaseController:
**kwargs,
)
+ def populate(self):
+ self.form.populate_obj(self.record)
+
def save(self):
if not self.form.save.data:
return
if not self.form.validate():
return
- previous = dict(self.record)
- self.form.populate_obj(self.record)
- transaction_log.record(previous, self.record)
+ 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)
logging.debug(f"Added {self.record = }")
diff --git a/ttfrog/webserver/controllers/character_sheet.py b/ttfrog/webserver/controllers/character_sheet.py
index c2939df..4954315 100644
--- a/ttfrog/webserver/controllers/character_sheet.py
+++ b/ttfrog/webserver/controllers/character_sheet.py
@@ -1,11 +1,39 @@
+import logging
+
from ttfrog.webserver.controllers.base import BaseController
-from ttfrog.webserver.forms import DeferredSelectField, DeferredSelectMultipleField
-from ttfrog.db.schema import Character, Ancestry, CharacterClass
+from ttfrog.webserver.forms import DeferredSelectField
+from ttfrog.webserver.forms import NullableDeferredSelectField
+from ttfrog.db.schema import Character, Ancestry, CharacterClass, CharacterClassMap
from ttfrog.db.base import STATS
+from ttfrog.db.manager import db
from wtforms_alchemy import ModelForm
-from wtforms.fields import SubmitField, SelectMultipleField
-from wtforms.widgets import Select
+from wtforms.fields import SubmitField, SelectField, SelectMultipleField, FieldList, FormField, HiddenField
+from wtforms.widgets import Select, ListWidget
+from wtforms import ValidationError
+
+VALID_LEVELS = range(1, 21)
+
+
+class MulticlassForm(ModelForm):
+ id = HiddenField()
+ character_class_id = NullableDeferredSelectField(
+ 'CharacterClass',
+ 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.
+ """
+ obj = db.query(CharacterClassMap).get(obj)
+ super().__init__(formdata=formdata, obj=obj, prefix=prefix)
class CharacterForm(ModelForm):
@@ -15,16 +43,9 @@ class CharacterForm(ModelForm):
save = SubmitField()
delete = SubmitField()
-
- ancestry = DeferredSelectField('Ancestry', model=Ancestry, default=1, validate_choice=True, widget=Select())
-
- character_class = DeferredSelectMultipleField(
- 'CharacterClass',
- model=CharacterClass,
- validate_choice=True,
- # option_widget=Select(multiple=True)
- )
-
+ ancestry_id = DeferredSelectField('Ancestry', model=Ancestry, default=1, validate_choice=True, widget=Select())
+ classes = FieldList(FormField(MulticlassForm, widget=ListWidget()), min_entries=0)
+ newclass = FormField(MulticlassForm, widget=ListWidget())
saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS)
@@ -37,3 +58,63 @@ class CharacterSheet(BaseController):
return super().resources + [
{'type': 'script', 'uri': 'js/character_sheet.js'},
]
+
+ def populate_class_map(self, formdata):
+ """
+ Populate the record's class_map association_proxy with dictionaries of
+ CharacterClassMap field data. The creator factory on the proxy will
+ convert dictionary data to CharacterClassMap instances..
+ """
+ populated = []
+ for field in formdata:
+ class_map_id = field.pop('id')
+ class_map_id = int(class_map_id) if class_map_id else 0
+ logging.debug(f"{class_map_id = }, {field = }, {self.record.classes = }")
+ if not field['character_class_id']:
+ continue
+ elif not class_map_id:
+ populated.append(field)
+ else:
+ field['id'] = class_map_id
+ populated.append(field)
+ self.record.classes = populated
+
+ def validate_multiclass_form(self):
+ """
+ Validate multiclass fields in form data.
+ """
+ err = ""
+ total_level = 0
+ for field in self.form.data['classes']:
+ level = field.get('level', 0)
+ total_level += level
+ if level not in VALID_LEVELS:
+ err = f"Multiclass form field {field = } level is outside possible range."
+ break
+ if self.record.id and field.get('character_id', None) != self.record.id:
+ err = f"Multiclass form field {field = } does not match character ID {self.record.id}"
+ 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)
+
+ def validate(self):
+ """
+ Add custom validation of the multiclass form data to standard form validation.
+ """
+ super().validate()
+ self.validate_multiclass_form()
+
+ def populate(self):
+ """
+ Delete the multiclass form data before calling form.populate_obj() and use
+ our own method for populating the fieldlist.
+ """
+ formdata = self.form.data['classes']
+ formdata.append(self.form.data['newclass'])
+ del self.form.classes
+ del self.form.newclass
+ super().populate()
+ self.populate_class_map(formdata)
diff --git a/ttfrog/webserver/forms.py b/ttfrog/webserver/forms.py
index 8cc738e..a041beb 100644
--- a/ttfrog/webserver/forms.py
+++ b/ttfrog/webserver/forms.py
@@ -1,6 +1,7 @@
from ttfrog.db.manager import db
from wtforms.fields import SelectField, SelectMultipleField
+
class DeferredSelectMultipleField(SelectMultipleField):
def __init__(self, *args, model=None, **kwargs):
super().__init__(*args, **kwargs)
@@ -11,3 +12,9 @@ class DeferredSelectField(SelectField):
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 NullableDeferredSelectField(DeferredSelectField):
+ def __init__(self, *args, model=None, label='---', **kwargs):
+ super().__init__(*args, model=model, **kwargs)
+ self.choices = [(0, label)] + self.choices