Rewrite settings page to reduce boilerplate

This commit is contained in:
Tim Van Baak 2020-04-29 17:13:12 -07:00
parent 7488a8ca79
commit 64b7ef0dee
3 changed files with 285 additions and 163 deletions

View File

@ -26,8 +26,8 @@ from amanuensis.server.helpers import (
from .forms import ( from .forms import (
LexiconCharacterForm, LexiconCharacterForm,
LexiconReviewForm, LexiconReviewForm,
LexiconPublishTurnForm) LexiconPublishTurnForm,
from .settings import LexiconConfigForm LexiconConfigForm)
from .editor import load_editor, new_draft, update_draft from .editor import load_editor, new_draft, update_draft
@ -142,24 +142,23 @@ def character(name):
@lexicon_param @lexicon_param
@editor_required @editor_required
def settings(name): def settings(name):
form = LexiconConfigForm() form: LexiconConfigForm = LexiconConfigForm(g.lexicon)
form.set_options(g.lexicon)
# Load the config for the lexicon on load
if not form.is_submitted(): if not form.is_submitted():
form.populate_from_lexicon(g.lexicon) # GET
form.load(g.lexicon)
return render_template('session.settings.jinja', form=form) return render_template('session.settings.jinja', form=form)
if form.validate(): if not form.validate():
if not form.update_lexicon(g.lexicon): # POST with invalid data
flash("Error updating settings") flash('Validation error')
return render_template("lexicon.settings.jinja", form=form)
flash("Settings updated")
return redirect(url_for('session.session', name=name))
flash("Validation error")
return render_template('session.settings.jinja', form=form) return render_template('session.settings.jinja', form=form)
# POST with valid data
form.save(g.lexicon)
flash('Settings updated')
return redirect(url_for('session.settings', name=name))
@bp_session.route('/review/', methods=['GET', 'POST']) @bp_session.route('/review/', methods=['GET', 'POST'])
@lexicon_param @lexicon_param

View File

@ -3,6 +3,8 @@ from wtforms import (
StringField, SubmitField, TextAreaField, RadioField) StringField, SubmitField, TextAreaField, RadioField)
from wtforms.validators import DataRequired from wtforms.validators import DataRequired
from .settings import ConfigFormBase
class LexiconCharacterForm(FlaskForm): class LexiconCharacterForm(FlaskForm):
"""/lexicon/<name>/session/character/""" """/lexicon/<name>/session/character/"""
@ -36,3 +38,8 @@ class LexiconReviewForm(FlaskForm):
class LexiconPublishTurnForm(FlaskForm): class LexiconPublishTurnForm(FlaskForm):
"""/lexicon/<name>/session/""" """/lexicon/<name>/session/"""
submit = SubmitField('Publish turn') submit = SubmitField('Publish turn')
class LexiconConfigForm(ConfigFormBase):
"""/lexicon/<name>/session/settings/"""
submit = SubmitField('Save settings')

View File

