From 9757d3bee0fb99d046dbd5082cf6a31f949595bb Mon Sep 17 00:00:00 2001 From: evilchili Date: Fri, 23 Feb 2024 10:45:38 -0800 Subject: [PATCH] fix multiclassing --- ttfrog/assets/static/css/styles.css | 14 +++ ttfrog/assets/templates/character_sheet.html | 9 +- ttfrog/db/schema/character.py | 21 +++- ttfrog/db/transaction_log.py | 1 + ttfrog/webserver/controllers/base.py | 12 +- .../webserver/controllers/character_sheet.py | 109 +++++++++++++++--- ttfrog/webserver/forms.py | 7 ++ 7 files changed, 149 insertions(+), 24 deletions(-) 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