Add character page and create/edit workflows #18

Merged
Jaculabilis merged 9 commits from tvb/character-page into develop 2021-08-29 15:15:45 +00:00
19 changed files with 323 additions and 111 deletions

View File

@ -2,7 +2,8 @@
Character query interface Character query interface
""" """
from typing import Optional from typing import Optional, Sequence
from uuid import UUID
from sqlalchemy import select, func from sqlalchemy import select, func
@ -68,3 +69,15 @@ def create(
db.session.add(new_character) db.session.add(new_character)
db.session.commit() db.session.commit()
return new_character return new_character
def get_in_lexicon(db: DbContext, lexicon_id: int) -> Sequence[Character]:
"""Get all characters in a lexicon."""
return db(select(Character).where(Character.lexicon_id == lexicon_id)).scalars()
def try_from_public_id(db: DbContext, public_id: UUID) -> Optional[Character]:
"""Get a character by its public id."""
return db(
select(Character).where(Character.public_id == public_id)
).scalar_one_or_none()

View File

@ -2,6 +2,8 @@
Membership query interface Membership query interface
""" """
from typing import Sequence
from sqlalchemy import select, func from sqlalchemy import select, func
from amanuensis.db import DbContext, Membership from amanuensis.db import DbContext, Membership
@ -66,6 +68,11 @@ def create(
return new_membership return new_membership
def get_players_in_lexicon(db: DbContext, lexicon_id: int) -> Sequence[Membership]:
"""Get all users who are members of a lexicon."""
return db(select(Membership).where(Membership.lexicon_id == lexicon_id)).scalars()
def try_from_ids(db: DbContext, user_id: int, lexicon_id: int) -> Membership: def try_from_ids(db: DbContext, user_id: int, lexicon_id: int) -> Membership:
"""Get a membership by the user and lexicon ids, or None if no such membership was found.""" """Get a membership by the user and lexicon ids, or None if no such membership was found."""
return db( return db(

View File

@ -5,6 +5,7 @@ import os
from typing import Callable from typing import Callable
import amanuensis.cli.admin import amanuensis.cli.admin
import amanuensis.cli.character
import amanuensis.cli.lexicon import amanuensis.cli.lexicon
import amanuensis.cli.user import amanuensis.cli.user
from amanuensis.db import DbContext from amanuensis.db import DbContext
@ -50,7 +51,7 @@ def add_subcommand(subparsers, module) -> None:
command_parser: ArgumentParser = subparsers.add_parser( command_parser: ArgumentParser = subparsers.add_parser(
command_name, help=command_help command_name, help=command_help
) )
command_parser.set_defaults(func=lambda args: command_parser.print_usage()) command_parser.set_defaults(func=lambda args: command_parser.print_help())
# Add all subcommands in the command module # Add all subcommands in the command module
subcommands = command_parser.add_subparsers(metavar="SUBCOMMAND") subcommands = command_parser.add_subparsers(metavar="SUBCOMMAND")
@ -97,7 +98,7 @@ def main():
parser = ArgumentParser() parser = ArgumentParser()
parser.set_defaults( parser.set_defaults(
parser=parser, parser=parser,
func=lambda args: parser.print_usage(), func=lambda args: parser.print_help(),
get_db=None, get_db=None,
) )
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
@ -108,6 +109,7 @@ def main():
# Add commands from cli submodules # Add commands from cli submodules
subparsers = parser.add_subparsers(metavar="COMMAND") subparsers = parser.add_subparsers(metavar="COMMAND")
add_subcommand(subparsers, amanuensis.cli.admin) add_subcommand(subparsers, amanuensis.cli.admin)
add_subcommand(subparsers, amanuensis.cli.character)
add_subcommand(subparsers, amanuensis.cli.lexicon) add_subcommand(subparsers, amanuensis.cli.lexicon)
add_subcommand(subparsers, amanuensis.cli.user) add_subcommand(subparsers, amanuensis.cli.user)

View File

@ -0,0 +1,31 @@
import logging
from amanuensis.backend import lexiq, userq, charq
from amanuensis.db import DbContext, Character
from .helpers import add_argument
COMMAND_NAME = "char"
COMMAND_HELP = "Interact with characters."
LOG = logging.getLogger(__name__)
@add_argument("--lexicon", required=True)
@add_argument("--user", required=True)
@add_argument("--name", required=True)
def command_create(args) -> int:
"""
Create a character.
"""
db: DbContext = args.get_db()
lexicon = lexiq.try_from_name(db, args.lexicon)
if not lexicon:
raise ValueError("Lexicon does not exist")
user = userq.try_from_username(db, args.user)
if not user:
raise ValueError("User does not exist")
char: Character = charq.create(db, lexicon.id, user.id, args.name, signature=None)
LOG.info(f"Created {char.name} in {lexicon.full_title}")
return 0

View File

@ -49,6 +49,7 @@ def command_create(args):
@add_argument("--no-public", dest="public", action="store_const", const=False) @add_argument("--no-public", dest="public", action="store_const", const=False)
@add_argument("--join", dest="join", action="store_const", const=True) @add_argument("--join", dest="join", action="store_const", const=True)
@add_argument("--no-join", dest="join", action="store_const", const=False) @add_argument("--no-join", dest="join", action="store_const", const=False)
@add_argument("--char-limit", type=int, default=None)
def command_edit(args): def command_edit(args):
""" """
Update a lexicon's configuration. Update a lexicon's configuration.
@ -66,6 +67,9 @@ def command_edit(args):
elif args.join == False: elif args.join == False:
values["joinable"] = False values["joinable"] = False
if args.char_limit:
values["character_limit"] = args.char_limit
result = db(update(Lexicon).where(Lexicon.name == args.name).values(**values)) result = db(update(Lexicon).where(Lexicon.name == args.name).values(**values))
LOG.info(f"Updated {result.rowcount} lexicons") LOG.info(f"Updated {result.rowcount} lexicons")
db.session.commit() db.session.commit()

View File

@ -30,6 +30,7 @@ class Uuid(TypeDecorator):
""" """
impl = CHAR(32) impl = CHAR(32)
cache_ok = True
def process_bind_param(self, value, dialect): def process_bind_param(self, value, dialect):
if value is None: if value is None:

View File

@ -95,9 +95,6 @@ div.contentblock {
border-radius: 5px; border-radius: 5px;
word-break: break-word; word-break: break-word;
} }
div.contentblock h3 {
margin: 0.3em 0;
}
a.phantom { a.phantom {
color: #cc2200; color: #cc2200;
} }
@ -126,6 +123,34 @@ div.dashboard-lexicon-item {
padding: 0 10px; padding: 0 10px;
border-left: 3px solid black; border-left: 3px solid black;
} }
ul.blockitem-list {
list-style: none;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0.5em;
margin-inline-end: 0.5em;
padding-inline-start: 0;
padding-inline-end: 0;
}
ul.blockitem-list li {
border-inline-start: 3px solid black;
padding-inline-start: 0.5em;
}
ul.blockitem-list * {
margin-block-start: 0.5em;
margin-block-end: 0.5em;
}
ul.blockitem-list pre {
background-color: lightgray;
padding-block-start: 2px;
padding-block-end: 2px;
padding-inline-start: 2px;
padding-inline-end: 2px;
border: 1px solid gray;
border-radius: 2px;
font-size: smaller;
white-space: break-spaces;
}
div.dashboard-lexicon-unstarted { div.dashboard-lexicon-unstarted {
border-left-color: blue; border-left-color: blue;
} }

View File

@ -4,7 +4,7 @@ import os
from flask import Flask, g, url_for, redirect from flask import Flask, g, url_for, redirect
from amanuensis.backend import lexiq, userq, memq from amanuensis.backend import *
from amanuensis.config import AmanuensisConfig, CommandLineConfig from amanuensis.config import AmanuensisConfig, CommandLineConfig
from amanuensis.db import DbContext from amanuensis.db import DbContext
from amanuensis.parser import filesafe_title from amanuensis.parser import filesafe_title
@ -68,7 +68,7 @@ def get_app(
# Configure jinja options # Configure jinja options
def include_backend(): def include_backend():
return {"db": db, "lexiq": lexiq, "userq": userq, "memq": memq} return {"db": db, "lexiq": lexiq, "userq": userq, "memq": memq, "charq": charq}
app.jinja_options.update(trim_blocks=True, lstrip_blocks=True) app.jinja_options.update(trim_blocks=True, lstrip_blocks=True)
app.template_filter("date")(date_format) app.template_filter("date")(date_format)

View File

@ -88,7 +88,7 @@ def editor_required(route):
user: User = current_user user: User = current_user
lexicon: Lexicon = g.lexicon lexicon: Lexicon = g.lexicon
mem: Optional[Membership] = memq.try_from_ids(db, user.id, lexicon.id) mem: Optional[Membership] = memq.try_from_ids(db, user.id, lexicon.id)
if not mem.is_editor: if not mem or not mem.is_editor:
flash("You must be the editor to view this page") 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', name=lexicon.name))
return route(*args, **kwargs) return route(*args, **kwargs)

View File

@ -7,8 +7,10 @@
{% endblock %} {% endblock %}
{% block sb_logo %}{% endblock %} {% block sb_logo %}{% endblock %}
{% block sb_home %}<a href="{{ url_for('home.home') }}">Home</a> {% block sb_characters %}<a
{% endblock %} {% if current_page == "characters" %}class="current-page"
{% else %}href="{{ url_for('lexicon.characters.list', name=g.lexicon.name) }}"
{% endif %}>Characters</a>{% endblock %}
{% block sb_contents %}<a {% block sb_contents %}<a
{% if current_page == "contents" %}class="current-page" {% if current_page == "contents" %}class="current-page"
{% else %}href="{{ url_for('lexicon.contents', name=g.lexicon.name) }}" {% else %}href="{{ url_for('lexicon.contents', name=g.lexicon.name) }}"
@ -32,7 +34,7 @@
) %} ) %}
{# self.sb_logo(), #} {# self.sb_logo(), #}
{% set template_sidebar_rows = [ {% set template_sidebar_rows = [
self.sb_home(), self.sb_characters(),
self.sb_contents(), self.sb_contents(),
self.sb_rules(), self.sb_rules(),
self.sb_session(), self.sb_session(),
@ -40,7 +42,7 @@
{% else %} {% else %}
{# self.sb_logo(), #} {# self.sb_logo(), #}
{% set template_sidebar_rows = [ {% set template_sidebar_rows = [
self.sb_home(), self.sb_characters(),
self.sb_contents(), self.sb_contents(),
self.sb_rules(), self.sb_rules(),
self.sb_stats()] %} self.sb_stats()] %}

View File

@ -6,10 +6,12 @@ from amanuensis.db import DbContext, Lexicon, User
from amanuensis.errors import ArgumentError from amanuensis.errors import ArgumentError
from amanuensis.server.helpers import lexicon_param, player_required_if_not_public from amanuensis.server.helpers import lexicon_param, player_required_if_not_public
from .characters import bp as characters_bp
from .forms import LexiconJoinForm from .forms import LexiconJoinForm
bp = Blueprint("lexicon", __name__, url_prefix="/lexicon/<name>", template_folder=".") bp = Blueprint("lexicon", __name__, url_prefix="/lexicon/<name>", template_folder=".")
bp.register_blueprint(characters_bp)
@bp.route("/join/", methods=["GET", "POST"]) @bp.route("/join/", methods=["GET", "POST"])

View File

@ -0,0 +1,74 @@
from typing import Optional
import uuid
from flask import Blueprint, render_template, url_for, g, flash
from flask_login import current_user
from werkzeug.utils import redirect
from amanuensis.backend import charq
from amanuensis.db import Character
from amanuensis.server.helpers import lexicon_param, player_required
from .forms import CharacterCreateForm
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)
@bp.route("/edit/<character_id>", methods=["GET", "POST"])
@lexicon_param
@player_required
def edit(name, character_id):
try:
char_uuid = uuid.UUID(character_id)
except:
flash("Character not found")
return redirect(url_for("lexicon.characters.list", name=name))
character: Optional[Character] = charq.try_from_public_id(g.db, char_uuid)
if not character:
flash("Character not found")
return redirect(url_for("lexicon.characters.list", name=name))
form = CharacterCreateForm()
if not form.is_submitted():
# GET
form.name.data = character.name
form.signature.data = character.signature
return render_template("characters.edit.jinja", character=character, form=form)
else:
# POST
if form.validate():
# 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))
else:
# POST submitted invalid data
return render_template(
"characters.edit.jinja", character=character, form=form
)
@bp.get("/new/")
@lexicon_param
@player_required
def new(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)
)

View File

@ -0,0 +1,24 @@
{% extends "lexicon.jinja" %}
{% block title %}Edit {{ character.name }} | {{ lexicon_title }}{% endblock %}
{% block main %}
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ form.name.label }}<br>{{ form.name(size=32) }}
</p>
{% for error in form.name.errors %}
<span style="color: #ff0000">{{ error }}</span><br>
{% endfor %}</p>
<p>
{{ form.signature.label }}<br>{{ form.signature(class_='fullwidth') }}
</p>
<p>{{ form.submit() }}</p>
</form>
{% for message in get_flashed_messages() %}
<span style="color:#ff0000">{{ message }}</span><br>
{% endfor %}
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -0,0 +1,34 @@
{% extends "lexicon.jinja" %}
{% set current_page = "characters" %}
{% block title %}Character | {{ lexicon_title }}{% endblock %}
{% block main %}
<h1>Characters</h1>
{% set players = memq.get_players_in_lexicon(db, g.lexicon.id)|list %}
{% set characters = charq.get_in_lexicon(db, g.lexicon.id)|list %}
<p>This lexicon has <b>{{ players|count }}</b> player{% if players|count > 1 %}s{% endif %} and <b>{{ characters|count }}</b> character{% if characters|count > 1 %}s{% endif %}.</p>
{% for message in get_flashed_messages() %}
<span style="color:#ff0000">{{ message }}</span><br>
{% endfor %}
<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>
<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 %}
{% for character in characters %}
<li>
<h3>{{ character.name }}</h3>
{% if character.user == current_user %}
<pre>{{ character.signature }}</pre>
{% 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>
{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}
{% set template_content_blocks = [self.main()] %}

View File

@ -0,0 +1,11 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField
from wtforms.validators import DataRequired
class CharacterCreateForm(FlaskForm):
"""/lexicon/<name>/characters/edit/<character_id>"""
name = StringField("Character name", validators=[DataRequired()])
signature = TextAreaField("Signature")
submit = SubmitField("Submit")

View File

@ -13,8 +13,13 @@
<div id="login-status" {% block login_status_attr %}{% endblock %}> <div id="login-status" {% block login_status_attr %}{% endblock %}>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<b>{{ current_user.username -}}</b> <b>{{ current_user.username -}}</b>
(<a href="{{ url_for('auth.logout') }}">Logout</a>) &#8231;
<a href="{{ url_for('home.home') }}">Home</a>
&#8231;
<a href="{{ url_for('auth.logout') }}">Logout</a>
{% else %} {% else %}
<a href="{{ url_for('home.home') }}">Home</a>
&#8231;
<a href="{{ url_for('auth.login') }}">Login</a> <a href="{{ url_for('auth.login') }}">Login</a>
{% endif %} {% endif %}
</div> </div>

View File

@ -68,77 +68,6 @@ def session(name):
publish_form=form) publish_form=form)
def edit_character(name, form, character):
if not form.is_submitted():
# GET, populate with values
return render_template(
'session.character.jinja', form=form.for_character(character))
if not form.validate():
# POST with invalid data, return unchanged
return render_template('session.character.jinja', form=form)
# POST with valid data, update character
with g.lexicon.ctx.edit_config() as cfg:
char = cfg.character[character.cid]
char.name = form.characterName.data
char.signature = form.defaultSignature.data
flash('Character updated')
return redirect(url_for('session.session', name=name))
def create_character(name: str, form: LexiconCharacterForm):
# Characters can't be created if the game has already started
if g.lexicon.status != LexiconModel.PREGAME:
flash("Characters can't be added after the game has started")
return redirect(url_for('session.session', name=name))
# Characters can't be created beyond the per-player limit
player_characters = get_player_characters(g.lexicon, current_user.uid)
if len(list(player_characters)) >= g.lexicon.cfg.join.chars_per_player:
flash("Can't create more characters")
return redirect(url_for('session.session', name=name))
if not form.is_submitted():
# GET, populate with default values
return render_template(
'session.character.jinja', form=form.for_new())
if not form.validate():
# POST with invalid data, return unchanged
return render_template('session.character.jinja', form=form)
# POST with valid data, create character
char_name = form.characterName.data
cid = create_character_in_lexicon(current_user, g.lexicon, char_name)
with g.lexicon.ctx.edit_config() as cfg:
cfg.character[cid].signature = form.defaultSignature.data
flash('Character created')
return redirect(url_for('session.session', name=name))
@bp_session.route('/character/', methods=['GET', 'POST'])
@lexicon_param
@player_required
def character(name):
form = LexiconCharacterForm()
cid = request.args.get('cid')
if not cid:
# No character specified, creating a new character
return create_character(name, form)
character = g.lexicon.cfg.character.get(cid)
if not character:
# Bad character id, abort
flash('Character not found')
return redirect(url_for('session.session', name=name))
if current_user.uid not in (character.player, g.lexicon.cfg.editor):
# Only its owner and the editor can edit a character
flash('Access denied')
return redirect(url_for('session.session', name=name))
# Edit allowed
return edit_character(name, form, character)
@bp_session.route('/settings/', methods=['GET', 'POST']) @bp_session.route('/settings/', methods=['GET', 'POST'])
@lexicon_param @lexicon_param
@editor_required @editor_required

