Add settings page and basic settings #20

Merged
Jaculabilis merged 8 commits from tvb/settings-page into develop 2021-09-10 03:39:07 +00:00
20 changed files with 810 additions and 158 deletions

View File

@ -3,7 +3,9 @@ Index query interface
"""
import re
from typing import Optional
from typing import Optional, Sequence
from sqlalchemy import select
from amanuensis.db import DbContext, ArticleIndex, IndexType
from amanuensis.errors import ArgumentError, BackendArgumentTypeError
@ -72,3 +74,52 @@ def create(
db.session.add(new_index)
db.session.commit()
return new_index
def get_for_lexicon(db: DbContext, lexicon_id: int) -> Sequence[ArticleIndex]:
"""Returns all index rules for a lexicon."""
return db(
select(ArticleIndex).where(ArticleIndex.lexicon_id == lexicon_id)
).scalars()
def update(db: DbContext, lexicon_id: int, indices: Sequence[ArticleIndex]) -> None:
"""
Update the indices for a lexicon. Indices are matched by type and pattern.
An extant index not matched to an input is deleted, and an input index not
matched to a an extant index is created. Matched indexes are updated with
the input logical and display orders and capacity.
"""
extant_indices: Sequence[ArticleIndex] = list(get_for_lexicon(db, lexicon_id))
s = lambda i: f"{i.index_type}:{i.pattern}"
for extant_index in extant_indices:
match = None
for new_index in indices:
is_match = (
extant_index.index_type == new_index.index_type
and extant_index.pattern == new_index.pattern
)
if is_match:
match = new_index
break
if match:
extant_index.logical_order = new_index.logical_order
extant_index.display_order = new_index.display_order
extant_index.capacity = new_index.capacity
else:
db.session.delete(extant_index)
for new_index in indices:
match = None
for extant_index in extant_indices:
is_match = (
extant_index.index_type == new_index.index_type
and extant_index.pattern == new_index.pattern
)
if is_match:
match = extant_index
break
if not match:
new_index.lexicon_id = lexicon_id
db.session.add(new_index)
db.session.commit()

View File

@ -84,7 +84,11 @@ def password_check(db: DbContext, lexicon_id: int, password: str) -> bool:
def password_set(db: DbContext, lexicon_id: int, new_password: Optional[str]) -> None:
"""Set or clear a lexicon's password."""
password_hash = generate_password_hash(new_password) if new_password else None
db(update(Lexicon).where(Lexicon.id == lexicon_id).values(password=password_hash))
db(
update(Lexicon)
.where(Lexicon.id == lexicon_id)
.values(join_password=password_hash)
)
db.session.commit()

View File

@ -6,6 +6,7 @@ from typing import Callable
import amanuensis.cli.admin
import amanuensis.cli.character
import amanuensis.cli.index
import amanuensis.cli.lexicon
import amanuensis.cli.user
from amanuensis.db import DbContext
@ -110,6 +111,7 @@ def main():
subparsers = parser.add_subparsers(metavar="COMMAND")
add_subcommand(subparsers, amanuensis.cli.admin)
add_subcommand(subparsers, amanuensis.cli.character)
add_subcommand(subparsers, amanuensis.cli.index)
add_subcommand(subparsers, amanuensis.cli.lexicon)
add_subcommand(subparsers, amanuensis.cli.user)

42
amanuensis/cli/index.py Normal file
View File

@ -0,0 +1,42 @@
import enum
import logging
from amanuensis.backend import *
from amanuensis.db import DbContext, ArticleIndex, IndexType
from .helpers import add_argument
COMMAND_NAME = "index"
COMMAND_HELP = "Interact with indexes."
LOG = logging.getLogger(__name__)
@add_argument("--lexicon", required=True)
@add_argument(
"--type", required=True, type=lambda s: IndexType[s.upper()], choices=IndexType
)
@add_argument("--pattern", required=True)
@add_argument("--logical", type=int, default=0)
@add_argument("--display", type=int, default=0)
@add_argument("--capacity", type=int, default=None)
def command_create(args) -> int:
"""
Create an index for a lexicon.
"""
db: DbContext = args.get_db()
lexicon = lexiq.try_from_name(db, args.lexicon)
if not lexicon:
raise ValueError("Lexicon does not exist")
index: ArticleIndex = indq.create(
db,
lexicon.id,
args.type,
args.pattern,
args.logical,
args.display,
args.capacity,
)
LOG.info(f"Created {index.index_type}:{index.pattern} in {lexicon.full_title}")
return 0

View File

@ -463,6 +463,9 @@ class IndexType(enum.Enum):
PREFIX = 2
ETC = 3
def __str__(self):
return self.name
class ArticleIndex(ModelBase):
"""
@ -470,6 +473,7 @@ class ArticleIndex(ModelBase):
"""
__tablename__ = "article_index"
__table_args__ = (UniqueConstraint("lexicon_id", "index_type", "pattern"),)
##############
# Index info #

View File

