tabletop-frog/ttfrog/webserver/controllers/character_sheet.py
2024-03-24 16:55:51 -07:00

204 lines
6.9 KiB
Python

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 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
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'])
self.label.text = self.character_class_map.character_class[0].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:
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.
"""
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())
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
)
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['classes']:
return ret
err = ""
total_level = 0
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."
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):
# 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()
}
assigned = [int(m.class_attribute_id) for m in self.record.character_class_attribute_map]
logging.debug(f"{assigned = }")
# step through the list of class mappings for this character
for class_map in self.record.class_map:
thisclass = classes_by_id[class_map.character_class_id]
# 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()
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,
})
def save_callback(self):
self.add_class_attributes()
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['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']
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)