View File

@ -1,26 +0,0 @@
{% extends "lexicon.jinja" %}
{% block title %}Character | {{ lexicon_title }}{% endblock %}
{% block main %}
<h1>Character</h1>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ form.characterName.label }}<br>{{ form.characterName(size=32) }}
</p>
{% for error in form.characterName.errors %}
<span style="color: #ff0000">{{ error }}</span><br>
{% endfor %}</p>
<p>
{{ form.defaultSignature.label }}<br>{{ form.defaultSignature(class_='fullwidth') }}
</p>
<p>{{ form.submit() }}</p>
</form>
{% for message in get_flashed_messages() %}
<span style="color:#ff0000">{{ message }}</span><br>
{% endfor %}
{% endblock %}
{% set template_content_blocks = [self.main()] %}

74
tests/test_character.py Normal file
View File

@ -0,0 +1,74 @@
import os
from urllib.parse import urlsplit
from bs4 import BeautifulSoup
from flask import Flask, url_for
from amanuensis.backend import memq, charq
from amanuensis.db import DbContext
from tests.conftest import ObjectFactory
def test_character_view(db: DbContext, app: Flask, make: ObjectFactory):
"""Test the lexicon character list, create, and edit pages."""
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
user = make.user(username=username, password=username)
assert user
user_client = make.client(user.id)
assert client
user_client.login(client)
# Create a lexicon and join
lexicon = make.lexicon()
assert lexicon
mem = memq.create(db, user.id, lexicon.id, is_editor=False)
assert mem
# The character page exists
list_url = url_for("lexicon.characters.list", 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)
assert new_url.encode("utf8") in response.data
# The character creation endpoint works
response = client.get(new_url)
assert response.status_code == 302
chars = list(charq.get_in_lexicon(db, lexicon.id))
assert len(chars) == 1
assert chars[0].user_id == user.id
created_redirect = response.location
assert str(chars[0].public_id) in created_redirect
# The character edit page works
response = client.get(created_redirect)
assert chars[0].name.encode("utf8") in response.data
assert chars[0].signature.encode("utf8") in response.data
assert b"csrf_token" in response.data
# Submitting the edit page works
soup = BeautifulSoup(response.data, features="html.parser")
csrf_token = soup.find(id="csrf_token")["value"]
assert csrf_token
response = client.post(
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
chars = list(charq.get_in_lexicon(db, lexicon.id))
assert len(chars) == 1
assert chars[0].user_id == user.id
assert chars[0].name == charname
assert chars[0].signature == char_sig