@ -1,3 +1,8 @@
:root {
--button-default: #dddddd;
--button-hover: #cccccc;
--button-current: #bbbbbb;
}
body {
background-color: #eeeeee;
line-height: 1.4;
@ -41,11 +46,8 @@ div#sidebar {
img#logo {
max-width: 200px;
}
table {
table-layout: fixed;
width: 100%;
}
div#sidebar table {
width: 100%;
border-collapse: collapse;
}
div.citeblock table td:first-child + td a {
@ -57,7 +59,7 @@ div.misclinks table td a {
table a {
display: flex;
padding: 3px;
background-color: #dddddd;
background-color: var(--button-default);
border-radius: 5px;
text-decoration: none;
}
@ -65,13 +67,13 @@ div#sidebar table a {
justify-content: center;
}
table a:hover {
background-color: #cccccc;
background-color: var(--button-hover);
}
div#sidebar table a.current-page {
background-color: #bbbbbb;
background-color: var(--button-current);
}
div#sidebar table a.current-page:hover {
background-color: #bbbbbb;
background-color: var(--button-current);
}
div#sidebar table td {
padding: 0px; margin: 3px 0;
@ -109,8 +111,9 @@ textarea.fullwidth {
width: 100%;
box-sizing: border-box;
}
input.smallnumber {
width: 4em;
input.fullwidth {
width: 100%;
box-sizing: border-box;
}
form#session-settings p {
line-height: 1.8em;
@ -168,6 +171,50 @@ div.dashboard-lexicon-item p {
margin-block-start: 0.5em;
margin-block-end: 0.5em;
}
ul.unordered-tabs {
list-style: none;
margin-block-start: 0;
margin-block-end: 0;
margin-inline-start: 0;
margin-inline-end: 0;
padding-block-start: 0;
padding-block-end: 0;
padding-inline-start: 0;
padding-inline-end: 0;
}
ul.unordered-tabs li {
display: inline-block;
margin: 3px;
}
ul.unordered-tabs li a {
background-color: var(--button-current);
display: flex;
border: 5px solid var(--button-current);
border-radius: 5px;
text-decoration: none;
}
ul.unordered-tabs li a[href] {
background-color: var(--button-default);
border-color: var(--button-default);
}
ul.unordered-tabs li a[href]:hover {
background-color: var(--button-hover);
border-color: var(--button-hover);
}
#index-definition-help {
margin-block-start: 1em;
margin-block-end: 1em;
}
#index-definition-table td:nth-child(2) {
width: 100%;
}
#index-definition-table td:nth-child(2) *:only-child {
box-sizing: border-box;
width: 100%;
}
#index-definition-table td input[type=number] {
width: 4em;
}
@media only screen and (max-width: 816px) {
div#wrapper {
padding: 5px;

View File

@ -9,7 +9,7 @@ from amanuensis.config import AmanuensisConfig, CommandLineConfig
from amanuensis.db import DbContext
from amanuensis.parser import filesafe_title
import amanuensis.server.auth as auth
from amanuensis.server.helpers import UuidConverter
from amanuensis.server.helpers import UuidConverter, current_lexicon, current_membership
import amanuensis.server.home as home
import amanuensis.server.lexicon as lexicon
@ -27,7 +27,7 @@ def article_link(title):
"""Get the url for a lexicon by its title"""
return url_for(
'lexicon.article',
name=g.lexicon.name,
lexicon_name=g.lexicon.name,
title=filesafe_title(title))
@ -68,13 +68,22 @@ def get_app(
app.teardown_appcontext(db_teardown)
# Configure jinja options
def include_backend():
return {"db": db, "lexiq": lexiq, "userq": userq, "memq": memq, "charq": charq}
def add_jinja_context():
return {
"db": db,
"lexiq": lexiq,
"userq": userq,
"memq": memq,
"charq": charq,
"indq": indq,
"current_lexicon": current_lexicon,
"current_membership": current_membership
}
app.jinja_options.update(trim_blocks=True, lstrip_blocks=True)
app.template_filter("date")(date_format)
app.template_filter("articlelink")(article_link)
app.context_processor(include_backend)
app.context_processor(add_jinja_context)
# Set up uuid route converter
app.url_map.converters["uuid"] = UuidConverter

View File

@ -2,8 +2,17 @@ from functools import wraps
from typing import Optional, Any
from uuid import UUID
from flask import g, flash, redirect, url_for
from flask import (
_request_ctx_stack,
flash,
g,
has_request_context,
redirect,
request,
url_for,
)
from flask_login import current_user
from werkzeug.local import LocalProxy
from werkzeug.routing import BaseConverter, ValidationError
from amanuensis.backend import lexiq, memq
@ -26,6 +35,45 @@ class UuidConverter(BaseConverter):
return str(value)
def get_current_lexicon():
# Check if the request context is for a lexicon page
if not has_request_context():
return None
lexicon_name = request.view_args.get("lexicon_name")
if not lexicon_name:
return None
# Pull up the lexicon if it exists and cache it in the request context
if not hasattr(_request_ctx_stack.top, "lexicon"):
db: DbContext = g.db
lexicon: Optional[Lexicon] = lexiq.try_from_name(db, lexicon_name)
setattr(_request_ctx_stack.top, "lexicon", lexicon)
# Return the cached lexicon
return getattr(_request_ctx_stack.top, "lexicon", None)
current_lexicon = LocalProxy(get_current_lexicon)
def get_current_membership():
# Base the current membership on the current user and the current lexicon
user: User = current_user
if not user or not user.is_authenticated:
return None
lexicon: Lexicon = current_lexicon
if not lexicon:
return None
# Pull up the membership and cache it in the request context
if not hasattr(_request_ctx_stack.top, "membership"):
db: DbContext = g.db
mem: Membership = memq.try_from_ids(db, user.id, lexicon.id)
setattr(_request_ctx_stack.top, "membership", mem)
# Return cached membership
return getattr(_request_ctx_stack.top, "membership", None)
current_membership = LocalProxy(get_current_membership)
def lexicon_param(route):
"""
Wrapper for loading a route's lexicon to `g`.
@ -34,7 +82,7 @@ def lexicon_param(route):
@wraps(route)
def with_lexicon(*args, **kwargs):
db: DbContext = g.db
name: str = kwargs.get('name')
name: str = kwargs.get('lexicon_name')
lexicon: Optional[Lexicon] = lexiq.try_from_name(db, name)
if lexicon is None:
flash(f"Couldn't find a lexicon with the name \"{name}\"")
@ -71,7 +119,7 @@ def player_required(route):
if not mem:
flash("You must be a player to view this page")
if lexicon.public:
return redirect(url_for('lexicon.contents', name=lexicon.name))
return redirect(url_for('lexicon.contents', lexicon_name=lexicon.name))
else:
return redirect(url_for('home.home'))
return route(*args, **kwargs)
@ -108,6 +156,6 @@ def editor_required(route):
mem: Optional[Membership] = memq.try_from_ids(db, user.id, lexicon.id)
if not mem or not mem.is_editor:
flash("You must be the editor to view this page")
return redirect(url_for('lexicon.contents', name=lexicon.name))
return redirect(url_for('lexicon.contents', lexicon_name=lexicon.name))
return route(*args, **kwargs)
return editor_route

View File

@ -9,41 +9,28 @@
{% block sb_logo %}{% endblock %}
{% block sb_characters %}<a
{% if current_page == "characters" %}class="current-page"
{% else %}href="{{ url_for('lexicon.characters.list', name=g.lexicon.name) }}"
{% else %}href="{{ url_for('lexicon.characters.list', lexicon_name=g.lexicon.name) }}"
{% endif %}>Characters</a>{% endblock %}
{% block sb_contents %}<a
{% if current_page == "contents" %}class="current-page"
{% else %}href="{{ url_for('lexicon.contents', name=g.lexicon.name) }}"
{% else %}href="{{ url_for('lexicon.contents', lexicon_name=g.lexicon.name) }}"
{% endif %}>Contents</a>{% endblock %}
{% block sb_rules %}<a
{% if current_page == "rules" %}class="current-page"
{% else %}href="{{ url_for('lexicon.rules', name=g.lexicon.name) }}"
{% else %}href="{{ url_for('lexicon.rules', lexicon_name=g.lexicon.name) }}"
{% endif %}>Rules</a>{% endblock %}
{% block sb_session %}<a
{% if current_page == "session" %}class="current-page"
{% else %}href="#{#{ url_for('session.session', name=g.lexicon.name) }#}"
{% endif %}>Session</a>{% endblock %}
{% block sb_settings %}<a
{% if current_page == "settings" %}class="current-page"
{% else %}href="{{ url_for('lexicon.settings.page', lexicon_name=g.lexicon.name) }}"
{% endif %}>Settings</a>{% endblock %}
{% block sb_stats %}<a
{% if current_page == "statistics" %}class="current-page"
{% else %}href="{{ url_for('lexicon.stats', name=g.lexicon.name) }}"
{% else %}href="{{ url_for('lexicon.stats', lexicon_name=g.lexicon.name) }}"
{% endif %}>Statistics</a>{% endblock %}
{% if current_user.is_authenticated and (
current_user.is_site_admin
or memq.try_from_ids(g.db, current_user.id, g.lexicon.id)
) %}
{# self.sb_logo(), #}
{% set template_sidebar_rows = [
self.sb_characters(),
self.sb_contents(),
self.sb_rules(),
self.sb_session(),
self.sb_settings(),
self.sb_stats()] %}
{% else %}
{# self.sb_logo(), #}
{% set template_sidebar_rows = [
self.sb_characters(),
self.sb_contents(),
self.sb_rules(),
self.sb_stats()] %}
{% endif %}

View File

@ -8,16 +8,20 @@ from amanuensis.server.helpers import lexicon_param, player_required_if_not_publ
from .characters import bp as characters_bp
from .forms import LexiconJoinForm
from .settings import bp as settings_bp
bp = Blueprint("lexicon", __name__, url_prefix="/lexicon/<name>", template_folder=".")
bp = Blueprint(
"lexicon", __name__, url_prefix="/lexicon/<lexicon_name>", template_folder="."
)
bp.register_blueprint(characters_bp)
bp.register_blueprint(settings_bp)
@bp.route("/join/", methods=["GET", "POST"])
@lexicon_param
@login_required
def join(name):
def join(lexicon_name):
lexicon: Lexicon = g.lexicon
if not lexicon.joinable:
flash("This game isn't open for joining")
@ -27,7 +31,9 @@ def join(name):
if not form.validate_on_submit():
# GET or POST with invalid form data
return render_template("lexicon.join.jinja", form=form)
return render_template(
"lexicon.join.jinja", lexicon_name=lexicon_name, form=form
)
# POST with valid data
# If the game is passworded, check password
@ -37,48 +43,48 @@ def join(name):
):
# Bad creds, try again
flash("Incorrect password")
return redirect(url_for("lexicon.join", name=name))
return redirect(url_for("lexicon.join", lexicon_name=lexicon_name))
# If the password was correct, check if the user can join
user: User = current_user
try:
memq.create(db, user.id, lexicon.id, is_editor=False)
return redirect(url_for("session.session", name=name))
return redirect(url_for("session.session", lexicon_name=lexicon_name))
except ArgumentError:
flash("Could not join game")
return redirect(url_for("home.home", name=name))
return redirect(url_for("home.home", lexicon_name=lexicon_name))
@bp.get("/contents/")
@lexicon_param
@player_required_if_not_public
def contents(name):
def contents(lexicon_name):
# indexed = sort_by_index_spec(info, g.lexicon.cfg.article.index.list)
# for articles in indexed.values():
# for i in range(len(articles)):
# articles[i] = {
# 'title': articles[i],
# **info.get(articles[i])}
return render_template("lexicon.contents.jinja")
return render_template("lexicon.contents.jinja", lexicon_name=lexicon_name)
@bp.get("/article/<title>")
@lexicon_param
@player_required_if_not_public
def article(name, title):
def article(lexicon_name, title):
# article = {**a, 'html': Markup(a['html'])}
return render_template("lexicon.article.jinja")
return render_template("lexicon.article.jinja", lexicon_name=lexicon_name)
@bp.get("/rules/")
@lexicon_param
@player_required_if_not_public
def rules(name):
return render_template("lexicon.rules.jinja")
def rules(lexicon_name):
return render_template("lexicon.rules.jinja", lexicon_name=lexicon_name)
@bp.get("/statistics/")
@lexicon_param
@player_required_if_not_public
def stats(name):
return render_template("lexicon.statistics.jinja")
def stats(lexicon_name):
return render_template("lexicon.statistics.jinja", lexicon_name=lexicon_name)

View File

@ -18,18 +18,18 @@ bp = Blueprint("characters", __name__, url_prefix="/characters", template_folder
@bp.get("/")
@lexicon_param
@player_required
def list(name):
return render_template("characters.jinja", name=name)
def list(lexicon_name):
return render_template("characters.jinja", lexicon_name=lexicon_name)
@bp.route("/edit/<uuid:character_id>", methods=["GET", "POST"])
@lexicon_param
@player_required
def edit(name, character_id: uuid.UUID):
def edit(lexicon_name, character_id: uuid.UUID):
character: Optional[Character] = charq.try_from_public_id(g.db, character_id)
if not character:
flash("Character not found")
return redirect(url_for("lexicon.characters.list", name=name))
return redirect(url_for("lexicon.characters.list", lexicon_name=lexicon_name))
form = CharacterCreateForm()
@ -37,7 +37,12 @@ def edit(name, character_id: uuid.UUID):
# GET
form.name.data = character.name
form.signature.data = character.signature
return render_template("characters.edit.jinja", character=character, form=form)
return render_template(
"characters.edit.jinja",
lexicon_name=lexicon_name,
character=character,
form=form,
)
else:
# POST
@ -45,25 +50,34 @@ def edit(name, character_id: uuid.UUID):
# Data is valid
character.name = form.name.data
character.signature = form.signature.data
g.db.session.commit()
return redirect(url_for("lexicon.characters.list", name=name))
g.db.session.commit() # TODO refactor into backend
return redirect(
url_for("lexicon.characters.list", lexicon_name=lexicon_name)
)
else:
# POST submitted invalid data
return render_template(
"characters.edit.jinja", character=character, form=form
"characters.edit.jinja",
lexicon_name=lexicon_name,
character=character,
form=form,
)
@bp.get("/new/")
@lexicon_param
@player_required
def new(name):
def new(lexicon_name):
dummy_name = f"{current_user.username}'s new character"
dummy_signature = "~"
char = charq.create(
g.db, g.lexicon.id, current_user.id, dummy_name, dummy_signature
)
return redirect(
url_for("lexicon.characters.edit", name=name, character_id=char.public_id)
url_for(
"lexicon.characters.edit",
lexicon_name=lexicon_name,
character_id=char.public_id,
)
)

View File

@ -13,7 +13,7 @@
<ul class="blockitem-list">
{% if characters|map(attribute="user_id")|select("equalto", current_user.id)|list|count < g.lexicon.character_limit %}
<li>
<h3><a href="{{ url_for('lexicon.characters.new', name=name) }}">Create a new character</a></h3>
<h3><a href="{{ url_for('lexicon.characters.new', lexicon_name=lexicon_name) }}">Create a new character</a></h3>
<p>You have created {{ characters|map(attribute="user_id")|select("equalto", current_user.id)|list|count }} out of {{ g.lexicon.character_limit }} allowed characters.</p>
</li>
{% endif %}
@ -25,7 +25,7 @@
{% endif %}
<p>Player: {{ character.user.username }}</p>
{% if character.user == current_user %}
<p><a href="{{ url_for('lexicon.characters.edit', name=g.lexicon.name, character_id=character.public_id) }}">Edit this character</a></p>
<p><a href="{{ url_for('lexicon.characters.edit', lexicon_name=lexicon_name, character_id=character.public_id) }}">Edit this character</a></p>
{% endif %}
</li>
{% endfor %}

View File

@ -0,0 +1,195 @@
from typing import Sequence
from flask import Blueprint, render_template, url_for, g, flash, redirect
from amanuensis.backend import *
from amanuensis.db import *
from amanuensis.server.helpers import (
editor_required,
lexicon_param,
player_required,
current_membership,
current_lexicon,
)
from .forms import PlayerSettingsForm, SetupSettingsForm, IndexSchemaForm
bp = Blueprint("settings", __name__, url_prefix="/settings", template_folder=".")
@bp.get("/")
@lexicon_param
@player_required
def page(lexicon_name):
return redirect(url_for("lexicon.settings.player", lexicon_name=lexicon_name))
@bp.route("/player/", methods=["GET", "POST"])
@lexicon_param
@player_required
def player(lexicon_name):
form = PlayerSettingsForm()
mem: Membership = current_membership
if not form.is_submitted():
# GET
form.notify_ready.data = mem.notify_ready
form.notify_reject.data = mem.notify_reject
form.notify_approve.data = mem.notify_approve
return render_template(
"settings.jinja",
lexicon_name=lexicon_name,
page_name=player.__name__,
form=form,
)
else:
# POST
if form.validate():
# Data is valid
mem.notify_ready = form.notify_ready.data
mem.notify_reject = form.notify_reject.data
mem.notify_approve = form.notify_approve.data
g.db.session.commit() # TODO refactor into backend
flash("Settings saved")
return redirect(
url_for("lexicon.settings.player", lexicon_name=lexicon_name)
)
else:
# Invalid POST data
return render_template(
"settings.jinja",
lexicon_name=lexicon_name,
page_name=player.__name__,
form=form,
)
@bp.route("/setup/", methods=["GET", "POST"])
@lexicon_param
@editor_required
def setup(lexicon_name):
form = SetupSettingsForm()
lexicon: Lexicon = current_lexicon
if not form.is_submitted():
# GET
form.title.data = lexicon.title
form.prompt.data = lexicon.prompt
form.public.data = lexicon.public
form.joinable.data = lexicon.joinable
form.has_password.data = lexicon.join_password is not None
form.turn_count.data = lexicon.turn_count
form.player_limit.data = lexicon.player_limit
form.character_limit.data = lexicon.character_limit
return render_template(
"settings.jinja",
lexicon_name=lexicon_name,
page_name=setup.__name__,
form=form,
)
else:
# POST
if form.validate():
# Data is valid
lexicon.title = form.title.data
lexicon.prompt = form.prompt.data
lexicon.public = form.public.data
lexicon.joinable = form.joinable.data
new_password = form.password.data if form.has_password.data else None
lexiq.password_set(g.db, lexicon.id, new_password)
lexicon.turn_count = form.turn_count.data
lexicon.player_limit = form.player_limit.data
lexicon.character_limit = form.character_limit.data
g.db.session.commit() # TODO refactor into backend
flash("Settings saved")
return redirect(
url_for("lexicon.settings.setup", lexicon_name=lexicon_name)
)
else:
# Invalid POST data
return render_template(
"settings.jinja",
lexicon_name=lexicon_name,
page_name=setup.__name__,
form=form,
)
@bp.get("/index/")
@lexicon_param
@editor_required
def index(lexicon_name):
# Get the current indices
indices: Sequence[ArticleIndex] = indq.get_for_lexicon(g.db, current_lexicon.id)
index_data = [
{
"index_type": str(index.index_type),
"pattern": index.pattern,
"logical_order": index.logical_order,
"display_order": index.display_order,
"capacity": index.capacity,
}
for index in indices
]
# Add a blank index to allow for adding rules
index_data.append(
{
"index_type": "",
"pattern": None,
"logical_order": None,
"display_order": None,
"capacity": None,
}
)
form = IndexSchemaForm(indices=index_data)
return render_template(
"settings.jinja", lexicon_name=lexicon_name, page_name=index.__name__, form=form
)
@bp.post("/index/")
@lexicon_param
@editor_required
def index_post(lexicon_name):
# Initialize the form
form = IndexSchemaForm()
if form.validate():
# Valid data, strip out all indexes with the blank type
indices = [
index_def.to_model()
for index_def in form.indices.entries
if index_def.index_type.data
]
indq.update(g.db, current_lexicon.id, indices)
return redirect(url_for("lexicon.settings.index", lexicon_name=lexicon_name))
else:
# Invalid data
return render_template(
"settings.jinja",
lexicon_name=lexicon_name,
page_name=index.__name__,
form=form,
)
@bp.get("/publish/")
@lexicon_param
@editor_required
def publish(lexicon_name):
return render_template(
"settings.jinja", lexicon_name=lexicon_name, page_name=publish.__name__
)
@bp.get("/article/")
@lexicon_param
@editor_required
def article(lexicon_name):
return render_template(
"settings.jinja", lexicon_name=lexicon_name, page_name=article.__name__
)

View File

@ -0,0 +1,97 @@
from flask_wtf import FlaskForm
from wtforms import (
BooleanField,
FieldList,
FormField,
IntegerField,
PasswordField,
SelectField,
StringField,
SubmitField,
TextAreaField,
)
from wtforms.validators import Optional, DataRequired, ValidationError
from wtforms.widgets.html5 import NumberInput
from amanuensis.db import ArticleIndex, IndexType
class PlayerSettingsForm(FlaskForm):
"""/lexicon/<name>/settings/player/"""
notify_ready = BooleanField("Notify me when an article is submitted for review")
notify_reject = BooleanField("Notify me when an editor rejects one of my articles")
notify_approve = BooleanField(
"Notify me when an editor approves one of my articles"
)
submit = SubmitField("Submit")
class SetupSettingsForm(FlaskForm):
"""/lexicon/<name>/settings/setup/"""
title = StringField("Title override")
prompt = TextAreaField("Prompt", validators=[DataRequired()])
public = BooleanField("Make game publicly visible")
joinable = BooleanField("Allow players to join game")
has_password = BooleanField("Require password to join the game")
password = PasswordField("Game password")
turn_count = IntegerField(
"Number of turns", widget=NumberInput(), validators=[DataRequired()]
)
player_limit = IntegerField(
"Maximum number of players", widget=NumberInput(), validators=[Optional()]
)
character_limit = IntegerField(
"Maximum number of characters per player",
widget=NumberInput(),
validators=[Optional()],
)
submit = SubmitField("Submit")
def parse_index_type(type_str):
if not type_str:
return None
return getattr(IndexType, type_str)
class IndexDefinitionForm(FlaskForm):
"""/lexicon/<name>/settings/index/"""
class Meta:
# Disable CSRF on the individual index definitions, since the schema
# form will have one
csrf = False
TYPE_CHOICES = [("", "")] + [(str(t), str(t).lower()) for t in IndexType]
index_type = SelectField(choices=TYPE_CHOICES, coerce=parse_index_type)
pattern = StringField()
logical_order = IntegerField(
widget=NumberInput(min=-99, max=99), validators=[Optional()]
)
display_order = IntegerField(
widget=NumberInput(min=-99, max=99), validators=[Optional()]
)
capacity = IntegerField(widget=NumberInput(min=0, max=99), validators=[Optional()])
def validate_pattern(form, field):
if form.index_type.data and not field.data:
raise ValidationError("Pattern must be defined")
def to_model(self):
return ArticleIndex(
index_type=self.index_type.data,
pattern=self.pattern.data,
logical_order=self.logical_order.data,
display_order=self.display_order.data,
capacity=self.capacity.data,
)
class IndexSchemaForm(FlaskForm):
"""/lexicon/<name>/settings/index/"""
indices = FieldList(FormField(IndexDefinitionForm))
submit = SubmitField("Submit")

View File

@ -0,0 +1,140 @@
{% extends "lexicon.jinja" %}
{% block title %}Edit | {{ lexicon_title }}{% endblock %}
{% macro settings_page_link(page, text) -%}
<a{% if page_name != page %} href="{{ url_for('lexicon.settings.' + page, lexicon_name=lexicon_name) }}"{% endif %}>{{ text }}</a>
{%- endmacro %}
{% macro flag_setting(field) %}
{{ field() }}
{{ field.label }}<br>
{% endmacro %}
{% macro number_setting(field) %}
{{ field(autocomplete="off", class_="smallnumber") }}
{{ field.label }}<br>
{% for error in field.errors %}
<span style="color: #ff0000">{{ error }}</span><br>
{% endfor %}
{% endmacro %}
{% block main %}
{% if current_membership.is_editor %}
<ul class="unordered-tabs">
<li>{{ settings_page_link("player", "Player Settings") }}</li>
<li>{{ settings_page_link("setup", "Game Setup") }}</li>
<li>{{ settings_page_link("index", "Article Indices") }}</li>
<li>{{ settings_page_link("publish", "Turn Publishing") }}</li>
<li>{{ settings_page_link("article", "Article Requirements") }}</li>
</ul>
{% endif %}
{% if page_name == "player" %}
<h3>Player Settings</h3>
<p>These settings are specific to you as a player in this lexicon.</p>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{% if current_membership.is_editor %}{{ flag_setting(form.notify_ready) }}{% endif %}
{{ flag_setting(form.notify_reject) }}
{{ flag_setting(form.notify_approve) }}
</p>
<p>{{ form.submit() }}</p>
</form>
{% for message in get_flashed_messages() %}
<span style="color:#ff0000">{{ message }}</span><br>
{% endfor %}
{% endif %}
{% if page_name == "setup" %}
<h3>Game Setup</h3>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ form.title.label }}:<br>
{{ form.title(autocomplete="off", placeholder="Lexicon " + lexicon_name, class_="fullwidth") }}<br>
</p>
<p>
{{ form.prompt.label }}: {{ form.prompt(class_="fullwidth") }}
{% for error in form.prompt.errors %}
<span style="color: #ff0000">{{ error }}</span><br>
{% endfor %}
</p>
<p>
{{ flag_setting(form.public) }}
{{ flag_setting(form.joinable) }}
{{ form.has_password() }}
{{ form.has_password.label }}:<br>
{{ form.password(autocomplete="off") }}
</p>
<p>
{{ number_setting(form.turn_count) }}
</p>
<p>
{{ number_setting(form.player_limit) }}
</p>
<p>
{{ number_setting(form.character_limit) }}
</p>
<p>{{ form.submit() }}</p>
</form>
{% for message in get_flashed_messages() %}
<span style="color:#ff0000">{{ message }}</span><br>
{% endfor %}
{% endif %}
{% if page_name == "index" %}
<h3>Article Indexes</h3>
<details id="index-definition-help">
<summary>Index definition help</summary>
<p>An index is a rule that matches the title of a lexicon article based on its <em>index type</em> and <em>pattern</em>. A <em>char</em> index matches a title if the first letter of the title (excluding "A", "An", and "The") is one of the letters in the pattern. A <em>range</em> index has a pattern denoting a range of letters, such as "A-F", and matches a title if the first letter of the title is in the range. A <em>prefix</em> index matches any title that begins with the pattern. An <em>etc</em> index always matches a title.</p>
<p>When a title is to be sorted under an index, indices are checked in order, sorted first by descending order of <em>logical priority</em>, and then by alphabetical order of index pattern. The title is sorted under the first index that matches it.</p>
<p>On the contents page, indices and the articles under them are displayed sorted instead by <em>display order</em> and then alphabetically by pattern.</p>
<p>The <em>capacity</em> of an index is the number of articles that may exist under that index. If an index is at capacity, no new articles may be written or created via phantom citation in that index.</p>
<p>To add an index, fill in the type and pattern in the blank row and save your changes. To remove an index, set the type to blank. Note: If you change the type or pattern of an index, all index assignments will be reset. Avoid changing index definitions during gameplay.</p>
</details>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<table id="index-definition-table">
<tr>
<th>Type</th>
<th>Pattern</th>
<th>Disp Or</th>
<th>Log Or</th>
<th>Cap</th>
</tr>
{% for index_form in form.indices %}
<tr>
<td>{{ index_form.index_type() }}</td>
<td>{{ index_form.pattern() }}</td>
<td>{{ index_form.logical_order() }}</td>
<td>{{ index_form.display_order() }}</td>
<td>{{ index_form.capacity() }}</td>
</tr>
{% for field in index_form %}
{% for error in field.errors %}
<tr>
<td colspan="5"><span style="color: #ff0000">{{ error }}</span></td>
</tr>
{% endfor %}
{% endfor %}
{% endfor %}
</table>
<p>{{ form.submit() }}</p>
</form>
{% for message in get_flashed_messages() %}
<span style="color:#ff0000">{{ message }}</span><br>
{% endfor %}
{% endif %}
{% if page_name == "publish" %}
<h3>Turn Publishing</h3>
{% endif %}
{% if page_name == "article" %}
<h3>Article Requirements</h3>
{% endif %}
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -3,7 +3,7 @@
<div class="dashboard-lexicon-item dashboard-lexicon-{{ status }}">
<p>
<span class="dashboard-lexicon-item-title">
<a href="{{ url_for('lexicon.contents', name=lexicon.name) }}">{{ lexicon.full_title }}</a>
<a href="{{ url_for('lexicon.contents', lexicon_name=lexicon.name) }}">{{ lexicon.full_title }}</a>
</span>
[{{ status.capitalize() }}]
</p>
@ -29,7 +29,7 @@
Players: {{ lexicon.memberships|count }}{% if lexicon.player_limit is not none %} / {{ lexicon.player_limit }}{% endif -%}
{%-
if lexicon.public and lexicon.joinable
%} / <a href="{{ url_for('lexicon.join', name=lexicon.name) }}">Join game</a>
%} / <a href="{{ url_for('lexicon.join', lexicon_name=lexicon.name) }}">Join game</a>
{%- endif -%}
{%- endif -%}
</p>

View File

@ -45,47 +45,21 @@
<h3>General</h3>
<p>
{{ form.title.label }}:<br>
{{ form.title(autocomplete="off", size=32, style="width:100%") }}<br>
{{ form.editor.label }}: {{ form.editor(autocomplete="off") }}<br>
{% for error in form.editor.errors %}
<span style="color: #ff0000">{{ error }}</span><br>
{% endfor %}
{{ form.prompt.label }}: {{ form.prompt(class_="fullwidth") }}
{% for error in form.prompt.errors %}
<span style="color: #ff0000">{{ error }}</span><br>
{% endfor %}
</p>
<h3>Game Progress</h3>
<p>
{{ number_setting(form.turnCurrent) }}
{{ number_setting(form.turnMax) }}
{{ form.articleIndexList.label }}:<br>
{{ form.articleIndexList(class_="fullwidth", rows=10) }}
{% for error in form.articleIndexList.errors %}
<span style="color: #ff0000">{{ error }}</span><br>
{% endfor %}
{{ number_setting(form.articleIndexCapacity) }}
{{ form.turnAssignment.label }}:<br>
{{ form.turnAssignment(class_="fullwidth", rows=10) }}
</p>
<h3>Visibility and Joining</h3>
<p>
{{ flag_setting(form.joinPublic) }}
{{ flag_setting(form.joinOpen) }}
{{ form.joinPassword(autocomplete="off") }}
{{ form.joinPassword.label }}<br>
{{ number_setting(form.joinMaxPlayers) }}
{{ number_setting(form.joinCharsPerPlayer) }}
</p>
<h3>Turn Publishing</h3>
<p>
{{ flag_setting(form.publishNotifyEditorOnReady) }}
{{ flag_setting(form.publishNotifyPlayerOnReject) }}
{{ flag_setting(form.publishNotifyPlayerOnAccept) }}
{{ form.publishDeadlines(autocomplete="off") }}
{{ form.publishDeadlines.label }}<br>
{{ flag_setting(form.publishAsap) }}

View File

@ -158,65 +158,20 @@ class Settings():
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(),
validators=[Optional()]))
s_turnMax = Setting('turn.max',
IntegerField(
'Number of turns',
widget=NumberInput(),
validators=[DataRequired()]))
s_turnAssignment = Setting('turn.assignment',
TextAreaField('index assignment raw'),
translator=TmpAsgnTranslator())
s_joinPublic = Setting('join.public',
BooleanField('Show game on public pages'))
s_joinOpen = Setting('join.open',
BooleanField('Allow players to join game'))
s_joinPassword = Setting('join.password',
StringField('Password to join game', validators=[Optional()]))
s_joinMaxPlayers = Setting('join.max_players',
IntegerField(
'Maximum number of players',
widget=NumberInput(),
validators=[DataRequired()]))
s_joinCharsPerPlayer = Setting('join.chars_per_player',
IntegerField(
'Characters per player',
widget=NumberInput(),
validators=[DataRequired()]))
s_publishNotifyEditorOnReady = Setting('publish.notify_editor_on_ready',
BooleanField(
'Notify the editor when a player marks an article as ready'))
s_publishNotifyPlayerOnReject = Setting('publish.notify_player_on_reject',
BooleanField(
'Notify a player when their article is rejected by the editor'))
s_publishNotifyPlayerOnAccept = Setting('publish.notify_player_on_accept',
BooleanField(
'Notify a player when their article is accepted by the editor'))
s_publishDeadlines = Setting('publish.deadlines',
StringField(
'Turn deadline, as a crontab specification',
@ -236,18 +191,6 @@ class Settings():
BooleanField(
'Block turn publish if any articles are awaiting editor review'))
s_articleIndexList = Setting('article.index.list',
TextAreaField(
'Index specifications',
validators=[IndexList]),
translator=IndexListTranslator())
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'))

View File

@ -15,7 +15,6 @@ def test_character_view(db: DbContext, app: Flask, make: ObjectFactory):
username: str = f"user_{os.urandom(8).hex()}"
charname: str = f"char_{os.urandom(8).hex()}"
char_sig: str = f"signature_{os.urandom(8).hex()}"
# ub: bytes = username.encode("utf8")
with app.test_client() as client:
# Create the user and log in
@ -32,12 +31,12 @@ def test_character_view(db: DbContext, app: Flask, make: ObjectFactory):
assert mem
# The character page exists
list_url = url_for("lexicon.characters.list", name=lexicon.name)
list_url = url_for("lexicon.characters.list", lexicon_name=lexicon.name)
response = client.get(list_url)
assert response.status_code == 200
assert charname.encode("utf8") not in response.data
assert char_sig.encode("utf8") not in response.data
new_url = url_for("lexicon.characters.new", name=lexicon.name)
new_url = url_for("lexicon.characters.new", lexicon_name=lexicon.name)
assert new_url.encode("utf8") in response.data
# The character creation endpoint works
@ -63,7 +62,6 @@ def test_character_view(db: DbContext, app: Flask, make: ObjectFactory):
created_redirect,
data={"name": charname, "signature": char_sig, "csrf_token": csrf_token},
)
print(response.data.decode("utf8"))
assert 300 <= response.status_code <= 399
# The character is updated

91
tests/test_index.py Normal file
View File

@ -0,0 +1,91 @@
from amanuensis.db.models import IndexType
import os
from urllib.parse import urlsplit
from bs4 import BeautifulSoup
from flask import Flask, url_for
from amanuensis.backend import memq, charq, indq
from amanuensis.db import DbContext
from tests.conftest import ObjectFactory
def test_index_view(db: DbContext, app: Flask, make: ObjectFactory):
"""Test the lexicon index page"""
with app.test_client() as client:
# Create the user and log in
user = make.user()
assert user
user_client = make.client(user.id)
assert client
user_client.login(client)
# Create a lexicon and join as the editor
lexicon = make.lexicon()
assert lexicon
mem = memq.create(db, user.id, lexicon.id, is_editor=True)
assert mem
# The index settings page exists
index_settings = url_for("lexicon.settings.index", lexicon_name=lexicon.name)
response = client.get(index_settings)
assert response.status_code == 200
# Add some indices
i1 = indq.create(db, lexicon.id, IndexType.CHAR, "ABCDE", 0, 0, 0)
assert i1
p1 = i1.pattern
assert p1
i2 = indq.create(db, lexicon.id, IndexType.RANGE, "F-M", 0, 0, 0)
assert i2
p2 = i2.pattern
assert p2
i3 = indq.create(db, lexicon.id, IndexType.CHAR, "NOPQ", 0, 0, 0)
assert i3
p3 = i3.pattern
assert p3
db.session.commit()
# The index settings page shows the indices
response = client.get(index_settings)
assert response.status_code == 200
# for i in indq.get_for_lexicon(db, lexicon.id):
assert p1.encode("utf8") in response.data
assert p2.encode("utf8") in response.data
assert p3.encode("utf8") in response.data
# Indices can be modified
soup = BeautifulSoup(response.data, features="html.parser")
csrf_token = soup.find(id="csrf_token")["value"]
assert csrf_token
response = client.post(
index_settings,
data={
"csrf_token": csrf_token,
"indices-0-index_type": "CHAR",
"indices-0-pattern": "ABCDEF",
"indices-0-logical_order": 0,
"indices-0-display_order": 0,
"indices-0-capacity": "",
"indices-1-index_type": "PREFIX",
"indices-1-pattern": "F-M",
"indices-1-logical_order": 1,
"indices-1-display_order": -1,
"indices-1-capacity": "",
"indices-2-index_type": "",
"indices-2-pattern": "NOPQ",
"indices-2-logical_order": 0,
"indices-2-display_order": 0,
"indices-2-capacity": "",
},
)
assert 300 <= response.status_code <= 399
updated_indices = list(indq.get_for_lexicon(db, lexicon.id))
assert len(updated_indices) == 2
assert updated_indices[0].index_type == IndexType.CHAR
assert updated_indices[0].pattern == "ABCDEF"
assert updated_indices[1].index_type == IndexType.PREFIX
assert updated_indices[1].pattern == "F-M"