tabletop-frog/ttfrog/webserver/controllers/character_sheet.py
2024-02-23 10:45:38 -08:00

121 lines
4.4 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
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
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 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, widget=ListWidget()), min_entries=0)
newclass = FormField(MulticlassForm, widget=ListWidget())
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 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)