fix multiclassing
This commit is contained in:
parent
ba0e66f9af
commit
9757d3bee0
|
@ -211,3 +211,17 @@ table td {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ul.multiclass {
|
||||||
|
display: inline;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.multiclass li {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.multiclass label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
|
@ -15,12 +15,13 @@
|
||||||
<div><img id='portrait' /></div>
|
<div><img id='portrait' /></div>
|
||||||
<div>
|
<div>
|
||||||
{{ field('name') }}
|
{{ field('name') }}
|
||||||
{{ field('ancestry') }}
|
{{ field('ancestry_id') }}
|
||||||
{% for rec in c.record.classes %}
|
{% for obj in c.form['classes'] %}
|
||||||
{{ rec.character_class.name }} {{ rec.level }}
|
{{ obj(class='multiclass') }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{{ c.form['newclass'](class='multiclass') }}
|
||||||
|
|
||||||
|
|
||||||
{{ c.record.character_class|join(' / ') }}
|
|
||||||
<div id='controls'>
|
<div id='controls'>
|
||||||
{{ c.form.save }} {{ c.form.delete }}
|
{{ c.form.save }} {{ c.form.delete }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin
|
from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin
|
||||||
from ttfrog.db.base import CreatureTypesEnum
|
from ttfrog.db.base import CreatureTypesEnum
|
||||||
|
|
||||||
|
@ -7,7 +10,9 @@ from sqlalchemy import Integer
|
||||||
from sqlalchemy import String
|
from sqlalchemy import String
|
||||||
from sqlalchemy import Text
|
from sqlalchemy import Text
|
||||||
from sqlalchemy import ForeignKey
|
from sqlalchemy import ForeignKey
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.ext.associationproxy import association_proxy
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@ -19,6 +24,11 @@ __all__ = [
|
||||||
'Character',
|
'Character',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def class_map_creator(fields):
|
||||||
|
if isinstance(fields, CharacterClassMap):
|
||||||
|
return fields
|
||||||
|
return CharacterClassMap(**fields)
|
||||||
|
|
||||||
|
|
||||||
class AncestryTraitMap(BaseObject):
|
class AncestryTraitMap(BaseObject):
|
||||||
__tablename__ = "trait_map"
|
__tablename__ = "trait_map"
|
||||||
|
@ -54,8 +64,11 @@ class AncestryTrait(BaseObject, IterableMixin):
|
||||||
|
|
||||||
class CharacterClassMap(BaseObject):
|
class CharacterClassMap(BaseObject):
|
||||||
__tablename__ = "class_map"
|
__tablename__ = "class_map"
|
||||||
character_id = Column(Integer, ForeignKey("character.id"), primary_key=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
character_class_id = Column(Integer, ForeignKey("character_class.id"), primary_key=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')
|
character_class = relationship("CharacterClass", lazy='immediate')
|
||||||
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1)
|
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})
|
cha = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30})
|
||||||
proficiencies = Column(String)
|
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")
|
attributes = relationship("CharacterClassAttributeMap")
|
||||||
|
|
||||||
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1')
|
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1')
|
||||||
|
|
|
@ -6,6 +6,7 @@ from ttfrog.db.schema import TransactionLog
|
||||||
|
|
||||||
|
|
||||||
def record(previous, new):
|
def record(previous, new):
|
||||||
|
logging.debug(f"{previous = }, {new = }")
|
||||||
diff = list((set(previous.items()) ^ set(dict(new).items())))
|
diff = list((set(previous.items()) ^ set(dict(new).items())))
|
||||||
if not diff:
|
if not diff:
|
||||||
return
|
return
|
||||||
|
|
|
@ -71,6 +71,7 @@ class BaseController:
|
||||||
else:
|
else:
|
||||||
self._form = self.model_form(obj=self.record)
|
self._form = self.model_form(obj=self.record)
|
||||||
if not self.record.id:
|
if not self.record.id:
|
||||||
|
# apply the db schema defaults
|
||||||
self._form.process()
|
self._form.process()
|
||||||
return self._form
|
return self._form
|
||||||
|
|
||||||
|
@ -96,14 +97,19 @@ class BaseController:
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def populate(self):
|
||||||
|
self.form.populate_obj(self.record)
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
if not self.form.save.data:
|
if not self.form.save.data:
|
||||||
return
|
return
|
||||||
if not self.form.validate():
|
if not self.form.validate():
|
||||||
return
|
return
|
||||||
previous = dict(self.record)
|
logging.debug(f"{self.form.data = }")
|
||||||
self.form.populate_obj(self.record)
|
# previous = dict(self.record)
|
||||||
transaction_log.record(previous, self.record)
|
logging.debug(f"{self.record = }")
|
||||||
|
self.populate()
|
||||||
|
# transaction_log.record(previous, self.record)
|
||||||
with db.transaction():
|
with db.transaction():
|
||||||
db.add(self.record)
|
db.add(self.record)
|
||||||
logging.debug(f"Added {self.record = }")
|
logging.debug(f"Added {self.record = }")
|
||||||
|
|
|
@ -1,11 +1,39 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
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
|
||||||
from ttfrog.db.schema import Character, Ancestry, CharacterClass
|
from ttfrog.webserver.forms import NullableDeferredSelectField
|
||||||
|
from ttfrog.db.schema import Character, Ancestry, CharacterClass, CharacterClassMap
|
||||||
from ttfrog.db.base import STATS
|
from ttfrog.db.base import STATS
|
||||||
|
from ttfrog.db.manager import db
|
||||||
|
|
||||||
from wtforms_alchemy import ModelForm
|
from wtforms_alchemy import ModelForm
|
||||||
from wtforms.fields import SubmitField, SelectMultipleField
|
from wtforms.fields import SubmitField, SelectField, SelectMultipleField, FieldList, FormField, HiddenField
|
||||||
from wtforms.widgets import Select
|
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):
|
class CharacterForm(ModelForm):
|
||||||
|
@ -15,16 +43,9 @@ class CharacterForm(ModelForm):
|
||||||
|
|
||||||
save = SubmitField()
|
save = SubmitField()
|
||||||
delete = SubmitField()
|
delete = SubmitField()
|
||||||
|
ancestry_id = DeferredSelectField('Ancestry', model=Ancestry, default=1, validate_choice=True, widget=Select())
|
||||||
ancestry = 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())
|
||||||
character_class = DeferredSelectMultipleField(
|
|
||||||
'CharacterClass',
|
|
||||||
model=CharacterClass,
|
|
||||||
validate_choice=True,
|
|
||||||
# option_widget=Select(multiple=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS)
|
saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS)
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,3 +58,63 @@ class CharacterSheet(BaseController):
|
||||||
return super().resources + [
|
return super().resources + [
|
||||||
{'type': 'script', 'uri': 'js/character_sheet.js'},
|
{'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)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from ttfrog.db.manager import db
|
from ttfrog.db.manager import db
|
||||||
from wtforms.fields import SelectField, SelectMultipleField
|
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)
|
||||||
|
@ -11,3 +12,9 @@ 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 = [(rec.id, rec.name) for rec in db.query(model).all()]
|
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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user