@ -1,161 +1,277 @@
from typing import cast
from flask import current_app
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import ( from wtforms import (
StringField, BooleanField, SubmitField, TextAreaField, Field,
IntegerField, SelectField) StringField,
from wtforms.validators import DataRequired, ValidationError, Optional BooleanField,
TextAreaField,
IntegerField,
SelectField)
from wtforms.validators import DataRequired, Optional
from wtforms.widgets.html5 import NumberInput from wtforms.widgets.html5 import NumberInput
from amanuensis.config import ReadOnlyOrderedDict, AttrOrderedDict
from amanuensis.models import ModelFactory, UserModel
from amanuensis.server.forms import User from amanuensis.server.forms import User
class LexiconConfigForm(FlaskForm): class SettingTranslator():
"""/lexicon/<name>/session/settings/""" """
# General Base class for the translation layer between internal config data
title = StringField('Title override', validators=[Optional()]) and user-friendly display in the settings form. By default the data
editor = SelectField('Editor', validators=[DataRequired(), User(True)]) is returned as-is.
prompt = TextAreaField('Prompt', validators=[DataRequired()]) """
# Turn def load(self, cfg_value):
turnCurrent = IntegerField('Current turn', widget=NumberInput(), validators=[Optional()]) return cfg_value
turnMax = IntegerField('Number of turns', widget=NumberInput(), validators=[DataRequired()])
# Join def save(self, field_data):
joinPublic = BooleanField("Show game on public pages") return field_data
joinOpen = BooleanField("Allow players to join game")
joinPassword = StringField("Password to join game", validators=[Optional()])
joinMaxPlayers = IntegerField( class UsernameTranslator(SettingTranslator):
"Maximum number of players", """
Converts an internal user id to a public-facing username.
"""
def load(self, cfg_value):
model_factory: ModelFactory = current_app.config['model_factory']
user: UserModel = model_factory.user(cfg_value)
return user.cfg.username
def save(self, field_data):
model_factory: ModelFactory = current_app.config['model_factory']
user: UserModel = model_factory.try_user(field_data)
if user:
return user.uid
class Setting():
"""
Represents a relation between a node in a lexicon config and a
field in a public-facing form that exposes it to the editor for
modification.
"""
def __init__(
self,
cfg_key: str,
field: Field,
translator: SettingTranslator = SettingTranslator()):
"""
Creates a setting. Optionally, defines a nontrivial translation
between internal and public values.
"""
self.cfg_path = cfg_key.split('.')
self.field = field
self.translator = translator
def load(self, cfg: ReadOnlyOrderedDict, field: Field):
"""
Sets the field's value to the corresponding config node
"""
for key in self.cfg_path[:-1]:
cfg = cast(ReadOnlyOrderedDict, cfg.get(key))
data = cfg.get(self.cfg_path[-1])
field.data = self.translator.load(data)
def save(self, cfg: AttrOrderedDict, field: Field):
"""
Updates the editable config with this field's value
"""
for key in self.cfg_path[:-1]:
cfg = cast(AttrOrderedDict, cfg.get(key))
data = field.data
cfg[self.cfg_path[-1]] = self.translator.save(data)
class Settings():
@staticmethod
def settings():
for name, setting in vars(Settings).items():
if name.startswith('s_'):
yield name, setting
s_title = Setting('title',
StringField('Title override', validators=[Optional()]))
s_editor = Setting('editor',
SelectField('Editor', validators=[DataRequired(), User(True)]),
translator=UsernameTranslator())
s_prompt = Setting('prompt',
TextAreaField('Prompt', validators=[DataRequired()]))
s_turnCurrent = Setting('turn.current',
IntegerField(
'Current turn',
widget=NumberInput(), widget=NumberInput(),
validators=[DataRequired()]) validators=[Optional()]))
joinCharsPerPlayer = IntegerField(
"Characters per player", s_turnMax = Setting('turn.max',
IntegerField(
'Number of turns',
widget=NumberInput(), widget=NumberInput(),
validators=[DataRequired()]) validators=[DataRequired()]))
# Publish
publishNotifyEditorOnReady = BooleanField(
"Notify the editor when a player marks an article as ready")
publishNotifyPlayerOnReject = BooleanField(
"Notify a player when their article is rejected by the editor")
publishNotifyPlayerOnAccept = BooleanField(
"Notify a player when their article is accepted by the editor")
publishDeadlines = StringField(
"Turn deadline, as a crontab specification", validators=[Optional()])
publishAsap = BooleanField(
"Publish the turn immediately when the last article is accepted")
publishQuorum = IntegerField(
"Quorum to publish incomplete turn", widget=NumberInput(), validators=[Optional()])
publishBlockOnReady = BooleanField(
"Block turn publish if any articles are awaiting editor review")
# Article
articleIndexList = TextAreaField("Index specifications")
articleIndexCapacity = IntegerField(
"Index capacity override", widget=NumberInput(), validators=[Optional()])
articleCitationAllowSelf = BooleanField(
"Allow players to cite themselves")
articleCitationMinExtant = IntegerField(
"Minimum number of extant articles to cite", widget=NumberInput(), validators=[Optional()])
articleCitationMaxExtant = IntegerField(
"Maximum number of extant articles to cite", widget=NumberInput(), validators=[Optional()])
articleCitationMinPhantom = IntegerField(
"Minimum number of phantom articles to cite", widget=NumberInput(), validators=[Optional()])
articleCitationMaxPhantom = IntegerField(
"Maximum number of phantom articles to cite", widget=NumberInput(), validators=[Optional()])
articleCitationMinTotal = IntegerField(
"Minimum number of articles to cite in total", widget=NumberInput(), validators=[Optional()])
articleCitationMaxTotal = IntegerField(
"Maximum number of articles to cite in total", widget=NumberInput(), validators=[Optional()])
articleCitationMinChars = IntegerField(
"Minimum number of characters to cite articles by",
widget=NumberInput(), validators=[Optional()])
articleCitationMaxChars = IntegerField(
"Maximum number of characters to cite articles by",
widget=NumberInput(), validators=[Optional()])
articleWordLimitSoft = IntegerField(
"Soft word limit", widget=NumberInput(), validators=[Optional()])
articleWordLimitHard = IntegerField(
"Hard word limit", widget=NumberInput(), validators=[Optional()])
articleAddendumAllowed = BooleanField("Allow addendum articles")
articleAddendumMax = IntegerField(
"Maximum number of addendum articles per character per turn",
widget=NumberInput(), validators=[Optional()])
# And finally, the submit button
submit = SubmitField("Submit")
def validate_publishDeadlines(form, field): s_joinPublic = Setting('join.public',
if form.publishAsap.data: BooleanField('Show game on public pages'))
raise ValidationError('Cannot specify deadline if immediate publishing is enabled')
# TODO add validators that call into extant valid check methods s_joinOpen = Setting('join.open',
BooleanField('Allow players to join game'))
# def set_options(self, lexicon): s_joinPassword = Setting('join.password',
# self.editor.choices = list(map(lambda x: (x, x), map( StringField('Password to join game', validators=[Optional()]))
# lambda uid: UserModel.by(uid=uid).username,
# lexicon.join.joined)))
# def populate_from_lexicon(self, lexicon): s_joinMaxPlayers = Setting('join.max_players',
# self.title.data = lexicon.cfg.title IntegerField(
# self.editor.data = ModelFactory(lexicon.ctx.root).user(lexicon.cfg.editor).cfg.username 'Maximum number of players',
# self.prompt.data = lexicon.prompt widget=NumberInput(),
# self.turnCurrent.data = lexicon.turn.current validators=[DataRequired()]))
# self.turnMax.data = lexicon.turn.max
# self.joinPublic.data = lexicon.join.public
# self.joinOpen.data = lexicon.join.open
# self.joinPassword.data = lexicon.join.password
# self.joinMaxPlayers.data = lexicon.join.max_players
# self.joinCharsPerPlayer.data = lexicon.join.chars_per_player
# self.publishNotifyEditorOnReady.data = lexicon.publish.notify.editor_on_ready
# self.publishNotifyPlayerOnReject.data = lexicon.publish.notify.player_on_reject
# self.publishNotifyPlayerOnAccept.data = lexicon.publish.notify.player_on_accept
# self.publishDeadlines.data = lexicon.publish.deadlines
# self.publishAsap.data = lexicon.publish.asap
# self.publishQuorum.data = lexicon.publish.quorum
# self.publishBlockOnReady.data = lexicon.publish.block_on_ready
# self.articleIndexList.data = lexicon.article.index.list
# self.articleIndexCapacity.data = lexicon.article.index.capacity
# self.articleCitationAllowSelf.data = lexicon.article.citation.allow_self
# self.articleCitationMinExtant.data = lexicon.article.citation.min_extant
# self.articleCitationMaxExtant.data = lexicon.article.citation.max_extant
# self.articleCitationMinPhantom.data = lexicon.article.citation.min_phantom
# self.articleCitationMaxPhantom.data = lexicon.article.citation.max_phantom
# self.articleCitationMinTotal.data = lexicon.article.citation.min_total
# self.articleCitationMaxTotal.data = lexicon.article.citation.max_total
# self.articleCitationMinChars.data = lexicon.article.citation.min_chars
# self.articleCitationMaxChars.data = lexicon.article.citation.max_chars
# self.articleWordLimitSoft.data = lexicon.article.word_limit.soft
# self.articleWordLimitHard.data = lexicon.article.word_limit.hard
# self.articleAddendumAllowed.data = lexicon.article.addendum.allowed
# self.articleAddendumMax.data = lexicon.article.addendum.max
# def update_lexicon(self, lexicon): s_joinCharsPerPlayer = Setting('join.chars_per_player',
# with lexicon.edit() as l: IntegerField(
# l.title = self.title.data 'Characters per player',
# l.editor = UserModel.by(name=self.editor.data).uid widget=NumberInput(),
# l.prompt = self.prompt.data validators=[DataRequired()]))
# l.turn.current = self.turnCurrent.data
# l.turn.max = self.turnMax.data s_publishNotifyEditorOnReady = Setting('publish.notify_editor_on_ready',
# l.join.public = self.joinPublic.data BooleanField(
# l.join.open = self.joinOpen.data 'Notify the editor when a player marks an article as ready'))
# l.join.password = self.joinPassword.data
# l.join.max_players = self.joinMaxPlayers.data s_publishNotifyPlayerOnReject = Setting('publish.notify_player_on_reject',
# l.join.chars_per_player = self.joinCharsPerPlayer.data BooleanField(
# l.publish.notify.editor_on_ready = self.publishNotifyEditorOnReady.data 'Notify a player when their article is rejected by the editor'))
# l.publish.notify.player_on_reject = self.publishNotifyPlayerOnReject.data
# l.publish.notify.player_on_accept = self.publishNotifyPlayerOnAccept.data s_publishNotifyPlayerOnAccept = Setting('publish.notify_player_on_accept',
# l.publish.deadlines = self.publishDeadlines.data BooleanField(
# l.publish.asap = self.publishAsap.data 'Notify a player when their article is accepted by the editor'))
# l.publish.quorum = self.publishQuorum.data
# l.publish.block_on_ready = self.publishBlockOnReady.data s_publishDeadlines = Setting('publish.deadlines',
# l.article.index.list = self.articleIndexList.data StringField(
# l.article.index.capacity = self.articleIndexCapacity.data 'Turn deadline, as a crontab specification',
# l.article.citation.allow_self = self.articleCitationAllowSelf.data validators=[Optional()]))
# l.article.citation.min_extant = self.articleCitationMinExtant.data
# l.article.citation.max_extant = self.articleCitationMaxExtant.data s_publishAsap = Setting('publish.asap',
# l.article.citation.min_phantom = self.articleCitationMinPhantom.data BooleanField(
# l.article.citation.max_phantom = self.articleCitationMaxPhantom.data 'Publish the turn immediately when the last article is accepted'))
# l.article.citation.min_total = self.articleCitationMinTotal.data
# l.article.citation.max_total = self.articleCitationMaxTotal.data s_publishQuorum = Setting('publish.quorum',
# l.article.citation.min_chars = self.articleCitationMinChars.data IntegerField(
# l.article.citation.max_chars = self.articleCitationMaxChars.data 'Quorum to publish incomplete turn',
# l.article.word_limit.soft = self.articleWordLimitSoft.data widget=NumberInput(),
# l.article.word_limit.hard = self.articleWordLimitHard.data validators=[Optional()]))
# l.article.addendum.allowed = self.articleAddendumAllowed.data
# l.article.addendum.max = self.articleAddendumMax.data s_publishBlockOnReady = Setting('publish.block_on_ready',
# return True BooleanField(
'Block turn publish if any articles are awaiting editor review'))
s_articleIndexList = Setting('article.index.list',
TextAreaField('Index specifications'))
s_articleIndexCapacity = Setting('article.index.capacity',
IntegerField(
'Index capacity override',
widget=NumberInput(),
validators=[Optional()]))
s_articleCitationAllowSelf = Setting('article.citation.allow_self',
BooleanField('Allow players to cite themselves'))
s_articleCitationMinExtant = Setting('article.citation.min_extant',
IntegerField(
'Minimum number of extant articles to cite',
widget=NumberInput(),
validators=[Optional()]))
s_articleCitationMaxExtant = Setting('article.citation.max_extant',
IntegerField(
'Maximum number of extant articles to cite',
widget=NumberInput(),
validators=[Optional()]))
s_articleCitationMinPhantom = Setting('article.citation.min_phantom',
IntegerField(
'Minimum number of phantom articles to cite',
widget=NumberInput(),
validators=[Optional()]))
s_articleCitationMaxPhantom = Setting('article.citation.max_phantom',
IntegerField(
'Maximum number of phantom articles to cite',
widget=NumberInput(),
validators=[Optional()]))
s_articleCitationMinTotal = Setting('article.citation.min_total',
IntegerField(
'Minimum number of articles to cite in total',
widget=NumberInput(),
validators=[Optional()]))
s_articleCitationMaxTotal = Setting('article.citation.max_total',
IntegerField(
'Maximum number of articles to cite in total',
widget=NumberInput(),
validators=[Optional()]))
s_articleCitationMinChars = Setting('article.citation.min_chars',
IntegerField(
'Minimum number of characters to cite articles by',
widget=NumberInput(),
validators=[Optional()]))
s_articleCitationMaxChars = Setting('article.citation.max_chars',
IntegerField(
'Maximum number of characters to cite articles by',
widget=NumberInput(),
validators=[Optional()]))
s_articleWordLimitSoft = Setting('article.word_limit.soft',
IntegerField(
'Soft word limit',
widget=NumberInput(),
validators=[Optional()]))
s_articleWordLimitHard = Setting('article.word_limit.hard',
IntegerField(
'Hard word limit',
widget=NumberInput(),
validators=[Optional()]))
s_articleAddendumAllowed = Setting('article.addendum.allowed',
BooleanField('Allow addendum articles'))
s_articleAddendumMax = Setting('article.addendum.max',
IntegerField(
'Maximum number of addendum articles per character per turn',
widget=NumberInput(),
validators=[Optional()]))
class ConfigFormBase(FlaskForm):
def __init__(self, lexicon):
super().__init__()
editor_field = getattr(self, 'editor', None)
if editor_field:
model_factory: ModelFactory = current_app.config['model_factory']
editor_field.choices = list(map(
lambda s: (s, s),
map(
lambda uid: model_factory.user(uid).cfg.username,
lexicon.cfg.join.joined)))
def load(self, lexicon):
for k, v in Settings.settings():
field = getattr(self, k[2:], None)
if field:
v.load(lexicon.cfg, field)
def save(self, lexicon):
with lexicon.ctx.edit_config() as cfg:
for k, v in Settings.settings():
field = getattr(self, k[2:], None)
if field:
v.save(cfg, field)
for k, v in Settings.settings():
setattr(ConfigFormBase, k[2:], v.field)